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, `imara-diff` for output compatible with upstream Git.
20//! Output formats: unified patch, raw (`:old-mode new-mode ...`), stat,
21//! numstat.
22
23use crate::git_path::CanonicalizeExt;
24use std::borrow::Cow;
25use std::collections::HashSet;
26use std::fs;
27#[cfg(unix)]
28use std::os::unix::fs::MetadataExt;
29use std::path::{Path, PathBuf};
30
31use crate::config::ConfigSet;
32use crate::diff_indent_heuristic;
33use crate::diff_rename_parallel::{
34    diff_rename_parallel_options, inexact_matrix_exceeds_limit, parallel_load_optional_blobs,
35    parallel_map_indices, parse_rename_limit,
36};
37use crate::error::{Error, Result};
38use crate::index::{CacheTreeNode, Index, IndexEntry, MODE_SYMLINK};
39use crate::objects::{
40    parse_commit, CommitData, FileMode, ObjectId, ObjectKind, TreeEntry, TreeRefIter,
41};
42use crate::odb::Odb;
43use crate::parallel::ThreadPolicy;
44use crate::repo::Repository;
45use crate::userdiff::FuncnameMatcher;
46use crate::worktree_rules::WorktreeRules;
47
48use super::rename_lines::RenameLineTable;
49use super::unified_emit::{
50    emit_unified_body_from_edits, line_ending_only_edits, EditSpan, LineSide, UnifiedEmitOptions,
51};
52
53/// Comparison keys for every line of `side`, which must be [`LineSide::from_text`] of `text`.
54///
55/// Without whitespace normalization a key is the raw line including its `\n` (the same bytes as
56/// `line_diff_token`), borrowed from `text`. Under `-w`/`-b`/`--ignore-space-at-eol` the keys
57/// are normalized line bodies, kept in `normalized` so the returned slices can borrow them.
58fn line_compare_keys<'a>(
59    text: &'a str,
60    side: &LineSide<'a>,
61    ws: super::WhitespaceMode,
62    normalized: &'a mut Vec<Cow<'a, str>>,
63) -> Vec<&'a str> {
64    if ws.normalizes_line_compare() {
65        // Git -w/-b compare normalized bodies only; EOF newline is whitespace.
66        normalized.extend(
67            side.lines
68                .iter()
69                .map(|line| ws.normalize_line_for_compare(line.body)),
70        );
71        return normalized.iter().map(Cow::as_ref).collect();
72    }
73    let mut keys = Vec::with_capacity(side.len());
74    let mut rest = text;
75    while !rest.is_empty() {
76        let end = memchr::memchr(b'\n', rest.as_bytes()).map_or(rest.len(), |nl| nl + 1);
77        let (line, tail) = rest.split_at(end);
78        keys.push(line);
79        rest = tail;
80    }
81    debug_assert_eq!(keys.len(), side.len());
82    keys
83}
84
85#[path = "../structured_diff.rs"]
86pub mod structured;
87
88pub(crate) fn imara_unified_hunk_slices(body: &str) -> Vec<&str> {
89    let mut starts: Vec<usize> = Vec::new();
90    if body.starts_with("@@") {
91        starts.push(0);
92    }
93    for (idx, _) in body.match_indices("\n@@ ") {
94        starts.push(idx + 1);
95    }
96    starts.push(body.len());
97    starts.windows(2).map(|w| &body[w[0]..w[1]]).collect()
98}
99
100/// Histogram line alignment (Git `xhistogram.c`), then `xdl_change_compact` and indent heuristic.
101pub(crate) fn histogram_compacted_line_ops(
102    old_content: &str,
103    new_content: &str,
104    indent_heuristic: bool,
105    ws: super::WhitespaceMode,
106) -> Vec<similar::DiffOp> {
107    let before = LineSide::from_text(old_content);
108    let after = LineSide::from_text(new_content);
109    let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
110    let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
111    let old_len = old_lines.len();
112    let new_len = new_lines.len();
113    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
114    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
115    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
116
117    let (changed1, changed2) = git_xdiff::histogram_changed_flags(&old_compare, &new_compare);
118
119    if !changed1.iter().any(|c| *c) && !changed2.iter().any(|c| *c) {
120        return Vec::new();
121    }
122    let raw = changed_flags_to_ops(&changed1, &changed2, old_len, new_len);
123    diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
124        &raw,
125        &old_lines,
126        &new_lines,
127        indent_heuristic,
128        ws,
129        true,
130    )
131}
132
133pub(crate) fn histogram_unified_body_raw(
134    old_content: &str,
135    new_content: &str,
136    context_lines: usize,
137    inter_hunk_context: usize,
138    indent_heuristic: bool,
139    ws: super::WhitespaceMode,
140) -> String {
141    let before_side = LineSide::from_text(old_content);
142    let after_side = LineSide::from_text(new_content);
143    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
144    let mut edits = diff_ops_to_edit_spans(&ops);
145    if edits.is_empty() {
146        if old_content != new_content && !ws.normalizes_line_compare() {
147            edits = line_ending_only_edits(&before_side, &after_side);
148        }
149        if edits.is_empty() {
150            return String::new();
151        }
152    }
153    emit_unified_body_from_edits(
154        &before_side,
155        &after_side,
156        &edits,
157        UnifiedEmitOptions {
158            context_lines,
159            inter_hunk_context,
160            function_context: false,
161        },
162        None,
163    )
164}
165
166/// Build the unified-diff body (no `---`/`+++` header) using Git's histogram
167/// algorithm, but applying `--ignore-blank-lines` / `-I` semantics through a
168/// Git-compatible implementation of the xdiff change-record machinery
169/// (`xdl_mark_ignorable_lines` + `xdl_get_hunk` + `xdl_emit_diff`).
170///
171/// `is_ignorable_change` is called with the slices of removed and added lines
172/// for one change record (each item is the line text without its trailing
173/// newline); it returns `true` when the whole change is ignorable (Git's
174/// `xch->ignore`). Ignorable changes that are far from substantive changes are
175/// dropped, and hunk boundaries are computed with Git's `max_ignorable`
176/// distance rules so the line numbers and surviving context match Git exactly.
177///
178/// Returns the body only; an empty string means no substantive change survives.
179/// Optional `--function-context` (`-W`) hunk expansion is applied *after*
180/// ignorable change-record pruning so the boundaries/funcname header match Git.
181#[allow(clippy::too_many_arguments)]
182fn diff_ops_to_ignorable_edit_spans<F>(
183    ops: &[similar::DiffOp],
184    old_lines: &[&str],
185    new_lines: &[&str],
186    is_ignorable_change: F,
187) -> Vec<EditSpan>
188where
189    F: Fn(&[&str], &[&str]) -> bool,
190{
191    use similar::DiffOp;
192    let mut out = Vec::new();
193    for op in ops {
194        match *op {
195            DiffOp::Equal { .. } => {}
196            DiffOp::Delete {
197                old_index,
198                old_len,
199                new_index,
200            } => {
201                let removed = &old_lines[old_index..old_index + old_len];
202                out.push(EditSpan {
203                    before_start: old_index as i64,
204                    before_len: old_len as i64,
205                    after_start: new_index as i64,
206                    after_len: 0,
207                    skip_in_output: is_ignorable_change(removed, &[]),
208                });
209            }
210            DiffOp::Insert {
211                old_index,
212                new_index,
213                new_len,
214            } => {
215                let added = &new_lines[new_index..new_index + new_len];
216                out.push(EditSpan {
217                    before_start: old_index as i64,
218                    before_len: 0,
219                    after_start: new_index as i64,
220                    after_len: new_len as i64,
221                    skip_in_output: is_ignorable_change(&[], added),
222                });
223            }
224            DiffOp::Replace {
225                old_index,
226                old_len,
227                new_index,
228                new_len,
229            } => {
230                let removed = &old_lines[old_index..old_index + old_len];
231                let added = &new_lines[new_index..new_index + new_len];
232                out.push(EditSpan {
233                    before_start: old_index as i64,
234                    before_len: old_len as i64,
235                    after_start: new_index as i64,
236                    after_len: new_len as i64,
237                    skip_in_output: is_ignorable_change(removed, added),
238                });
239            }
240        }
241    }
242    out
243}
244
245#[allow(clippy::too_many_arguments)]
246pub(crate) fn histogram_unified_body_ignore_fc<F>(
247    old_content: &str,
248    new_content: &str,
249    context_lines: usize,
250    inter_hunk_context: usize,
251    function_context: bool,
252    funcname_matcher: Option<&FuncnameMatcher>,
253    indent_heuristic: bool,
254    ws: super::WhitespaceMode,
255    is_ignorable_change: F,
256) -> String
257where
258    F: Fn(&[&str], &[&str]) -> bool,
259{
260    let before = LineSide::from_text(old_content);
261    let after = LineSide::from_text(new_content);
262    let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
263    let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
264    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
265    if ops.is_empty() {
266        return String::new();
267    }
268    let edits = diff_ops_to_ignorable_edit_spans(&ops, &old_lines, &new_lines, is_ignorable_change);
269    emit_unified_body_from_edits(
270        &before,
271        &after,
272        &edits,
273        UnifiedEmitOptions {
274            context_lines,
275            inter_hunk_context,
276            function_context,
277        },
278        funcname_matcher,
279    )
280}
281
282pub(crate) fn git_xdiff_compacted_line_ops(
283    old_compare: &[&str],
284    new_compare: &[&str],
285    old_lines: &[&str],
286    new_lines: &[&str],
287    indent_heuristic: bool,
288    need_min: bool,
289) -> Vec<similar::DiffOp> {
290    use crate::line_interner::LineInterner;
291
292    let mut interner = LineInterner::with_capacity(old_compare.len() + new_compare.len());
293    let ids1 = interner.intern_all(old_compare);
294    let ids2 = interner.intern_all(new_compare);
295    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, need_min);
296    diff_indent_heuristic::apply_change_compact_to_masks(
297        changed1,
298        changed2,
299        old_lines,
300        new_lines,
301        indent_heuristic,
302    )
303}
304
305fn diff_ops_to_edit_spans(ops: &[similar::DiffOp]) -> Vec<EditSpan> {
306    use similar::DiffOp;
307    let mut out = Vec::new();
308    for op in ops {
309        match *op {
310            DiffOp::Equal { .. } => {}
311            DiffOp::Delete {
312                old_index,
313                old_len,
314                new_index,
315            } => out.push(EditSpan {
316                before_start: old_index as i64,
317                before_len: old_len as i64,
318                after_start: new_index as i64,
319                after_len: 0,
320                skip_in_output: false,
321            }),
322            DiffOp::Insert {
323                old_index,
324                new_index,
325                new_len,
326            } => out.push(EditSpan {
327                before_start: old_index as i64,
328                before_len: 0,
329                after_start: new_index as i64,
330                after_len: new_len as i64,
331                skip_in_output: false,
332            }),
333            DiffOp::Replace {
334                old_index,
335                old_len,
336                new_index,
337                new_len,
338            } => out.push(EditSpan {
339                before_start: old_index as i64,
340                before_len: old_len as i64,
341                after_start: new_index as i64,
342                after_len: new_len as i64,
343                skip_in_output: false,
344            }),
345        }
346    }
347    out
348}
349
350/// Unified hunk body using Git's xdiff engine (no `---` / `+++`).
351fn git_xdiff_unified_body_raw(
352    old_content: &str,
353    new_content: &str,
354    context_lines: usize,
355    inter_hunk_context: usize,
356    indent_heuristic: bool,
357    need_min: bool,
358    ws: super::WhitespaceMode,
359) -> String {
360    git_xdiff_unified_body_raw_with_ignorable(
361        old_content,
362        new_content,
363        context_lines,
364        inter_hunk_context,
365        indent_heuristic,
366        need_min,
367        ws,
368        None::<fn(&[&str], &[&str]) -> bool>,
369    )
370}
371
372#[allow(clippy::too_many_arguments)]
373fn git_xdiff_unified_body_raw_with_ignorable<F>(
374    old_content: &str,
375    new_content: &str,
376    context_lines: usize,
377    inter_hunk_context: usize,
378    indent_heuristic: bool,
379    need_min: bool,
380    ws: super::WhitespaceMode,
381    is_ignorable_change: Option<F>,
382) -> String
383where
384    F: Fn(&[&str], &[&str]) -> bool,
385{
386    let before = LineSide::from_text(old_content);
387    let after = LineSide::from_text(new_content);
388    if before.is_empty() && after.is_empty() {
389        return String::new();
390    }
391    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
392    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
393    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
394    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
395    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
396    let ops = git_xdiff_compacted_line_ops(
397        &old_compare,
398        &new_compare,
399        &old_refs,
400        &new_refs,
401        indent_heuristic,
402        need_min,
403    );
404    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
405        diff_ops_to_ignorable_edit_spans(&ops, &old_refs, &new_refs, is_ignorable)
406    } else {
407        diff_ops_to_edit_spans(&ops)
408    };
409    if edits.is_empty() {
410        if old_content != new_content && !ws.normalizes_line_compare() {
411            edits = line_ending_only_edits(&before, &after);
412        }
413        if edits.is_empty() {
414            return String::new();
415        }
416    }
417    emit_unified_body_from_edits(
418        &before,
419        &after,
420        &edits,
421        UnifiedEmitOptions {
422            context_lines,
423            inter_hunk_context,
424            function_context: false,
425        },
426        None,
427    )
428}
429
430#[allow(clippy::too_many_arguments)]
431fn git_patience_unified_body_raw_with_ignorable<F>(
432    old_content: &str,
433    new_content: &str,
434    context_lines: usize,
435    inter_hunk_context: usize,
436    indent_heuristic: bool,
437    ws: super::WhitespaceMode,
438    is_ignorable_change: Option<F>,
439) -> String
440where
441    F: Fn(&[&str], &[&str]) -> bool,
442{
443    let before = LineSide::from_text(old_content);
444    let after = LineSide::from_text(new_content);
445    if before.is_empty() && after.is_empty() {
446        return String::new();
447    }
448    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
449    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
450    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
451    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
452    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
453    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
454    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
455    let ops = if ws.normalizes_line_compare() {
456        diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
457            &raw,
458            &old_refs,
459            &new_refs,
460            indent_heuristic,
461            ws,
462        )
463    } else {
464        diff_indent_heuristic::apply_change_compact_to_ops(
465            &raw,
466            &old_refs,
467            &new_refs,
468            indent_heuristic,
469        )
470    };
471    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
472        diff_ops_to_ignorable_edit_spans(&ops, &old_refs, &new_refs, is_ignorable)
473    } else {
474        diff_ops_to_edit_spans(&ops)
475    };
476    if edits.is_empty() {
477        if old_content != new_content && !ws.normalizes_line_compare() {
478            edits = line_ending_only_edits(&before, &after);
479        }
480        if edits.is_empty() {
481            return String::new();
482        }
483    }
484    emit_unified_body_from_edits(
485        &before,
486        &after,
487        &edits,
488        UnifiedEmitOptions {
489            context_lines,
490            inter_hunk_context,
491            function_context: false,
492        },
493        None,
494    )
495}
496
497fn blank_line_ignorable(ws: super::WhitespaceMode) -> impl Fn(&[&str], &[&str]) -> bool + Copy {
498    move |removed: &[&str], added: &[&str]| {
499        ws.ignore_blank_lines
500            && removed.iter().all(|l| crate::ws::ws_blank_line(l))
501            && added.iter().all(|l| crate::ws::ws_blank_line(l))
502    }
503}
504
505/// Unified hunk bodies (`@@` …) for [`DiffOptions`], shared by structured and log-patch output.
506pub(crate) fn unified_hunk_body_for_diff_options(
507    old_content: &str,
508    new_content: &str,
509    options: &crate::diff::options::DiffOptions,
510) -> String {
511    use crate::diff::options::DiffAlgorithm;
512
513    let ws = options.whitespace;
514    if ws.is_active() {
515        let blank_only = blank_line_ignorable(ws);
516        if ws.normalizes_line_compare() {
517            let ignorable = ws.ignore_blank_lines.then_some(blank_only);
518            return match options.algorithm {
519                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
520                    old_content,
521                    new_content,
522                    options.context_lines,
523                    options.inter_hunk_context,
524                    options.indent_heuristic,
525                    false,
526                    ws,
527                    ignorable,
528                ),
529                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
530                    old_content,
531                    new_content,
532                    options.context_lines,
533                    options.inter_hunk_context,
534                    options.indent_heuristic,
535                    true,
536                    ws,
537                    ignorable,
538                ),
539                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
540                    old_content,
541                    new_content,
542                    options.context_lines,
543                    options.inter_hunk_context,
544                    options.indent_heuristic,
545                    ws,
546                    ignorable,
547                ),
548                DiffAlgorithm::Histogram => {
549                    if ws.ignore_blank_lines {
550                        histogram_unified_body_ignore_fc(
551                            old_content,
552                            new_content,
553                            options.context_lines,
554                            options.inter_hunk_context,
555                            false,
556                            None,
557                            options.indent_heuristic,
558                            ws,
559                            blank_only,
560                        )
561                    } else {
562                        histogram_unified_body_raw(
563                            old_content,
564                            new_content,
565                            options.context_lines,
566                            options.inter_hunk_context,
567                            options.indent_heuristic,
568                            ws,
569                        )
570                    }
571                }
572            };
573        }
574        if ws.ignore_blank_lines {
575            return match options.algorithm {
576                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
577                    old_content,
578                    new_content,
579                    options.context_lines,
580                    options.inter_hunk_context,
581                    options.indent_heuristic,
582                    false,
583                    ws,
584                    Some(blank_only),
585                ),
586                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
587                    old_content,
588                    new_content,
589                    options.context_lines,
590                    options.inter_hunk_context,
591                    options.indent_heuristic,
592                    true,
593                    ws,
594                    Some(blank_only),
595                ),
596                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
597                    old_content,
598                    new_content,
599                    options.context_lines,
600                    options.inter_hunk_context,
601                    options.indent_heuristic,
602                    ws,
603                    Some(blank_only),
604                ),
605                DiffAlgorithm::Histogram => histogram_unified_body_ignore_fc(
606                    old_content,
607                    new_content,
608                    options.context_lines,
609                    options.inter_hunk_context,
610                    false,
611                    None,
612                    options.indent_heuristic,
613                    ws,
614                    blank_only,
615                ),
616            };
617        }
618    }
619    match options.algorithm {
620        DiffAlgorithm::Myers => git_xdiff_unified_body_raw(
621            old_content,
622            new_content,
623            options.context_lines,
624            options.inter_hunk_context,
625            options.indent_heuristic,
626            false,
627            ws,
628        ),
629        DiffAlgorithm::Minimal => git_xdiff_unified_body_raw(
630            old_content,
631            new_content,
632            options.context_lines,
633            options.inter_hunk_context,
634            options.indent_heuristic,
635            true,
636            ws,
637        ),
638        DiffAlgorithm::Patience => git_patience_unified_body_raw(
639            old_content,
640            new_content,
641            options.context_lines,
642            options.inter_hunk_context,
643            options.indent_heuristic,
644        ),
645        DiffAlgorithm::Histogram => histogram_unified_body_raw(
646            old_content,
647            new_content,
648            options.context_lines,
649            options.inter_hunk_context,
650            options.indent_heuristic,
651            ws,
652        ),
653    }
654}
655
656/// Unified hunk body using Git's patience diff (no `---` / `+++`).
657fn git_patience_unified_body_raw(
658    old_content: &str,
659    new_content: &str,
660    context_lines: usize,
661    inter_hunk_context: usize,
662    indent_heuristic: bool,
663) -> String {
664    let before = LineSide::from_text(old_content);
665    let after = LineSide::from_text(new_content);
666    if before.is_empty() && after.is_empty() {
667        return String::new();
668    }
669    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
670    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
671    let ws = super::WhitespaceMode::default();
672    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
673    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
674    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
675    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
676    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
677    let ops = diff_indent_heuristic::apply_change_compact_to_ops(
678        &raw,
679        &old_refs,
680        &new_refs,
681        indent_heuristic,
682    );
683    let mut edits = diff_ops_to_edit_spans(&ops);
684    if edits.is_empty() {
685        if old_content != new_content {
686            edits = line_ending_only_edits(&before, &after);
687        }
688        if edits.is_empty() {
689            return String::new();
690        }
691    }
692    emit_unified_body_from_edits(
693        &before,
694        &after,
695        &edits,
696        UnifiedEmitOptions {
697            context_lines,
698            inter_hunk_context,
699            function_context: false,
700        },
701        None,
702    )
703}
704
705/// Parse `@@ -old +new @@` into 1-based starts and line counts (Git display form).
706pub(crate) fn unified_hunk_header_counts(
707    header_line: &str,
708) -> Option<(usize, usize, usize, usize)> {
709    let rest = header_line.strip_prefix("@@ ")?;
710    let (old_part, rest2) = rest.split_once(" +")?;
711    let old_tok = old_part.strip_prefix('-')?.split(' ').next()?;
712    let new_tok = rest2.split(" @@").next()?.split(' ').next()?;
713
714    fn parse_side(spec: &str) -> (usize, usize) {
715        if let Some((start, count)) = spec.split_once(',') {
716            (start.parse().unwrap_or(0), count.parse().unwrap_or(0))
717        } else {
718            (spec.parse().unwrap_or(0), 1)
719        }
720    }
721
722    let (os, ol) = parse_side(old_tok);
723    let (ns, nl) = parse_side(new_tok);
724    Some((os, ol, ns, nl))
725}
726
727/// Write `---` / `+++` and unified hunks using [`unified_hunk_body_for_diff_options`].
728#[allow(clippy::too_many_arguments)]
729pub(crate) fn write_unified_diff_from_diff_options<W: std::io::Write>(
730    out: &mut W,
731    old_content: &str,
732    new_content: &str,
733    old_path: &str,
734    new_path: &str,
735    src_prefix: &str,
736    dst_prefix: &str,
737    funcname_matcher: Option<&FuncnameMatcher>,
738    options: &crate::diff::options::DiffOptions,
739    quote_path_fully: bool,
740) -> Result<()> {
741    use crate::quote_path::format_diff_path_with_prefix;
742
743    if old_path == "/dev/null" {
744        out.write_all(b"--- /dev/null\n")?;
745    } else if src_prefix.is_empty() {
746        writeln!(out, "--- {old_path}")?;
747    } else {
748        out.write_all(b"--- ")?;
749        out.write_all(
750            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
751        )?;
752        out.write_all(b"\n")?;
753    }
754    if new_path == "/dev/null" {
755        out.write_all(b"+++ /dev/null\n")?;
756    } else if dst_prefix.is_empty() {
757        writeln!(out, "+++ {new_path}")?;
758    } else {
759        out.write_all(b"+++ ")?;
760        out.write_all(
761            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
762        )?;
763        out.write_all(b"\n")?;
764    }
765
766    let body = unified_hunk_body_for_diff_options(old_content, new_content, options);
767    if body.is_empty() {
768        return Ok(());
769    }
770    let old_lines: Vec<&str> = old_content.lines().collect();
771    for hunk_str in imara_unified_hunk_slices(&body) {
772        if hunk_str.is_empty() {
773            continue;
774        }
775        if let Some(first_newline) = hunk_str.find('\n') {
776            let header_line = &hunk_str[..first_newline];
777            let rest = &hunk_str[first_newline..];
778            if let Some(func_ctx) =
779                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
780            {
781                out.write_all(header_line.as_bytes())?;
782                out.write_all(b" ")?;
783                out.write_all(func_ctx.as_bytes())?;
784                out.write_all(rest.as_bytes())?;
785            } else {
786                out.write_all(hunk_str.as_bytes())?;
787            }
788        } else {
789            out.write_all(hunk_str.as_bytes())?;
790        }
791    }
792    Ok(())
793}
794
795#[expect(clippy::too_many_arguments)]
796fn unified_diff_git_myers_with_prefix_and_funcname(
797    old_content: &str,
798    new_content: &str,
799    old_path: &str,
800    new_path: &str,
801    context_lines: usize,
802    inter_hunk_context: usize,
803    src_prefix: &str,
804    dst_prefix: &str,
805    funcname_matcher: Option<&FuncnameMatcher>,
806    indent_heuristic: bool,
807    quote_path_fully: bool,
808) -> String {
809    use crate::quote_path::format_diff_path_with_prefix;
810
811    let body = git_xdiff_unified_body_raw(
812        old_content,
813        new_content,
814        context_lines,
815        inter_hunk_context,
816        indent_heuristic,
817        false,
818        super::WhitespaceMode::default(),
819    );
820
821    let mut output = String::new();
822    if old_path == "/dev/null" {
823        output.push_str("--- /dev/null\n");
824    } else if src_prefix.is_empty() {
825        output.push_str(&format!("--- {old_path}\n"));
826    } else {
827        output.push_str("--- ");
828        output.push_str(&format_diff_path_with_prefix(
829            src_prefix,
830            old_path,
831            quote_path_fully,
832        ));
833        output.push('\n');
834    }
835    if new_path == "/dev/null" {
836        output.push_str("+++ /dev/null\n");
837    } else if dst_prefix.is_empty() {
838        output.push_str(&format!("+++ {new_path}\n"));
839    } else {
840        output.push_str("+++ ");
841        output.push_str(&format_diff_path_with_prefix(
842            dst_prefix,
843            new_path,
844            quote_path_fully,
845        ));
846        output.push('\n');
847    }
848
849    let old_lines: Vec<&str> = old_content.lines().collect();
850    for hunk_str in imara_unified_hunk_slices(&body) {
851        if hunk_str.is_empty() {
852            continue;
853        }
854        if let Some(first_newline) = hunk_str.find('\n') {
855            let header_line = &hunk_str[..first_newline];
856            let rest = &hunk_str[first_newline..];
857            if let Some(func_ctx) =
858                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
859            {
860                output.push_str(header_line);
861                output.push(' ');
862                output.push_str(&func_ctx);
863                output.push_str(rest);
864            } else {
865                output.push_str(hunk_str);
866            }
867        } else {
868            output.push_str(hunk_str);
869        }
870    }
871
872    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
873}
874
875#[expect(clippy::too_many_arguments)]
876fn unified_diff_git_minimal_with_prefix_and_funcname(
877    old_content: &str,
878    new_content: &str,
879    old_path: &str,
880    new_path: &str,
881    context_lines: usize,
882    inter_hunk_context: usize,
883    src_prefix: &str,
884    dst_prefix: &str,
885    funcname_matcher: Option<&FuncnameMatcher>,
886    indent_heuristic: bool,
887    quote_path_fully: bool,
888) -> String {
889    use crate::quote_path::format_diff_path_with_prefix;
890
891    let body = git_xdiff_unified_body_raw(
892        old_content,
893        new_content,
894        context_lines,
895        inter_hunk_context,
896        indent_heuristic,
897        true,
898        super::WhitespaceMode::default(),
899    );
900
901    let mut output = String::new();
902    if old_path == "/dev/null" {
903        output.push_str("--- /dev/null\n");
904    } else if src_prefix.is_empty() {
905        output.push_str(&format!("--- {old_path}\n"));
906    } else {
907        output.push_str("--- ");
908        output.push_str(&format_diff_path_with_prefix(
909            src_prefix,
910            old_path,
911            quote_path_fully,
912        ));
913        output.push('\n');
914    }
915    if new_path == "/dev/null" {
916        output.push_str("+++ /dev/null\n");
917    } else if dst_prefix.is_empty() {
918        output.push_str(&format!("+++ {new_path}\n"));
919    } else {
920        output.push_str("+++ ");
921        output.push_str(&format_diff_path_with_prefix(
922            dst_prefix,
923            new_path,
924            quote_path_fully,
925        ));
926        output.push('\n');
927    }
928
929    let old_lines: Vec<&str> = old_content.lines().collect();
930    for hunk_str in imara_unified_hunk_slices(&body) {
931        if hunk_str.is_empty() {
932            continue;
933        }
934        if let Some(first_newline) = hunk_str.find('\n') {
935            let header_line = &hunk_str[..first_newline];
936            let rest = &hunk_str[first_newline..];
937            if let Some(func_ctx) =
938                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
939            {
940                output.push_str(header_line);
941                output.push(' ');
942                output.push_str(&func_ctx);
943                output.push_str(rest);
944            } else {
945                output.push_str(hunk_str);
946            }
947        } else {
948            output.push_str(hunk_str);
949        }
950    }
951
952    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
953}
954
955#[expect(clippy::too_many_arguments)]
956fn unified_diff_git_patience_with_prefix_and_funcname(
957    old_content: &str,
958    new_content: &str,
959    old_path: &str,
960    new_path: &str,
961    context_lines: usize,
962    inter_hunk_context: usize,
963    src_prefix: &str,
964    dst_prefix: &str,
965    funcname_matcher: Option<&FuncnameMatcher>,
966    indent_heuristic: bool,
967    quote_path_fully: bool,
968) -> String {
969    use crate::quote_path::format_diff_path_with_prefix;
970
971    let body = git_patience_unified_body_raw(
972        old_content,
973        new_content,
974        context_lines,
975        inter_hunk_context,
976        indent_heuristic,
977    );
978
979    let mut output = String::new();
980    if old_path == "/dev/null" {
981        output.push_str("--- /dev/null\n");
982    } else if src_prefix.is_empty() {
983        output.push_str(&format!("--- {old_path}\n"));
984    } else {
985        output.push_str("--- ");
986        output.push_str(&format_diff_path_with_prefix(
987            src_prefix,
988            old_path,
989            quote_path_fully,
990        ));
991        output.push('\n');
992    }
993    if new_path == "/dev/null" {
994        output.push_str("+++ /dev/null\n");
995    } else if dst_prefix.is_empty() {
996        output.push_str(&format!("+++ {new_path}\n"));
997    } else {
998        output.push_str("+++ ");
999        output.push_str(&format_diff_path_with_prefix(
1000            dst_prefix,
1001            new_path,
1002            quote_path_fully,
1003        ));
1004        output.push('\n');
1005    }
1006
1007    let old_lines: Vec<&str> = old_content.lines().collect();
1008    for hunk_str in imara_unified_hunk_slices(&body) {
1009        if hunk_str.is_empty() {
1010            continue;
1011        }
1012        if let Some(first_newline) = hunk_str.find('\n') {
1013            let header_line = &hunk_str[..first_newline];
1014            let rest = &hunk_str[first_newline..];
1015            if let Some(func_ctx) =
1016                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1017            {
1018                output.push_str(header_line);
1019                output.push(' ');
1020                output.push_str(&func_ctx);
1021                output.push_str(rest);
1022            } else {
1023                output.push_str(hunk_str);
1024            }
1025        } else {
1026            output.push_str(hunk_str);
1027        }
1028    }
1029
1030    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1031}
1032
1033/// Unified diff hunks for Git's histogram algorithm (no `---` / `+++` lines).
1034///
1035/// Used by `--no-index` when whitespace normalization is off so the patch matches upstream Git.
1036#[must_use]
1037pub fn unified_diff_histogram_hunks_only(
1038    old_content: &str,
1039    new_content: &str,
1040    context_lines: usize,
1041    inter_hunk_context: usize,
1042    indent_heuristic: bool,
1043) -> String {
1044    histogram_unified_body_raw(
1045        old_content,
1046        new_content,
1047        context_lines,
1048        inter_hunk_context,
1049        indent_heuristic,
1050        super::WhitespaceMode::default(),
1051    )
1052}
1053#[expect(clippy::too_many_arguments)]
1054/// Full unified diff (`---` / `+++` / hunks) using Git's histogram algorithm.
1055#[must_use]
1056pub fn unified_diff_histogram_with_prefix_and_funcname(
1057    old_content: &str,
1058    new_content: &str,
1059    old_path: &str,
1060    new_path: &str,
1061    context_lines: usize,
1062    inter_hunk_context: usize,
1063    src_prefix: &str,
1064    dst_prefix: &str,
1065    funcname_matcher: Option<&FuncnameMatcher>,
1066    indent_heuristic: bool,
1067    quote_path_fully: bool,
1068) -> String {
1069    unified_diff_histogram_with_prefix_and_funcname_indent(
1070        old_content,
1071        new_content,
1072        old_path,
1073        new_path,
1074        context_lines,
1075        inter_hunk_context,
1076        src_prefix,
1077        dst_prefix,
1078        funcname_matcher,
1079        indent_heuristic,
1080        quote_path_fully,
1081    )
1082}
1083
1084#[expect(clippy::too_many_arguments)]
1085fn unified_diff_histogram_with_prefix_and_funcname_indent(
1086    old_content: &str,
1087    new_content: &str,
1088    old_path: &str,
1089    new_path: &str,
1090    context_lines: usize,
1091    inter_hunk_context: usize,
1092    src_prefix: &str,
1093    dst_prefix: &str,
1094    funcname_matcher: Option<&FuncnameMatcher>,
1095    indent_heuristic: bool,
1096    quote_path_fully: bool,
1097) -> String {
1098    use crate::quote_path::format_diff_path_with_prefix;
1099
1100    let body = histogram_unified_body_raw(
1101        old_content,
1102        new_content,
1103        context_lines,
1104        inter_hunk_context,
1105        indent_heuristic,
1106        super::WhitespaceMode::default(),
1107    );
1108
1109    let mut output = String::new();
1110    if old_path == "/dev/null" {
1111        output.push_str("--- /dev/null\n");
1112    } else if src_prefix.is_empty() {
1113        output.push_str(&format!("--- {old_path}\n"));
1114    } else {
1115        output.push_str("--- ");
1116        output.push_str(&format_diff_path_with_prefix(
1117            src_prefix,
1118            old_path,
1119            quote_path_fully,
1120        ));
1121        output.push('\n');
1122    }
1123    if new_path == "/dev/null" {
1124        output.push_str("+++ /dev/null\n");
1125    } else if dst_prefix.is_empty() {
1126        output.push_str(&format!("+++ {new_path}\n"));
1127    } else {
1128        output.push_str("+++ ");
1129        output.push_str(&format_diff_path_with_prefix(
1130            dst_prefix,
1131            new_path,
1132            quote_path_fully,
1133        ));
1134        output.push('\n');
1135    }
1136
1137    let old_lines: Vec<&str> = old_content.lines().collect();
1138    for hunk_str in imara_unified_hunk_slices(&body) {
1139        if hunk_str.is_empty() {
1140            continue;
1141        }
1142        if let Some(first_newline) = hunk_str.find('\n') {
1143            let header_line = &hunk_str[..first_newline];
1144            let rest = &hunk_str[first_newline..];
1145            if let Some(func_ctx) =
1146                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1147            {
1148                output.push_str(header_line);
1149                output.push(' ');
1150                output.push_str(&func_ctx);
1151                output.push_str(rest);
1152            } else {
1153                output.push_str(hunk_str);
1154            }
1155        } else {
1156            output.push_str(hunk_str);
1157        }
1158    }
1159
1160    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1161}
1162
1163/// Full unified diff (`---` / `+++` / hunks) using Git's histogram algorithm,
1164/// applying `--ignore-blank-lines` / `-I` change-record suppression
1165/// (`histogram_unified_body_ignore_fc`) and then attaching function-name hunk
1166/// headers exactly like [`unified_diff_histogram_with_prefix_and_funcname`].
1167///
1168/// `is_ignorable_change` is given the removed/added line slices (without
1169/// trailing newlines) of one change record and returns whether the whole change
1170/// is ignorable. Returns an empty string when no substantive change survives so
1171/// the caller can hide the file entry.
1172#[allow(clippy::too_many_arguments)]
1173pub fn unified_diff_histogram_ignore_with_prefix_and_funcname<F>(
1174    old_content: &str,
1175    new_content: &str,
1176    old_path: &str,
1177    new_path: &str,
1178    context_lines: usize,
1179    inter_hunk_context: usize,
1180    src_prefix: &str,
1181    dst_prefix: &str,
1182    funcname_matcher: Option<&FuncnameMatcher>,
1183    quote_path_fully: bool,
1184    function_context: bool,
1185    indent_heuristic: bool,
1186    is_ignorable_change: F,
1187) -> String
1188where
1189    F: Fn(&[&str], &[&str]) -> bool,
1190{
1191    use crate::quote_path::format_diff_path_with_prefix;
1192
1193    let body = histogram_unified_body_ignore_fc(
1194        old_content,
1195        new_content,
1196        context_lines,
1197        inter_hunk_context,
1198        function_context,
1199        funcname_matcher,
1200        indent_heuristic,
1201        super::WhitespaceMode::default(),
1202        is_ignorable_change,
1203    );
1204    if body.is_empty() {
1205        return String::new();
1206    }
1207
1208    let mut output = String::new();
1209    if old_path == "/dev/null" {
1210        output.push_str("--- /dev/null\n");
1211    } else if src_prefix.is_empty() {
1212        output.push_str(&format!("--- {old_path}\n"));
1213    } else {
1214        output.push_str("--- ");
1215        output.push_str(&format_diff_path_with_prefix(
1216            src_prefix,
1217            old_path,
1218            quote_path_fully,
1219        ));
1220        output.push('\n');
1221    }
1222    if new_path == "/dev/null" {
1223        output.push_str("+++ /dev/null\n");
1224    } else if dst_prefix.is_empty() {
1225        output.push_str(&format!("+++ {new_path}\n"));
1226    } else {
1227        output.push_str("+++ ");
1228        output.push_str(&format_diff_path_with_prefix(
1229            dst_prefix,
1230            new_path,
1231            quote_path_fully,
1232        ));
1233        output.push('\n');
1234    }
1235
1236    let old_lines: Vec<&str> = old_content.lines().collect();
1237    for hunk_str in imara_unified_hunk_slices(&body) {
1238        if hunk_str.is_empty() {
1239            continue;
1240        }
1241        if let Some(first_newline) = hunk_str.find('\n') {
1242            let header_line = &hunk_str[..first_newline];
1243            let rest = &hunk_str[first_newline..];
1244            if let Some(func_ctx) =
1245                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1246            {
1247                output.push_str(header_line);
1248                output.push(' ');
1249                output.push_str(&func_ctx);
1250                output.push_str(rest);
1251            } else {
1252                output.push_str(hunk_str);
1253            }
1254        } else {
1255            output.push_str(hunk_str);
1256        }
1257    }
1258
1259    output
1260}
1261
1262/// `diff.indentHeuristic` from config (Git defaults to true when unset).
1263#[must_use]
1264pub fn indent_heuristic_from_config(config: &ConfigSet) -> bool {
1265    match config.get_bool("diff.indentHeuristic") {
1266        Some(Ok(b)) => b,
1267        Some(Err(_)) | None => true,
1268    }
1269}
1270
1271/// Resolve indent heuristic: `--no-indent-heuristic` and `--indent-heuristic` override config.
1272#[must_use]
1273pub fn resolve_indent_heuristic(
1274    config: &ConfigSet,
1275    cli_indent_heuristic: bool,
1276    cli_no_indent_heuristic: bool,
1277) -> bool {
1278    if cli_no_indent_heuristic {
1279        false
1280    } else if cli_indent_heuristic {
1281        true
1282    } else {
1283        indent_heuristic_from_config(config)
1284    }
1285}
1286
1287// ---------------------------------------------------------------------------
1288// Git-compatible implementation of the xdiff Myers engine,
1289// restricted to what the word-diff path needs (no rename/ignore-whitespace
1290// flags). Git's word diff runs the *default* xdiff Myers over the per-word
1291// token streams; matching Git's exact change-record selection requires
1292// reproducing its preprocessing (record classification + `xdl_cleanup_records`
1293// + `xdl_trim_ends`) and its divide-and-conquer split, because `imara-diff`
1294// (and `similar`) pick different — but equally minimal — alignments for inputs
1295// with many repeated tokens (e.g. the `{`/`}`/`,` in the `bibtex` driver).
1296mod git_xdiff {
1297    const XDL_MAX_COST_MIN: i64 = 256;
1298    const XDL_HEUR_MIN_COST: i64 = 256;
1299    const XDL_SNAKE_CNT: i64 = 20;
1300    const XDL_K_HEUR: i64 = 4;
1301    const XDL_LINE_MAX: i64 = i64::MAX;
1302    const XDL_KPDIS_RUN: i64 = 4;
1303    const XDL_MAX_EQLIMIT: i64 = 1024;
1304    const XDL_SIMSCAN_WINDOW: i64 = 100;
1305
1306    // record-classification actions (xprepare.c)
1307    const DISCARD: u8 = 0;
1308    const KEEP: u8 = 1;
1309    const INVESTIGATE: u8 = 2;
1310
1311    /// Classical integer square root approximation using shifts (`xdl_bogosqrt`).
1312    fn bogosqrt(mut n: i64) -> i64 {
1313        let mut i: i64 = 1;
1314        while n > 0 {
1315            i <<= 1;
1316            n >>= 2;
1317        }
1318        i
1319    }
1320
1321    struct XdAlgoEnv {
1322        mxcost: i64,
1323        snake_cnt: i64,
1324        heur_min: i64,
1325    }
1326
1327    struct XdpSplit {
1328        i1: i64,
1329        i2: i64,
1330        min_lo: bool,
1331        min_hi: bool,
1332    }
1333
1334    /// A single side of the diff: token ids plus the `changed` flags and the
1335    /// `reference_index` mapping from effective record -> original record.
1336    struct XdFile<'a> {
1337        ids: &'a [u32],
1338        changed: Vec<bool>,
1339        reference_index: Vec<usize>,
1340        dstart: i64,
1341        dend: i64,
1342        nreff: i64,
1343    }
1344
1345    /// Hash/id lookup over the *effective* records: index into `reference_index`.
1346    #[inline]
1347    fn get_id(xdf: &XdFile<'_>, idx: i64) -> u32 {
1348        xdf.ids[xdf.reference_index[idx as usize]]
1349    }
1350
1351    /// Faithful port of `xdl_split` (the middle-snake finder). `kvdf`/`kvdb` are
1352    /// the forward/backward k-vectors, offset so index 0 maps to diagonal 0.
1353    #[allow(clippy::too_many_arguments)]
1354    fn xdl_split(
1355        xdf1: &XdFile<'_>,
1356        off1: i64,
1357        lim1: i64,
1358        xdf2: &XdFile<'_>,
1359        off2: i64,
1360        lim2: i64,
1361        kvdf: &mut [i64],
1362        kvdb: &mut [i64],
1363        koff: i64,
1364        need_min: bool,
1365        spl: &mut XdpSplit,
1366        xenv: &XdAlgoEnv,
1367    ) -> i64 {
1368        let dmin = off1 - lim2;
1369        let dmax = lim1 - off2;
1370        let fmid = off1 - off2;
1371        let bmid = lim1 - lim2;
1372        let odd = ((fmid - bmid) & 1) != 0;
1373        let mut fmin = fmid;
1374        let mut fmax = fmid;
1375        let mut bmin = bmid;
1376        let mut bmax = bmid;
1377
1378        let kf = |k: i64| -> usize { (k + koff) as usize };
1379
1380        kvdf[kf(fmid)] = off1;
1381        kvdb[kf(bmid)] = lim1;
1382
1383        let mut ec: i64 = 1;
1384        loop {
1385            let mut got_snake = false;
1386
1387            if fmin > dmin {
1388                fmin -= 1;
1389                kvdf[kf(fmin - 1)] = -1;
1390            } else {
1391                fmin += 1;
1392            }
1393            if fmax < dmax {
1394                fmax += 1;
1395                kvdf[kf(fmax + 1)] = -1;
1396            } else {
1397                fmax -= 1;
1398            }
1399
1400            let mut d = fmax;
1401            while d >= fmin {
1402                let mut i1 = if kvdf[kf(d - 1)] >= kvdf[kf(d + 1)] {
1403                    kvdf[kf(d - 1)] + 1
1404                } else {
1405                    kvdf[kf(d + 1)]
1406                };
1407                let prev1 = i1;
1408                let mut i2 = i1 - d;
1409                while i1 < lim1 && i2 < lim2 && get_id(xdf1, i1) == get_id(xdf2, i2) {
1410                    i1 += 1;
1411                    i2 += 1;
1412                }
1413                if i1 - prev1 > xenv.snake_cnt {
1414                    got_snake = true;
1415                }
1416                kvdf[kf(d)] = i1;
1417                if odd && bmin <= d && d <= bmax && kvdb[kf(d)] <= i1 {
1418                    spl.i1 = i1;
1419                    spl.i2 = i2;
1420                    spl.min_lo = true;
1421                    spl.min_hi = true;
1422                    return ec;
1423                }
1424                d -= 2;
1425            }
1426
1427            if bmin > dmin {
1428                bmin -= 1;
1429                kvdb[kf(bmin - 1)] = XDL_LINE_MAX;
1430            } else {
1431                bmin += 1;
1432            }
1433            if bmax < dmax {
1434                bmax += 1;
1435                kvdb[kf(bmax + 1)] = XDL_LINE_MAX;
1436            } else {
1437                bmax -= 1;
1438            }
1439
1440            let mut d = bmax;
1441            while d >= bmin {
1442                let mut i1 = if kvdb[kf(d - 1)] < kvdb[kf(d + 1)] {
1443                    kvdb[kf(d - 1)]
1444                } else {
1445                    kvdb[kf(d + 1)] - 1
1446                };
1447                let prev1 = i1;
1448                let mut i2 = i1 - d;
1449                while i1 > off1 && i2 > off2 && get_id(xdf1, i1 - 1) == get_id(xdf2, i2 - 1) {
1450                    i1 -= 1;
1451                    i2 -= 1;
1452                }
1453                if prev1 - i1 > xenv.snake_cnt {
1454                    got_snake = true;
1455                }
1456                kvdb[kf(d)] = i1;
1457                if !odd && fmin <= d && d <= fmax && i1 <= kvdf[kf(d)] {
1458                    spl.i1 = i1;
1459                    spl.i2 = i2;
1460                    spl.min_lo = true;
1461                    spl.min_hi = true;
1462                    return ec;
1463                }
1464                d -= 2;
1465            }
1466
1467            if need_min {
1468                ec += 1;
1469                continue;
1470            }
1471
1472            if got_snake && ec > xenv.heur_min {
1473                let mut best = 0i64;
1474                let mut d = fmax;
1475                while d >= fmin {
1476                    let dd = if d > fmid { d - fmid } else { fmid - d };
1477                    let i1 = kvdf[kf(d)];
1478                    let i2 = i1 - d;
1479                    let v = (i1 - off1) + (i2 - off2) - dd;
1480                    if v > XDL_K_HEUR * ec
1481                        && v > best
1482                        && off1 + xenv.snake_cnt <= i1
1483                        && i1 < lim1
1484                        && off2 + xenv.snake_cnt <= i2
1485                        && i2 < lim2
1486                    {
1487                        let mut k = 1i64;
1488                        while get_id(xdf1, i1 - k) == get_id(xdf2, i2 - k) {
1489                            if k == xenv.snake_cnt {
1490                                best = v;
1491                                spl.i1 = i1;
1492                                spl.i2 = i2;
1493                                break;
1494                            }
1495                            k += 1;
1496                        }
1497                    }
1498                    d -= 2;
1499                }
1500                if best > 0 {
1501                    spl.min_lo = true;
1502                    spl.min_hi = false;
1503                    return ec;
1504                }
1505
1506                let mut best = 0i64;
1507                let mut d = bmax;
1508                while d >= bmin {
1509                    let dd = if d > bmid { d - bmid } else { bmid - d };
1510                    let i1 = kvdb[kf(d)];
1511                    let i2 = i1 - d;
1512                    let v = (lim1 - i1) + (lim2 - i2) - dd;
1513                    if v > XDL_K_HEUR * ec
1514                        && v > best
1515                        && off1 < i1
1516                        && i1 <= lim1 - xenv.snake_cnt
1517                        && off2 < i2
1518                        && i2 <= lim2 - xenv.snake_cnt
1519                    {
1520                        let mut k = 0i64;
1521                        while get_id(xdf1, i1 + k) == get_id(xdf2, i2 + k) {
1522                            if k == xenv.snake_cnt - 1 {
1523                                best = v;
1524                                spl.i1 = i1;
1525                                spl.i2 = i2;
1526                                break;
1527                            }
1528                            k += 1;
1529                        }
1530                    }
1531                    d -= 2;
1532                }
1533                if best > 0 {
1534                    spl.min_lo = false;
1535                    spl.min_hi = true;
1536                    return ec;
1537                }
1538            }
1539
1540            if ec >= xenv.mxcost {
1541                let mut fbest = -1i64;
1542                let mut fbest1 = -1i64;
1543                let mut d = fmax;
1544                while d >= fmin {
1545                    let mut i1 = kvdf[kf(d)].min(lim1);
1546                    let mut i2 = i1 - d;
1547                    if lim2 < i2 {
1548                        i1 = lim2 + d;
1549                        i2 = lim2;
1550                    }
1551                    if fbest < i1 + i2 {
1552                        fbest = i1 + i2;
1553                        fbest1 = i1;
1554                    }
1555                    d -= 2;
1556                }
1557
1558                let mut bbest = XDL_LINE_MAX;
1559                let mut bbest1 = XDL_LINE_MAX;
1560                let mut d = bmax;
1561                while d >= bmin {
1562                    let mut i1 = off1.max(kvdb[kf(d)]);
1563                    let mut i2 = i1 - d;
1564                    if i2 < off2 {
1565                        i1 = off2 + d;
1566                        i2 = off2;
1567                    }
1568                    if i1 + i2 < bbest {
1569                        bbest = i1 + i2;
1570                        bbest1 = i1;
1571                    }
1572                    d -= 2;
1573                }
1574
1575                if (lim1 + lim2) - bbest < fbest - (off1 + off2) {
1576                    spl.i1 = fbest1;
1577                    spl.i2 = fbest - fbest1;
1578                    spl.min_lo = true;
1579                    spl.min_hi = false;
1580                } else {
1581                    spl.i1 = bbest1;
1582                    spl.i2 = bbest - bbest1;
1583                    spl.min_lo = false;
1584                    spl.min_hi = true;
1585                }
1586                return ec;
1587            }
1588
1589            ec += 1;
1590        }
1591    }
1592
1593    /// Faithful port of `xdl_recs_cmp` (divide & conquer). Marks `changed` flags.
1594    #[allow(clippy::too_many_arguments)]
1595    fn xdl_recs_cmp(
1596        xdf1: &mut XdFile<'_>,
1597        mut off1: i64,
1598        mut lim1: i64,
1599        xdf2: &mut XdFile<'_>,
1600        mut off2: i64,
1601        mut lim2: i64,
1602        kvdf: &mut [i64],
1603        kvdb: &mut [i64],
1604        koff: i64,
1605        need_min: bool,
1606        xenv: &XdAlgoEnv,
1607    ) {
1608        while off1 < lim1 && off2 < lim2 && get_id(xdf1, off1) == get_id(xdf2, off2) {
1609            off1 += 1;
1610            off2 += 1;
1611        }
1612        while off1 < lim1 && off2 < lim2 && get_id(xdf1, lim1 - 1) == get_id(xdf2, lim2 - 1) {
1613            lim1 -= 1;
1614            lim2 -= 1;
1615        }
1616
1617        if off1 == lim1 {
1618            while off2 < lim2 {
1619                let r = xdf2.reference_index[off2 as usize];
1620                xdf2.changed[r] = true;
1621                off2 += 1;
1622            }
1623        } else if off2 == lim2 {
1624            while off1 < lim1 {
1625                let r = xdf1.reference_index[off1 as usize];
1626                xdf1.changed[r] = true;
1627                off1 += 1;
1628            }
1629        } else {
1630            let mut spl = XdpSplit {
1631                i1: 0,
1632                i2: 0,
1633                min_lo: false,
1634                min_hi: false,
1635            };
1636            xdl_split(
1637                xdf1, off1, lim1, xdf2, off2, lim2, kvdf, kvdb, koff, need_min, &mut spl, xenv,
1638            );
1639            xdl_recs_cmp(
1640                xdf1, off1, spl.i1, xdf2, off2, spl.i2, kvdf, kvdb, koff, spl.min_lo, xenv,
1641            );
1642            xdl_recs_cmp(
1643                xdf1, spl.i1, lim1, xdf2, spl.i2, lim2, kvdf, kvdb, koff, spl.min_hi, xenv,
1644            );
1645        }
1646    }
1647
1648    /// `xdl_clean_mmatch`: decide whether a multimatch record should be discarded.
1649    fn clean_mmatch(action: &[u8], i: i64, mut s: i64, mut e: i64) -> bool {
1650        if i - s > XDL_SIMSCAN_WINDOW {
1651            s = i - XDL_SIMSCAN_WINDOW;
1652        }
1653        if e - i > XDL_SIMSCAN_WINDOW {
1654            e = i + XDL_SIMSCAN_WINDOW;
1655        }
1656
1657        let mut rdis0 = 0i64;
1658        let mut rpdis0 = 1i64;
1659        let mut r = 1i64;
1660        while i - r >= s {
1661            match action[(i - r) as usize] {
1662                DISCARD => rdis0 += 1,
1663                INVESTIGATE => rpdis0 += 1,
1664                _ => break, // KEEP
1665            }
1666            r += 1;
1667        }
1668        if rdis0 == 0 {
1669            return false;
1670        }
1671        let mut rdis1 = 0i64;
1672        let mut rpdis1 = 1i64;
1673        let mut r = 1i64;
1674        while i + r <= e {
1675            match action[(i + r) as usize] {
1676                DISCARD => rdis1 += 1,
1677                INVESTIGATE => rpdis1 += 1,
1678                _ => break, // KEEP
1679            }
1680            r += 1;
1681        }
1682        if rdis1 == 0 {
1683            return false;
1684        }
1685        rdis1 += rdis0;
1686        rpdis1 += rpdis0;
1687        rpdis1 * XDL_KPDIS_RUN < (rpdis1 + rdis1)
1688    }
1689
1690    /// Build the `changed` flags for both token streams using Git's xdiff engine.
1691    /// When `need_min` is true, Git's minimal diff (`--minimal`) is used.
1692    /// `ids1`/`ids2` are dense interned token ids from one [`crate::line_interner::LineInterner`]
1693    /// (equal id <=> equal token); per-id tables are sized by the largest id.
1694    /// Returns `(changed1, changed2)`, one bool per original token.
1695    pub fn changed_flags(ids1: &[u32], ids2: &[u32], need_min: bool) -> (Vec<bool>, Vec<bool>) {
1696        changed_flags_with_shared(ids1, ids2, &[], 0, need_min)
1697    }
1698
1699    /// Like [`changed_flags`] for slices cut from two larger files whose other records are
1700    /// identical on both sides.
1701    ///
1702    /// Git classifies each record by how often it occurs in the whole other file, so the
1703    /// records left out still count: `shared_counts[id]` is how many of them have token `id`
1704    /// (ids past its end have none), and `shared_records` is how many there are on each side.
1705    pub fn changed_flags_with_shared(
1706        ids1: &[u32],
1707        ids2: &[u32],
1708        shared_counts: &[u32],
1709        shared_records: usize,
1710        need_min: bool,
1711    ) -> (Vec<bool>, Vec<bool>) {
1712        let nrec1 = ids1.len();
1713        let nrec2 = ids2.len();
1714
1715        // Per-token occurrence counts on each side (class len1/len2), indexed by the dense ids.
1716        let nclasses = ids1
1717            .iter()
1718            .chain(ids2)
1719            .max()
1720            .map_or(0, |&max| max as usize + 1);
1721        let mut count1 = vec![0i64; nclasses];
1722        let mut count2 = vec![0i64; nclasses];
1723        for &id in ids1 {
1724            count1[id as usize] += 1;
1725        }
1726        for &id in ids2 {
1727            count2[id as usize] += 1;
1728        }
1729        for (id, &shared) in shared_counts.iter().enumerate().take(nclasses) {
1730            count1[id] += i64::from(shared);
1731            count2[id] += i64::from(shared);
1732        }
1733
1734        let mut xdf1 = XdFile {
1735            ids: ids1,
1736            changed: vec![false; nrec1],
1737            reference_index: Vec::new(),
1738            dstart: 0,
1739            dend: nrec1 as i64 - 1,
1740            nreff: 0,
1741        };
1742        let mut xdf2 = XdFile {
1743            ids: ids2,
1744            changed: vec![false; nrec2],
1745            reference_index: Vec::new(),
1746            dstart: 0,
1747            dend: nrec2 as i64 - 1,
1748            nreff: 0,
1749        };
1750
1751        // xdl_trim_ends: trim leading/trailing matching records.
1752        let lim = nrec1.min(nrec2) as i64;
1753        let mut i = 0i64;
1754        while i < lim && ids1[i as usize] == ids2[i as usize] {
1755            i += 1;
1756        }
1757        xdf1.dstart = i;
1758        xdf2.dstart = i;
1759        let mut j = 0i64;
1760        let rem = lim - i;
1761        while j < rem && ids1[nrec1 - 1 - j as usize] == ids2[nrec2 - 1 - j as usize] {
1762            j += 1;
1763        }
1764        xdf1.dend = nrec1 as i64 - j - 1;
1765        xdf2.dend = nrec2 as i64 - j - 1;
1766
1767        // xdl_cleanup_records: classify and reduce to effective records.
1768        let mut action1 = vec![0u8; nrec1 + 1];
1769        let mut action2 = vec![0u8; nrec2 + 1];
1770
1771        let mlim1 = if need_min {
1772            i64::MAX
1773        } else {
1774            let mut mlim = bogosqrt((nrec1 + shared_records) as i64);
1775            if mlim > XDL_MAX_EQLIMIT {
1776                mlim = XDL_MAX_EQLIMIT;
1777            }
1778            mlim
1779        };
1780        let mut idx = xdf1.dstart;
1781        while idx <= xdf1.dend {
1782            let id = ids1[idx as usize];
1783            let nm = count2[id as usize];
1784            action1[idx as usize] = if nm == 0 {
1785                DISCARD
1786            } else if nm >= mlim1 {
1787                INVESTIGATE
1788            } else {
1789                KEEP
1790            };
1791            idx += 1;
1792        }
1793
1794        let mlim2 = if need_min {
1795            i64::MAX
1796        } else {
1797            let mut mlim = bogosqrt((nrec2 + shared_records) as i64);
1798            if mlim > XDL_MAX_EQLIMIT {
1799                mlim = XDL_MAX_EQLIMIT;
1800            }
1801            mlim
1802        };
1803        let mut idx = xdf2.dstart;
1804        while idx <= xdf2.dend {
1805            let id = ids2[idx as usize];
1806            let nm = count1[id as usize];
1807            action2[idx as usize] = if nm == 0 {
1808                DISCARD
1809            } else if nm >= mlim2 {
1810                INVESTIGATE
1811            } else {
1812                KEEP
1813            };
1814            idx += 1;
1815        }
1816
1817        let mut idx = xdf1.dstart;
1818        while idx <= xdf1.dend {
1819            let a = action1[idx as usize];
1820            if a == KEEP
1821                || (a == INVESTIGATE && !clean_mmatch(&action1, idx, xdf1.dstart, xdf1.dend))
1822            {
1823                xdf1.reference_index.push(idx as usize);
1824            } else {
1825                xdf1.changed[idx as usize] = true;
1826            }
1827            idx += 1;
1828        }
1829        xdf1.nreff = xdf1.reference_index.len() as i64;
1830
1831        let mut idx = xdf2.dstart;
1832        while idx <= xdf2.dend {
1833            let a = action2[idx as usize];
1834            if a == KEEP
1835                || (a == INVESTIGATE && !clean_mmatch(&action2, idx, xdf2.dstart, xdf2.dend))
1836            {
1837                xdf2.reference_index.push(idx as usize);
1838            } else {
1839                xdf2.changed[idx as usize] = true;
1840            }
1841            idx += 1;
1842        }
1843        xdf2.nreff = xdf2.reference_index.len() as i64;
1844
1845        // Allocate K vectors (xdl_do_diff). koff lets us use negative diagonals.
1846        let ndiags = xdf1.nreff + xdf2.nreff + 3;
1847        let kvd_len = (2 * ndiags + 2) as usize;
1848        let mut kvd = vec![0i64; kvd_len];
1849        // kvdf base offset = nreff2 + 1; kvdb base offset = ndiags + nreff2 + 1.
1850        let koff = xdf2.nreff + 1;
1851        let (kvdf_slice, kvdb_slice) = kvd.split_at_mut(ndiags as usize);
1852        // Both slices are indexed as [k + koff]; their length covers the diagonal range.
1853
1854        let xenv = XdAlgoEnv {
1855            mxcost: bogosqrt(ndiags).max(XDL_MAX_COST_MIN),
1856            snake_cnt: XDL_SNAKE_CNT,
1857            heur_min: XDL_HEUR_MIN_COST,
1858        };
1859
1860        let nreff1 = xdf1.nreff;
1861        let nreff2 = xdf2.nreff;
1862        xdl_recs_cmp(
1863            &mut xdf1, 0, nreff1, &mut xdf2, 0, nreff2, kvdf_slice, kvdb_slice, koff, need_min,
1864            &xenv,
1865        );
1866
1867        (xdf1.changed, xdf2.changed)
1868    }
1869
1870    /// Patience diff: match unique lines, then recurse on gaps with xdiff Myers.
1871    pub fn patience_changed_flags(lines1: &[&str], lines2: &[&str]) -> (Vec<bool>, Vec<bool>) {
1872        let mut changed1 = vec![false; lines1.len()];
1873        let mut changed2 = vec![false; lines2.len()];
1874        patience_recurse(
1875            lines1,
1876            lines2,
1877            &mut changed1,
1878            &mut changed2,
1879            0,
1880            lines1.len(),
1881            0,
1882            lines2.len(),
1883        );
1884        (changed1, changed2)
1885    }
1886
1887    #[allow(clippy::too_many_arguments)]
1888    fn patience_recurse(
1889        lines1: &[&str],
1890        lines2: &[&str],
1891        changed1: &mut [bool],
1892        changed2: &mut [bool],
1893        o_start: usize,
1894        o_end: usize,
1895        n_start: usize,
1896        n_end: usize,
1897    ) {
1898        if o_start >= o_end && n_start >= n_end {
1899            return;
1900        }
1901        if o_start >= o_end {
1902            changed2[n_start..n_end].fill(true);
1903            return;
1904        }
1905        if n_start >= n_end {
1906            changed1[o_start..o_end].fill(true);
1907            return;
1908        }
1909
1910        let anchors = patience_anchors(lines1, lines2, o_start, o_end, n_start, n_end);
1911        if anchors.is_empty() {
1912            merge_myers_region(
1913                lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
1914            );
1915            return;
1916        }
1917
1918        let mut prev_o = o_start;
1919        let mut prev_n = n_start;
1920        for (ao, an) in anchors {
1921            patience_recurse(lines1, lines2, changed1, changed2, prev_o, ao, prev_n, an);
1922            prev_o = ao + 1;
1923            prev_n = an + 1;
1924        }
1925        patience_recurse(
1926            lines1, lines2, changed1, changed2, prev_o, o_end, prev_n, n_end,
1927        );
1928    }
1929
1930    #[allow(clippy::too_many_arguments)]
1931    fn patience_anchors(
1932        lines1: &[&str],
1933        lines2: &[&str],
1934        o_start: usize,
1935        o_end: usize,
1936        n_start: usize,
1937        n_end: usize,
1938    ) -> Vec<(usize, usize)> {
1939        use std::collections::HashMap;
1940
1941        let old_slice = &lines1[o_start..o_end];
1942        let new_slice = &lines2[n_start..n_end];
1943        let mut old_pos: HashMap<&str, Vec<usize>> = HashMap::new();
1944        let mut new_pos: HashMap<&str, Vec<usize>> = HashMap::new();
1945        for (i, line) in old_slice.iter().enumerate() {
1946            old_pos.entry(*line).or_default().push(o_start + i);
1947        }
1948        for (i, line) in new_slice.iter().enumerate() {
1949            new_pos.entry(*line).or_default().push(n_start + i);
1950        }
1951
1952        let mut pairs: Vec<(usize, usize)> = Vec::new();
1953        for (line, o_indices) in &old_pos {
1954            if o_indices.len() != 1 {
1955                continue;
1956            }
1957            if let Some(n_indices) = new_pos.get(line) {
1958                if n_indices.len() == 1 {
1959                    pairs.push((o_indices[0], n_indices[0]));
1960                }
1961            }
1962        }
1963        pairs.sort_by_key(|p| p.0);
1964        longest_increasing_by_second(&pairs)
1965    }
1966
1967    fn longest_increasing_by_second(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
1968        if pairs.is_empty() {
1969            return Vec::new();
1970        }
1971        let n = pairs.len();
1972        let mut tails = vec![0usize; n];
1973        let mut prev = vec![usize::MAX; n];
1974        let mut len = 0usize;
1975        for i in 0..n {
1976            let v = pairs[i].1;
1977            let mut lo = 0usize;
1978            let mut hi = len;
1979            while lo < hi {
1980                let mid = (lo + hi) / 2;
1981                if pairs[tails[mid]].1 < v {
1982                    lo = mid + 1;
1983                } else {
1984                    hi = mid;
1985                }
1986            }
1987            if lo > 0 {
1988                prev[i] = tails[lo - 1];
1989            }
1990            tails[lo] = i;
1991            if lo == len {
1992                len += 1;
1993            }
1994        }
1995        let mut out = Vec::with_capacity(len);
1996        let mut k = tails[len.saturating_sub(1)];
1997        while k != usize::MAX {
1998            out.push(pairs[k]);
1999            k = prev[k];
2000        }
2001        out.reverse();
2002        out
2003    }
2004
2005    pub(crate) fn histogram_changed_flags(
2006        lines1: &[&str],
2007        lines2: &[&str],
2008    ) -> (Vec<bool>, Vec<bool>) {
2009        crate::diff::git_histogram::histogram_changed_flags(
2010            lines1,
2011            lines2,
2012            |changed1, changed2, o_start, o_end, n_start, n_end| {
2013                merge_myers_region(
2014                    lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
2015                );
2016            },
2017        )
2018    }
2019
2020    #[allow(clippy::too_many_arguments)]
2021    fn merge_myers_region(
2022        lines1: &[&str],
2023        lines2: &[&str],
2024        changed1: &mut [bool],
2025        changed2: &mut [bool],
2026        o_start: usize,
2027        o_end: usize,
2028        n_start: usize,
2029        n_end: usize,
2030    ) {
2031        use crate::line_interner::LineInterner;
2032
2033        let old_slice = &lines1[o_start..o_end];
2034        let new_slice = &lines2[n_start..n_end];
2035        if old_slice.is_empty() && new_slice.is_empty() {
2036            return;
2037        }
2038        let mut interner = LineInterner::with_capacity(old_slice.len() + new_slice.len());
2039        let ids1 = interner.intern_all(old_slice);
2040        let ids2 = interner.intern_all(new_slice);
2041        let (sub1, sub2) = changed_flags(&ids1, &ids2, false);
2042        for (i, ch) in sub1.iter().enumerate() {
2043            if *ch {
2044                changed1[o_start + i] = true;
2045            }
2046        }
2047        for (j, ch) in sub2.iter().enumerate() {
2048            if *ch {
2049                changed2[n_start + j] = true;
2050            }
2051        }
2052    }
2053}
2054
2055/// Myers changed-line masks for a line slice, honoring [`super::WhitespaceMode`] like Git `xdl_fall_back_diff`.
2056pub(crate) fn myers_rediff_changed_masks_for_region<'a>(
2057    old_lines: &[&'a str],
2058    new_lines: &[&'a str],
2059    ws: super::WhitespaceMode,
2060) -> (Vec<bool>, Vec<bool>) {
2061    use crate::line_interner::LineInterner;
2062
2063    if old_lines.is_empty() && new_lines.is_empty() {
2064        return (Vec::new(), Vec::new());
2065    }
2066    let normalize = |lines: &[&'a str]| -> Vec<Cow<'a, str>> {
2067        lines
2068            .iter()
2069            .map(|line| ws.normalize_line_for_compare(line))
2070            .collect()
2071    };
2072    let (old_normalized, new_normalized) = (normalize(old_lines), normalize(new_lines));
2073    let old_keys: Vec<&str> = old_normalized.iter().map(Cow::as_ref).collect();
2074    let new_keys: Vec<&str> = new_normalized.iter().map(Cow::as_ref).collect();
2075    let mut interner = LineInterner::with_capacity(old_keys.len() + new_keys.len());
2076    let ids1 = interner.intern_all(&old_keys);
2077    let ids2 = interner.intern_all(&new_keys);
2078    git_xdiff::changed_flags(&ids1, &ids2, false)
2079}
2080
2081/// Convert per-token `changed` flags (Git xdiff style) into `similar::DiffOp`s.
2082fn changed_flags_to_ops(
2083    changed1: &[bool],
2084    changed2: &[bool],
2085    old_len: usize,
2086    new_len: usize,
2087) -> Vec<similar::DiffOp> {
2088    use similar::DiffOp;
2089    let mut ops: Vec<DiffOp> = Vec::new();
2090    let mut i = 0usize;
2091    let mut j = 0usize;
2092    while i < old_len || j < new_len {
2093        let del = i < old_len && changed1[i];
2094        let ins = j < new_len && changed2[j];
2095        if !del && !ins {
2096            if i < old_len && j < new_len {
2097                // Equal run on both sides.
2098                let start_i = i;
2099                let start_j = j;
2100                while i < old_len && j < new_len && !changed1[i] && !changed2[j] {
2101                    i += 1;
2102                    j += 1;
2103                }
2104                ops.push(DiffOp::Equal {
2105                    old_index: start_i,
2106                    new_index: start_j,
2107                    len: i - start_i,
2108                });
2109            } else if i < old_len {
2110                let start_i = i;
2111                while i < old_len {
2112                    i += 1;
2113                }
2114                ops.push(DiffOp::Delete {
2115                    old_index: start_i,
2116                    old_len: i - start_i,
2117                    new_index: j,
2118                });
2119            } else if j < new_len {
2120                let start_j = j;
2121                while j < new_len {
2122                    j += 1;
2123                }
2124                ops.push(DiffOp::Insert {
2125                    old_index: i,
2126                    new_index: start_j,
2127                    new_len: j - start_j,
2128                });
2129            }
2130        } else {
2131            // Changed run: consecutive deletions then/and insertions.
2132            let start_i = i;
2133            let start_j = j;
2134            while i < old_len && changed1[i] {
2135                i += 1;
2136            }
2137            while j < new_len && changed2[j] {
2138                j += 1;
2139            }
2140            let dlen = i - start_i;
2141            let ilen = j - start_j;
2142            if dlen > 0 && ilen > 0 {
2143                ops.push(DiffOp::Replace {
2144                    old_index: start_i,
2145                    old_len: dlen,
2146                    new_index: start_j,
2147                    new_len: ilen,
2148                });
2149            } else if dlen > 0 {
2150                ops.push(DiffOp::Delete {
2151                    old_index: start_i,
2152                    old_len: dlen,
2153                    new_index: start_j,
2154                });
2155            } else if ilen > 0 {
2156                ops.push(DiffOp::Insert {
2157                    old_index: start_i,
2158                    new_index: start_j,
2159                    new_len: ilen,
2160                });
2161            }
2162        }
2163    }
2164    ops
2165}
2166
2167/// Line-diff ops for string slices after Git `xdl_change_compact` (and optional indent heuristic).
2168#[must_use]
2169pub fn diff_slice_ops_compacted(
2170    old_lines: &[&str],
2171    new_lines: &[&str],
2172    algorithm: similar::Algorithm,
2173    indent_heuristic: bool,
2174) -> Vec<similar::DiffOp> {
2175    diff_indent_heuristic::diff_slice_ops_compacted(
2176        old_lines,
2177        new_lines,
2178        algorithm,
2179        indent_heuristic,
2180    )
2181}
2182
2183/// Compacted line-diff ops from Git's xdiff Myers engine over precomputed line ids for each side
2184/// (default blame path; faster than `similar` on large files).
2185#[must_use]
2186///
2187/// `shared_counts` and `shared_records` describe identical records of both files left out of
2188/// the slices, as for [`git_xdiff::changed_flags_with_shared`]; pass `&[]` and `0` for whole files.
2189#[allow(clippy::too_many_arguments)]
2190pub(crate) fn line_diff_ops_git_xdiff_compacted_ids(
2191    old_ids: &[u32],
2192    new_ids: &[u32],
2193    shared_counts: &[u32],
2194    shared_records: usize,
2195    old_lines: &[&str],
2196    new_lines: &[&str],
2197    indent_heuristic: bool,
2198    ws: super::WhitespaceMode,
2199) -> Vec<similar::DiffOp> {
2200    let (changed1, changed2) = git_xdiff::changed_flags_with_shared(
2201        old_ids,
2202        new_ids,
2203        shared_counts,
2204        shared_records,
2205        false,
2206    );
2207    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2208    diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
2209        &ops,
2210        old_lines,
2211        new_lines,
2212        indent_heuristic,
2213        ws,
2214    )
2215}
2216
2217/// Whether each new-side line is marked changed by Git's xdiff engine (after change compaction).
2218#[cfg(test)]
2219#[must_use]
2220pub(crate) fn xdiff_new_line_changed_flags(
2221    old_lines: &[&str],
2222    new_lines: &[&str],
2223    indent_heuristic: bool,
2224) -> Vec<bool> {
2225    let mut interner =
2226        crate::line_interner::LineInterner::with_capacity(old_lines.len() + new_lines.len());
2227    let ids1 = interner.intern_all(old_lines);
2228    let ids2 = interner.intern_all(new_lines);
2229    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, false);
2230    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2231    let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
2232        &ops,
2233        old_lines,
2234        new_lines,
2235        indent_heuristic,
2236    );
2237    let mut flags = vec![false; new_lines.len()];
2238    for op in compacted {
2239        match op.tag() {
2240            similar::DiffTag::Insert | similar::DiffTag::Replace => {
2241                for idx in op.new_range() {
2242                    if idx < flags.len() {
2243                        flags[idx] = true;
2244                    }
2245                }
2246            }
2247            similar::DiffTag::Equal | similar::DiffTag::Delete => {}
2248        }
2249    }
2250    flags
2251}
2252
2253/// Map each line in `new_joined` to its origin in `old_joined` after Git-style compaction (for blame).
2254#[must_use]
2255pub fn map_new_to_old_lines_compacted(
2256    old_joined: &str,
2257    new_joined: &str,
2258    algorithm: similar::Algorithm,
2259    indent_heuristic: bool,
2260    new_line_count: usize,
2261) -> Vec<Option<usize>> {
2262    let ops = diff_indent_heuristic::diff_lines_ops_compacted(
2263        old_joined,
2264        new_joined,
2265        algorithm,
2266        indent_heuristic,
2267    );
2268    diff_indent_heuristic::map_new_to_old_from_ops(&ops, new_line_count)
2269}
2270
2271/// The kind of change between two sides of a diff.
2272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2273#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2274#[cfg_attr(feature = "serde-models", serde(rename_all = "snake_case"))]
2275pub enum DiffStatus {
2276    /// File was added.
2277    Added,
2278    /// File was deleted.
2279    Deleted,
2280    /// File was modified (content or mode change).
2281    Modified,
2282    /// File was renamed (with optional content change).
2283    Renamed,
2284    /// File was copied.
2285    Copied,
2286    /// File type changed (e.g. regular → symlink).
2287    TypeChanged,
2288    /// Unmerged (conflict).
2289    Unmerged,
2290}
2291
2292impl DiffStatus {
2293    /// Single-character status letter used in raw diff output.
2294    #[must_use]
2295    pub fn letter(&self) -> char {
2296        match self {
2297            Self::Added => 'A',
2298            Self::Deleted => 'D',
2299            Self::Modified => 'M',
2300            Self::Renamed => 'R',
2301            Self::Copied => 'C',
2302            Self::TypeChanged => 'T',
2303            Self::Unmerged => 'U',
2304        }
2305    }
2306
2307    /// Status for a path present on both sides with differing content or mode.
2308    ///
2309    /// Returns [`DiffStatus::TypeChanged`] when the object type bits of the two
2310    /// modes differ (regular file, symlink, gitlink) and [`DiffStatus::Modified`]
2311    /// otherwise, so a permission-only change stays `Modified`. Every diff mode
2312    /// (tree, index, work tree) shares this rule.
2313    #[must_use]
2314    pub(crate) fn for_content_change(old_mode: u32, new_mode: u32) -> Self {
2315        if old_mode & 0o170000 != new_mode & 0o170000 {
2316            Self::TypeChanged
2317        } else {
2318            Self::Modified
2319        }
2320    }
2321}
2322
2323/// A single diff entry representing one changed path.
2324#[derive(Debug, Clone, PartialEq, Eq)]
2325#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2326pub struct DiffEntry {
2327    /// The status of this change.
2328    pub status: DiffStatus,
2329    /// Path in the "old" side (None for Added).
2330    pub old_path: Option<String>,
2331    /// Path in the "new" side (None for Deleted).
2332    pub new_path: Option<String>,
2333    /// Old file mode ([`FileMode::ABSENT`] for Added).
2334    pub old_mode: FileMode,
2335    /// New file mode ([`FileMode::ABSENT`] for Deleted).
2336    pub new_mode: FileMode,
2337    /// Old object ID (zero OID for Added).
2338    pub old_oid: ObjectId,
2339    /// New object ID (zero OID for Deleted, or for Modified index↔worktree / tree↔worktree
2340    /// entries whose content lives only on disk — raw output uses the null OID on that side).
2341    pub new_oid: ObjectId,
2342    /// Similarity score (0–100) for renames/copies.
2343    pub score: Option<u32>,
2344}
2345
2346impl DiffEntry {
2347    /// The primary path for display (new_path for adds, old_path for deletes).
2348    #[must_use]
2349    pub fn path(&self) -> &str {
2350        self.new_path
2351            .as_deref()
2352            .or(self.old_path.as_deref())
2353            .unwrap_or("")
2354    }
2355
2356    /// Return a human-oriented path display for this entry.
2357    ///
2358    /// For renames and copies this returns `old -> new`; for all other entry
2359    /// kinds this returns the primary path.
2360    #[must_use]
2361    pub fn display_path(&self) -> String {
2362        match self.status {
2363            DiffStatus::Renamed | DiffStatus::Copied => {
2364                let old = self.old_path.as_deref().unwrap_or("");
2365                let new = self.new_path.as_deref().unwrap_or("");
2366                if old.is_empty() || new.is_empty() {
2367                    self.path().to_owned()
2368                } else {
2369                    format!("{old} -> {new}")
2370                }
2371            }
2372            _ => self.path().to_owned(),
2373        }
2374    }
2375}
2376
2377/// Return the zero ObjectId.
2378#[must_use]
2379pub fn zero_oid() -> ObjectId {
2380    ObjectId::from_bytes(&[0u8; 20]).unwrap_or_else(|_| {
2381        // This should never fail since we pass exactly 20 bytes
2382        panic!("internal error: failed to create zero OID");
2383    })
2384}
2385
2386/// Return the ObjectId for the empty blob object.
2387#[must_use]
2388pub fn empty_blob_oid() -> ObjectId {
2389    ObjectId::from_hex("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391").unwrap_or_else(|_| {
2390        // This should never fail since the object ID literal is valid.
2391        panic!("internal error: failed to create empty blob OID");
2392    })
2393}
2394
2395// ── Tree-to-tree diff ───────────────────────────────────────────────
2396
2397/// Compare two trees and return the list of changed entries.
2398///
2399/// # Parameters
2400///
2401/// - `odb` — object database to read tree objects from.
2402/// - `old_tree_oid` — OID of the old tree (or `None` for comparison against empty).
2403/// - `new_tree_oid` — OID of the new tree (or `None` for comparison against empty).
2404/// - `prefix` — path prefix for nested tree recursion (empty string for root).
2405///
2406/// # Errors
2407///
2408/// Returns errors from object database reads.
2409pub fn diff_trees(
2410    odb: &Odb,
2411    old_tree_oid: Option<&ObjectId>,
2412    new_tree_oid: Option<&ObjectId>,
2413    prefix: &str,
2414) -> Result<Vec<DiffEntry>> {
2415    crate::trace::trace_span!("grit.diff.tree");
2416    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, false, None)
2417}
2418
2419/// Like [`diff_trees`], but when `index` matches `old_tree_oid` deleted subtrees are expanded
2420/// from sorted index entries instead of reading every tree object from the ODB.
2421pub(crate) fn diff_trees_with_index(
2422    odb: &Odb,
2423    index: &Index,
2424    old_tree_oid: Option<&ObjectId>,
2425    new_tree_oid: Option<&ObjectId>,
2426    prefix: &str,
2427    trust_index_for_old_tree: bool,
2428) -> Result<Vec<DiffEntry>> {
2429    let hint = (trust_index_for_old_tree
2430        && old_tree_oid.is_some()
2431        && index.entries_sorted
2432        && !index.entries.iter().any(|e| e.stage() != 0))
2433    .then_some(DiffTreesIndexHint { index });
2434    diff_trees_opts(
2435        odb,
2436        old_tree_oid,
2437        new_tree_oid,
2438        prefix,
2439        false,
2440        hint.as_ref(),
2441    )
2442}
2443
2444#[derive(Clone, Copy)]
2445struct DiffTreesIndexHint<'a> {
2446    index: &'a Index,
2447}
2448
2449/// Count path-level differences between two trees without building a [`DiffEntry`] list.
2450///
2451/// Same semantics as [`diff_trees`], but avoids allocating diff records (issue #1064 commit hot path).
2452///
2453/// # Errors
2454///
2455/// Returns errors from object database reads.
2456pub fn diff_tree_path_change_count(
2457    odb: &Odb,
2458    old_tree_oid: Option<&ObjectId>,
2459    new_tree_oid: Option<&ObjectId>,
2460    prefix: &str,
2461) -> Result<usize> {
2462    if old_tree_oid == new_tree_oid {
2463        return Ok(0);
2464    }
2465    let old_entries = match old_tree_oid {
2466        Some(oid) => read_tree(odb, oid)?,
2467        None => Vec::new(),
2468    };
2469    let new_entries = match new_tree_oid {
2470        Some(oid) => read_tree(odb, oid)?,
2471        None => Vec::new(),
2472    };
2473    let mut count = 0usize;
2474    diff_tree_entries_count(odb, &old_entries, &new_entries, prefix, false, &mut count)?;
2475    Ok(count)
2476}
2477
2478/// Diff two trees and pair deleted/added paths as renames when content is similar enough.
2479///
2480/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
2481/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
2482///
2483/// # Errors
2484///
2485/// Propagates errors from [`diff_trees`].
2486pub fn diff_trees_with_renames(
2487    odb: &Odb,
2488    old_tree_oid: Option<&ObjectId>,
2489    new_tree_oid: Option<&ObjectId>,
2490    prefix: &str,
2491    rename_threshold: u32,
2492) -> Result<Vec<DiffEntry>> {
2493    let config = ConfigSet::new();
2494    let policy = ThreadPolicy::default();
2495    diff_trees_with_renames_and_policy(
2496        odb,
2497        old_tree_oid,
2498        new_tree_oid,
2499        prefix,
2500        rename_threshold,
2501        &config,
2502        &policy,
2503    )
2504}
2505
2506/// Like [`diff_trees_with_renames`], but honors `index.threads` / embedder thread policy.
2507///
2508/// # Errors
2509///
2510/// Propagates errors from [`diff_trees`].
2511pub fn diff_trees_with_renames_and_policy(
2512    odb: &Odb,
2513    old_tree_oid: Option<&ObjectId>,
2514    new_tree_oid: Option<&ObjectId>,
2515    prefix: &str,
2516    rename_threshold: u32,
2517    config: &ConfigSet,
2518    thread_policy: &ThreadPolicy,
2519) -> Result<Vec<DiffEntry>> {
2520    let mut entries = diff_trees(odb, old_tree_oid, new_tree_oid, prefix)?;
2521    if rename_threshold <= 100 {
2522        entries = detect_renames_with_policy(
2523            odb,
2524            None,
2525            entries,
2526            rename_threshold,
2527            config,
2528            thread_policy,
2529            None,
2530        )?;
2531    }
2532    Ok(entries)
2533}
2534
2535/// Return the tree OID stored in a commit object.
2536///
2537/// # Errors
2538///
2539/// Returns object read/parse errors when `commit_oid` is missing or not a commit.
2540pub fn tree_oid_of_commit(odb: &Odb, commit_oid: &ObjectId) -> Result<ObjectId> {
2541    let obj = odb.read(commit_oid)?;
2542    if obj.kind != ObjectKind::Commit {
2543        return Err(Error::InvalidRef(format!(
2544            "invalid ref: {commit_oid} is not a commit"
2545        )));
2546    }
2547    let commit = parse_commit(&obj.data)?;
2548    Ok(commit.tree)
2549}
2550
2551/// Like [`tree_oid_of_commit`], but uses the repository commit-graph cache when available.
2552///
2553/// # Errors
2554///
2555/// Returns object read/parse errors when `commit_oid` is missing or not a commit.
2556pub fn tree_oid_of_commit_in_repo(
2557    repo: &crate::repo::Repository,
2558    commit_oid: &ObjectId,
2559) -> Result<ObjectId> {
2560    if let Some(chain) = repo.caches().commit_graph_chain(repo.odb().objects_dir()) {
2561        if let Some(tree) = chain.graph_commit_tree(commit_oid) {
2562            return Ok(tree);
2563        }
2564    }
2565    tree_oid_of_commit(repo.odb(), commit_oid)
2566}
2567
2568/// Diff the trees recorded at two commits (`base` is the old side, `tip` the new side).
2569///
2570/// # Errors
2571///
2572/// Propagates object read errors and tree diff failures.
2573pub fn diff_commits(odb: &Odb, base: &ObjectId, tip: &ObjectId) -> Result<Vec<DiffEntry>> {
2574    let base_tree = tree_oid_of_commit(odb, base)?;
2575    let tip_tree = tree_oid_of_commit(odb, tip)?;
2576    diff_trees(odb, Some(&base_tree), Some(&tip_tree), "")
2577}
2578
2579/// Diff the trees at two commits and pair deleted/added paths as renames when similar enough.
2580///
2581/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
2582/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
2583///
2584/// # Errors
2585///
2586/// Propagates object read errors and tree diff failures.
2587pub fn diff_commits_with_renames(
2588    odb: &Odb,
2589    base: &ObjectId,
2590    tip: &ObjectId,
2591    rename_threshold: u32,
2592) -> Result<Vec<DiffEntry>> {
2593    let config = ConfigSet::new();
2594    let policy = ThreadPolicy::default();
2595    diff_commits_with_renames_and_policy(odb, base, tip, rename_threshold, &config, &policy)
2596}
2597
2598/// Like [`diff_commits_with_renames`], but honors `index.threads` / embedder thread policy.
2599///
2600/// # Errors
2601///
2602/// Propagates object read errors and tree diff failures.
2603pub fn diff_commits_with_renames_and_policy(
2604    odb: &Odb,
2605    base: &ObjectId,
2606    tip: &ObjectId,
2607    rename_threshold: u32,
2608    config: &ConfigSet,
2609    thread_policy: &ThreadPolicy,
2610) -> Result<Vec<DiffEntry>> {
2611    let base_tree = tree_oid_of_commit(odb, base)?;
2612    let tip_tree = tree_oid_of_commit(odb, tip)?;
2613    diff_trees_with_renames_and_policy(
2614        odb,
2615        Some(&base_tree),
2616        Some(&tip_tree),
2617        "",
2618        rename_threshold,
2619        config,
2620        thread_policy,
2621    )
2622}
2623
2624/// Like [`diff_trees`], but emit entries for tree objects themselves in addition
2625/// to their recursive contents (Git's `diff-tree -t` behavior).
2626///
2627/// # Errors
2628///
2629/// Returns errors from object database reads.
2630pub fn diff_trees_show_tree_entries(
2631    odb: &Odb,
2632    old_tree_oid: Option<&ObjectId>,
2633    new_tree_oid: Option<&ObjectId>,
2634    prefix: &str,
2635) -> Result<Vec<DiffEntry>> {
2636    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, true, None)
2637}
2638
2639fn diff_subtrees_in_place(
2640    odb: &Odb,
2641    old_tree_oid: Option<&ObjectId>,
2642    new_tree_oid: Option<&ObjectId>,
2643    prefix: &str,
2644    show_trees: bool,
2645    index_hint: Option<&DiffTreesIndexHint<'_>>,
2646    result: &mut Vec<DiffEntry>,
2647) -> Result<()> {
2648    if !show_trees {
2649        if let (Some(a), Some(b)) = (old_tree_oid, new_tree_oid) {
2650            if a == b {
2651                return Ok(());
2652            }
2653        }
2654    }
2655    // The ODB already budgets parsed trees. Keep only active recursion frames
2656    // here so completing siblings can release entries evicted from that cache.
2657    let old_entries = old_tree_oid
2658        .map(|oid| odb.cached_tree_entries(oid))
2659        .transpose()?;
2660    let new_entries = new_tree_oid
2661        .map(|oid| odb.cached_tree_entries(oid))
2662        .transpose()?;
2663    diff_tree_entries_opts(
2664        odb,
2665        old_entries
2666            .as_deref()
2667            .map(Vec::as_slice)
2668            .unwrap_or_default(),
2669        new_entries
2670            .as_deref()
2671            .map(Vec::as_slice)
2672            .unwrap_or_default(),
2673        prefix,
2674        show_trees,
2675        index_hint,
2676        result,
2677    )
2678}
2679
2680fn diff_trees_opts(
2681    odb: &Odb,
2682    old_tree_oid: Option<&ObjectId>,
2683    new_tree_oid: Option<&ObjectId>,
2684    prefix: &str,
2685    show_trees: bool,
2686    index_hint: Option<&DiffTreesIndexHint<'_>>,
2687) -> Result<Vec<DiffEntry>> {
2688    let mut result = Vec::new();
2689    diff_subtrees_in_place(
2690        odb,
2691        old_tree_oid,
2692        new_tree_oid,
2693        prefix,
2694        show_trees,
2695        index_hint,
2696        &mut result,
2697    )?;
2698    Ok(result)
2699}
2700
2701#[cfg(test)]
2702use std::cell::RefCell;
2703#[cfg(test)]
2704use std::rc::Rc;
2705#[cfg(test)]
2706use std::sync::atomic::{AtomicUsize, Ordering};
2707
2708#[cfg(test)]
2709thread_local! {
2710    static DIFF_TREE_READ_HOOK: RefCell<Option<Rc<AtomicUsize>>> = const { RefCell::new(None) };
2711}
2712
2713/// Install a per-thread tree read counter for unit tests (cleared when `hook` is `None`).
2714#[cfg(test)]
2715pub(crate) fn set_diff_tree_read_hook_for_tests(hook: Option<Rc<AtomicUsize>>) {
2716    DIFF_TREE_READ_HOOK.with(|cell| *cell.borrow_mut() = hook);
2717}
2718
2719/// Read and parse a tree object from the ODB (no session cache).
2720fn read_tree_from_odb(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
2721    #[cfg(test)]
2722    if let Some(counter) = DIFF_TREE_READ_HOOK.with(|cell| cell.borrow().clone()) {
2723        counter.fetch_add(1, Ordering::Relaxed);
2724    }
2725    Ok((*odb.cached_tree_entries(oid)?).clone())
2726}
2727
2728/// Read and parse a tree object from the ODB.
2729fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
2730    read_tree_from_odb(odb, oid)
2731}
2732
2733fn diff_tree_entries_count(
2734    odb: &Odb,
2735    old: &[TreeEntry],
2736    new: &[TreeEntry],
2737    prefix: &str,
2738    show_trees: bool,
2739    count: &mut usize,
2740) -> Result<()> {
2741    let mut oi = 0;
2742    let mut ni = 0;
2743
2744    while oi < old.len() || ni < new.len() {
2745        match (old.get(oi), new.get(ni)) {
2746            (Some(o), Some(n)) => {
2747                let cmp = crate::objects::tree_entry_cmp(
2748                    &o.name,
2749                    is_tree_mode(o.mode),
2750                    &n.name,
2751                    is_tree_mode(n.mode),
2752                );
2753                match cmp {
2754                    std::cmp::Ordering::Less => {
2755                        emit_deleted_count(odb, o, prefix, show_trees, count)?;
2756                        oi += 1;
2757                    }
2758                    std::cmp::Ordering::Greater => {
2759                        emit_added_count(odb, n, prefix, show_trees, count)?;
2760                        ni += 1;
2761                    }
2762                    std::cmp::Ordering::Equal => {
2763                        if o.oid != n.oid || o.mode != n.mode {
2764                            let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
2765                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
2766                                if show_trees {
2767                                    *count += 1;
2768                                }
2769                                *count += diff_tree_path_change_count(
2770                                    odb,
2771                                    Some(&o.oid),
2772                                    Some(&n.oid),
2773                                    &path,
2774                                )?;
2775                            } else if is_tree_mode(o.mode) != is_tree_mode(n.mode) {
2776                                emit_deleted_count(odb, o, prefix, show_trees, count)?;
2777                                emit_added_count(odb, n, prefix, show_trees, count)?;
2778                            } else {
2779                                *count += 1;
2780                            }
2781                        }
2782                        oi += 1;
2783                        ni += 1;
2784                    }
2785                }
2786            }
2787            (Some(o), None) => {
2788                emit_deleted_count(odb, o, prefix, show_trees, count)?;
2789                oi += 1;
2790            }
2791            (None, Some(n)) => {
2792                emit_added_count(odb, n, prefix, show_trees, count)?;
2793                ni += 1;
2794            }
2795            (None, None) => break,
2796        }
2797    }
2798
2799    Ok(())
2800}
2801
2802fn emit_deleted_count(
2803    odb: &Odb,
2804    entry: &TreeEntry,
2805    prefix: &str,
2806    show_trees: bool,
2807    count: &mut usize,
2808) -> Result<()> {
2809    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
2810    if is_tree_mode(entry.mode) {
2811        if show_trees {
2812            *count += 1;
2813        }
2814        *count += diff_tree_path_change_count(odb, Some(&entry.oid), None, &path)?;
2815    } else {
2816        *count += 1;
2817    }
2818    Ok(())
2819}
2820
2821fn emit_added_count(
2822    odb: &Odb,
2823    entry: &TreeEntry,
2824    prefix: &str,
2825    show_trees: bool,
2826    count: &mut usize,
2827) -> Result<()> {
2828    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
2829    if is_tree_mode(entry.mode) {
2830        if show_trees {
2831            *count += 1;
2832        }
2833        *count += diff_tree_path_change_count(odb, None, Some(&entry.oid), &path)?;
2834    } else {
2835        *count += 1;
2836    }
2837    Ok(())
2838}
2839
2840/// Compare two sorted lists of tree entries, recursing into subtrees.
2841fn diff_tree_entries_opts(
2842    odb: &Odb,
2843    old: &[TreeEntry],
2844    new: &[TreeEntry],
2845    prefix: &str,
2846    show_trees: bool,
2847    index_hint: Option<&DiffTreesIndexHint<'_>>,
2848    result: &mut Vec<DiffEntry>,
2849) -> Result<()> {
2850    let mut oi = 0;
2851    let mut ni = 0;
2852
2853    while oi < old.len() || ni < new.len() {
2854        match (old.get(oi), new.get(ni)) {
2855            (Some(o), Some(n)) => {
2856                let cmp = crate::objects::tree_entry_cmp(
2857                    &o.name,
2858                    is_tree_mode(o.mode),
2859                    &n.name,
2860                    is_tree_mode(n.mode),
2861                );
2862                match cmp {
2863                    std::cmp::Ordering::Less => {
2864                        // Old entry not in new → deleted
2865                        emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
2866                        oi += 1;
2867                    }
2868                    std::cmp::Ordering::Greater => {
2869                        // New entry not in old → added
2870                        emit_added_opts(odb, n, prefix, show_trees, result)?;
2871                        ni += 1;
2872                    }
2873                    std::cmp::Ordering::Equal => {
2874                        // Both present — check for changes
2875                        if o.oid != n.oid || o.mode != n.mode {
2876                            let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
2877                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
2878                                // Both are trees
2879                                if show_trees {
2880                                    result.push(DiffEntry {
2881                                        status: DiffStatus::Modified,
2882                                        old_path: Some(path.clone()),
2883                                        new_path: Some(path.clone()),
2884                                        old_mode: FileMode::new(o.mode),
2885                                        new_mode: FileMode::new(n.mode),
2886                                        old_oid: o.oid,
2887                                        new_oid: n.oid,
2888                                        score: None,
2889                                    });
2890                                }
2891                                diff_subtrees_in_place(
2892                                    odb,
2893                                    Some(&o.oid),
2894                                    Some(&n.oid),
2895                                    &path,
2896                                    show_trees,
2897                                    index_hint,
2898                                    result,
2899                                )?;
2900                            } else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
2901                                // Tree → blob: delete tree contents, add blob
2902                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
2903                                emit_added_opts(odb, n, prefix, show_trees, result)?;
2904                            } else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
2905                                // Blob → tree: delete blob, add tree contents
2906                                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
2907                                emit_added_opts(odb, n, prefix, show_trees, result)?;
2908                            } else {
2909                                result.push(DiffEntry {
2910                                    status: DiffStatus::for_content_change(o.mode, n.mode),
2911                                    old_path: Some(path.clone()),
2912                                    new_path: Some(path),
2913                                    old_mode: FileMode::new(o.mode),
2914                                    new_mode: FileMode::new(n.mode),
2915                                    old_oid: o.oid,
2916                                    new_oid: n.oid,
2917                                    score: None,
2918                                });
2919                            }
2920                        }
2921                        oi += 1;
2922                        ni += 1;
2923                    }
2924                }
2925            }
2926            (Some(o), None) => {
2927                emit_deleted_opts(odb, o, prefix, show_trees, index_hint, result)?;
2928                oi += 1;
2929            }
2930            (None, Some(n)) => {
2931                emit_added_opts(odb, n, prefix, show_trees, result)?;
2932                ni += 1;
2933            }
2934            (None, None) => break,
2935        }
2936    }
2937
2938    Ok(())
2939}
2940
2941fn emit_deleted_tree_from_index(index: &Index, dir_prefix: &str, result: &mut Vec<DiffEntry>) {
2942    let scan_prefix: Vec<u8> = if dir_prefix.is_empty() {
2943        Vec::new()
2944    } else {
2945        format!("{dir_prefix}/").into_bytes()
2946    };
2947    if index.entries_sorted {
2948        let start = if scan_prefix.is_empty() {
2949            0
2950        } else {
2951            index
2952                .entries
2953                .partition_point(|e| e.path.as_slice() < scan_prefix.as_slice())
2954        };
2955        for ie in index.entries[start..].iter() {
2956            if ie.stage() != 0 {
2957                continue;
2958            }
2959            if !scan_prefix.is_empty() && !ie.path.starts_with(&scan_prefix) {
2960                break;
2961            }
2962            if scan_prefix.is_empty() && ie.path.contains(&b'/') {
2963                continue;
2964            }
2965            let path = String::from_utf8_lossy(&ie.path).into_owned();
2966            result.push(DiffEntry {
2967                status: DiffStatus::Deleted,
2968                old_path: Some(path),
2969                new_path: None,
2970                old_mode: FileMode::new(ie.mode),
2971                new_mode: FileMode::ABSENT,
2972                old_oid: ie.oid,
2973                new_oid: zero_oid(),
2974                score: None,
2975            });
2976        }
2977        return;
2978    }
2979    for ie in &index.entries {
2980        if ie.stage() != 0 {
2981            continue;
2982        }
2983        let Ok(path) = std::str::from_utf8(&ie.path) else {
2984            continue;
2985        };
2986        let under = if dir_prefix.is_empty() {
2987            !path.contains('/')
2988        } else if path == dir_prefix {
2989            false
2990        } else {
2991            path.strip_prefix(dir_prefix)
2992                .is_some_and(|rest| rest.starts_with('/'))
2993        };
2994        if !under {
2995            continue;
2996        }
2997        result.push(DiffEntry {
2998            status: DiffStatus::Deleted,
2999            old_path: Some(path.to_owned()),
3000            new_path: None,
3001            old_mode: FileMode::new(ie.mode),
3002            new_mode: FileMode::ABSENT,
3003            old_oid: ie.oid,
3004            new_oid: zero_oid(),
3005            score: None,
3006        });
3007    }
3008}
3009
3010fn emit_deleted_opts(
3011    odb: &Odb,
3012    entry: &TreeEntry,
3013    prefix: &str,
3014    show_trees: bool,
3015    index_hint: Option<&DiffTreesIndexHint<'_>>,
3016    result: &mut Vec<DiffEntry>,
3017) -> Result<()> {
3018    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3019    if is_tree_mode(entry.mode) {
3020        if show_trees {
3021            result.push(DiffEntry {
3022                status: DiffStatus::Deleted,
3023                old_path: Some(path.clone()),
3024                new_path: None,
3025                old_mode: FileMode::new(entry.mode),
3026                new_mode: FileMode::ABSENT,
3027                old_oid: entry.oid,
3028                new_oid: zero_oid(),
3029                score: None,
3030            });
3031        }
3032        if let Some(hint) =
3033            index_hint.filter(|hint| !hint.index.has_sparse_directory_placeholders())
3034        {
3035            emit_deleted_tree_from_index(hint.index, &path, result);
3036            return Ok(());
3037        }
3038        diff_subtrees_in_place(odb, Some(&entry.oid), None, &path, show_trees, None, result)?;
3039    } else {
3040        result.push(DiffEntry {
3041            status: DiffStatus::Deleted,
3042            old_path: Some(path.clone()),
3043            new_path: None,
3044            old_mode: FileMode::new(entry.mode),
3045            new_mode: FileMode::ABSENT,
3046            old_oid: entry.oid,
3047            new_oid: zero_oid(),
3048            score: None,
3049        });
3050    }
3051    Ok(())
3052}
3053
3054fn emit_added_opts(
3055    odb: &Odb,
3056    entry: &TreeEntry,
3057    prefix: &str,
3058    show_trees: bool,
3059    result: &mut Vec<DiffEntry>,
3060) -> Result<()> {
3061    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3062    if is_tree_mode(entry.mode) {
3063        if show_trees {
3064            result.push(DiffEntry {
3065                status: DiffStatus::Added,
3066                old_path: None,
3067                new_path: Some(path.clone()),
3068                old_mode: FileMode::ABSENT,
3069                new_mode: FileMode::new(entry.mode),
3070                old_oid: zero_oid(),
3071                new_oid: entry.oid,
3072                score: None,
3073            });
3074        }
3075        diff_subtrees_in_place(odb, None, Some(&entry.oid), &path, show_trees, None, result)?;
3076    } else {
3077        result.push(DiffEntry {
3078            status: DiffStatus::Added,
3079            old_path: None,
3080            new_path: Some(path),
3081            old_mode: FileMode::ABSENT,
3082            new_mode: FileMode::new(entry.mode),
3083            old_oid: zero_oid(),
3084            new_oid: entry.oid,
3085            score: None,
3086        });
3087    }
3088    Ok(())
3089}
3090
3091// ── Index-to-tree diff (staged changes) ─────────────────────────────
3092
3093/// Compare the index against a tree (usually HEAD's tree).
3094///
3095/// This shows "staged" changes — what would be committed.
3096///
3097/// # Parameters
3098///
3099/// - `odb` — object database.
3100/// - `index` — the current index.
3101/// - `tree_oid` — the tree to compare against (e.g. HEAD's tree), or `None`
3102///   for comparison against an empty tree (initial commit).
3103///
3104/// # Errors
3105///
3106/// Returns errors from ODB reads.
3107///
3108/// When `ignore_submodules` is true, gitlink (`160000`) paths are omitted from the diff, matching
3109/// Git's `require_clean_work_tree(..., ignore_submodules=1)` used by `git rebase` / `git pull`.
3110pub fn diff_index_to_tree(
3111    odb: &Odb,
3112    index: &Index,
3113    tree_oid: Option<&ObjectId>,
3114    ignore_submodules: bool,
3115) -> Result<Vec<DiffEntry>> {
3116    match index_cache_tree_walk(index, tree_oid) {
3117        CacheTreeWalk::Fast {
3118            head_tree,
3119            cache_root,
3120        } => {
3121            diff_index_to_tree_with_cache_tree(odb, index, head_tree, cache_root, ignore_submodules)
3122        }
3123        CacheTreeWalk::Flatten => {
3124            diff_index_to_tree_flatten(odb, index, tree_oid, ignore_submodules)
3125        }
3126    }
3127}
3128
3129/// Diff the index against a tree and pair deleted/added paths as renames when similar enough.
3130///
3131/// `rename_threshold` is Git's `-M` percentage (50 is the usual default); pass `100` to skip
3132/// rename detection.
3133///
3134/// # Errors
3135///
3136/// Propagates errors from [`diff_index_to_tree`].
3137pub fn diff_index_to_tree_with_renames(
3138    odb: &Odb,
3139    index: &Index,
3140    tree_oid: Option<&ObjectId>,
3141    ignore_submodules: bool,
3142    rename_threshold: u32,
3143    config: &ConfigSet,
3144    thread_policy: &ThreadPolicy,
3145) -> Result<Vec<DiffEntry>> {
3146    let entries = diff_index_to_tree(odb, index, tree_oid, ignore_submodules)?;
3147    apply_rename_detection_to_diff_entries(
3148        odb,
3149        entries,
3150        None,
3151        rename_threshold,
3152        config,
3153        thread_policy,
3154    )
3155}
3156
3157/// Apply Git `-M` rename detection to a flat diff (index↔tree, tree↔tree, or index↔worktree).
3158///
3159/// Pass `work_root` when added sides may be read from the working tree (index↔worktree diffs).
3160/// Use `rename_threshold` `100` to leave entries unchanged.
3161///
3162/// # Errors
3163///
3164/// Propagates object read failures while scoring renames.
3165pub fn apply_rename_detection_to_diff_entries(
3166    odb: &Odb,
3167    entries: Vec<DiffEntry>,
3168    work_root: Option<&Path>,
3169    rename_threshold: u32,
3170    config: &ConfigSet,
3171    thread_policy: &ThreadPolicy,
3172) -> Result<Vec<DiffEntry>> {
3173    if entries.is_empty() || rename_threshold >= 100 {
3174        return Ok(entries);
3175    }
3176    if work_root.is_some() {
3177        let opts = DiffPostprocessOptions {
3178            rename_threshold,
3179            ..DiffPostprocessOptions::default()
3180        };
3181        postprocess_diff_entries(
3182            odb,
3183            entries,
3184            None,
3185            work_root,
3186            None,
3187            config,
3188            thread_policy,
3189            &opts,
3190        )
3191    } else {
3192        detect_renames_with_policy(
3193            odb,
3194            None,
3195            entries,
3196            rename_threshold,
3197            config,
3198            thread_policy,
3199            None,
3200        )
3201    }
3202}
3203
3204/// Add [`DiffStatus::Added`] entries for untracked worktree files so rename detection can pair
3205/// plain filesystem moves (index still lists the old path, new path only on disk).
3206///
3207/// The returned path set lists every synthetic added candidate. After rename detection, drop
3208/// entries whose `new_path` is still in that set and still [`DiffStatus::Added`] — they were
3209/// not consumed by a rename pair and must not appear in the diff (Git does not report unrelated
3210/// untracked paths in `git diff`).
3211///
3212/// # Errors
3213///
3214/// Propagates I/O and ignore-rule load failures while scanning the work tree.
3215pub fn append_untracked_worktree_added_for_rename_detection(
3216    repo: &Repository,
3217    index: &Index,
3218    rules: &WorktreeRules,
3219    mut entries: Vec<DiffEntry>,
3220) -> Result<(Vec<DiffEntry>, HashSet<String>)> {
3221    let mut synthetic_new_paths = HashSet::new();
3222    if !entries.iter().any(|e| e.status == DiffStatus::Deleted) {
3223        return Ok((entries, synthetic_new_paths));
3224    }
3225    let mut existing_new: HashSet<String> =
3226        entries.iter().filter_map(|e| e.new_path.clone()).collect();
3227    let untracked = list_untracked_worktree_files_for_rename(repo, index, rules)?;
3228    for path in untracked {
3229        if existing_new.contains(&path) {
3230            continue;
3231        }
3232        existing_new.insert(path.clone());
3233        synthetic_new_paths.insert(path.clone());
3234        entries.push(DiffEntry {
3235            status: DiffStatus::Added,
3236            old_path: None,
3237            new_path: Some(path),
3238            old_mode: FileMode::ABSENT,
3239            new_mode: FileMode::REGULAR,
3240            old_oid: zero_oid(),
3241            new_oid: zero_oid(),
3242            score: None,
3243        });
3244    }
3245    Ok((entries, synthetic_new_paths))
3246}
3247
3248/// Drop synthetic untracked rename candidates that rename detection did not pair.
3249#[must_use]
3250pub fn drop_unused_synthetic_rename_candidates(
3251    entries: Vec<DiffEntry>,
3252    synthetic_new_paths: &HashSet<String>,
3253) -> Vec<DiffEntry> {
3254    if synthetic_new_paths.is_empty() {
3255        return entries;
3256    }
3257    entries
3258        .into_iter()
3259        .filter(|e| {
3260            !(e.status == DiffStatus::Added
3261                && e.new_path
3262                    .as_ref()
3263                    .is_some_and(|p| synthetic_new_paths.contains(p)))
3264        })
3265        .collect()
3266}
3267
3268fn list_untracked_worktree_files_for_rename(
3269    repo: &Repository,
3270    index: &Index,
3271    rules: &WorktreeRules,
3272) -> Result<Vec<String>> {
3273    let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
3274    let mut out = Vec::new();
3275    walk_untracked_worktree_files(repo, index, work_tree, "", rules, &mut out)?;
3276    out.sort();
3277    Ok(out)
3278}
3279
3280fn walk_untracked_worktree_files(
3281    repo: &Repository,
3282    index: &Index,
3283    dir: &Path,
3284    rel_prefix: &str,
3285    rules: &WorktreeRules,
3286    out: &mut Vec<String>,
3287) -> Result<()> {
3288    let read = match fs::read_dir(dir) {
3289        Ok(r) => r,
3290        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3291        Err(e) => return Err(Error::Io(e)),
3292    };
3293    for entry in read {
3294        let entry = entry.map_err(Error::Io)?;
3295        let name = entry.file_name();
3296        let name = name.to_string_lossy();
3297        if rel_prefix.is_empty() && name == ".git" {
3298            continue;
3299        }
3300        let rel_path = if rel_prefix.is_empty() {
3301            name.to_string()
3302        } else {
3303            format!("{rel_prefix}/{name}")
3304        };
3305        let file_type = entry.file_type().map_err(Error::Io)?;
3306        if file_type.is_dir() {
3307            let (ignored, _) = rules
3308                .ignore_mut()
3309                .check_path(repo, Some(index), &rel_path, true)?;
3310            if ignored {
3311                continue;
3312            }
3313            walk_untracked_worktree_files(
3314                repo,
3315                index,
3316                &dir.join(name.as_ref()),
3317                &rel_path,
3318                rules,
3319                out,
3320            )?;
3321        } else if file_type.is_file() || file_type.is_symlink() {
3322            if index.get(rel_path.as_bytes(), 0).is_some() {
3323                continue;
3324            }
3325            let (ignored, _) =
3326                rules
3327                    .ignore_mut()
3328                    .check_path(repo, Some(index), &rel_path, false)?;
3329            if !ignored {
3330                out.push(rel_path);
3331            }
3332        }
3333    }
3334    Ok(())
3335}
3336
3337/// Diff the index against the working tree and pair deleted/added paths as renames when similar.
3338///
3339/// Runs [`diff_index_to_worktree_with_options`], appends untracked worktree paths as added
3340/// candidates when needed, then [`apply_rename_detection_to_diff_entries`] with `work_root`
3341/// set to `work_tree`.
3342///
3343/// # Errors
3344///
3345/// Propagates errors from the index↔worktree diff or rename post-processing.
3346pub fn diff_index_to_worktree_with_renames(
3347    repo: &Repository,
3348    index: &mut Index,
3349    work_tree: &Path,
3350    options: DiffIndexToWorktreeOptions,
3351    rename_threshold: u32,
3352) -> Result<(Vec<DiffEntry>, bool)> {
3353    let config = repo.config()?.as_ref().clone();
3354    let thread_policy = repo.thread_policy();
3355    let odb = repo.odb();
3356    let rules_for_append = options.worktree_rules.clone();
3357    let (mut entries, changed) =
3358        diff_index_to_worktree_with_options(odb, index, work_tree, options)?;
3359    let mut synthetic_new_paths = HashSet::new();
3360    if rename_threshold < 100 {
3361        if let Some(rules_arc) = rules_for_append {
3362            let rules = rules_arc
3363                .lock()
3364                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))?;
3365            (entries, synthetic_new_paths) =
3366                append_untracked_worktree_added_for_rename_detection(repo, index, &rules, entries)?;
3367        } else {
3368            let rules = WorktreeRules::from_repository(repo, index)?;
3369            (entries, synthetic_new_paths) =
3370                append_untracked_worktree_added_for_rename_detection(repo, index, &rules, entries)?;
3371        }
3372    }
3373    let entries = apply_rename_detection_to_diff_entries(
3374        odb,
3375        entries,
3376        Some(work_tree),
3377        rename_threshold,
3378        &config,
3379        &thread_policy,
3380    )?;
3381    let entries = drop_unused_synthetic_rename_candidates(entries, &synthetic_new_paths);
3382    Ok((entries, changed))
3383}
3384
3385enum CacheTreeWalk<'a> {
3386    Fast {
3387        head_tree: &'a ObjectId,
3388        cache_root: &'a CacheTreeNode,
3389    },
3390    Flatten,
3391}
3392
3393/// Whether the index may use the cache-tree walker (possibly with an invalid root and valid
3394/// descendant nodes after partial invalidation).
3395fn index_cache_tree_walk<'a>(
3396    index: &'a Index,
3397    tree_oid: Option<&'a ObjectId>,
3398) -> CacheTreeWalk<'a> {
3399    let Some(head_tree) = tree_oid else {
3400        return CacheTreeWalk::Flatten;
3401    };
3402    let Some(cache_root) = index.cache_tree.as_ref() else {
3403        return CacheTreeWalk::Flatten;
3404    };
3405    if index.entries.iter().any(|e| e.stage() != 0) {
3406        return CacheTreeWalk::Flatten;
3407    }
3408    CacheTreeWalk::Fast {
3409        head_tree,
3410        cache_root,
3411    }
3412}
3413
3414/// Flatten-tree index-vs-tree diff (used when the index has no cache-tree extension).
3415fn diff_index_to_tree_flatten(
3416    odb: &Odb,
3417    index: &Index,
3418    tree_oid: Option<&ObjectId>,
3419    ignore_submodules: bool,
3420) -> Result<Vec<DiffEntry>> {
3421    // Flatten the tree into a sorted list of (path, mode, oid)
3422    let tree_entries = match tree_oid {
3423        Some(oid) => flatten_tree(odb, oid, "")?,
3424        None => Vec::new(),
3425    };
3426
3427    // Build maps keyed by path
3428    let mut tree_map: std::collections::BTreeMap<&str, &FlatEntry> =
3429        std::collections::BTreeMap::new();
3430    for entry in &tree_entries {
3431        tree_map.insert(&entry.path, entry);
3432    }
3433
3434    let mut result = Vec::new();
3435    let mut stage0_paths = std::collections::BTreeSet::new();
3436    let mut unmerged_modes: std::collections::BTreeMap<String, (u8, u32)> =
3437        std::collections::BTreeMap::new();
3438
3439    // Check index entries against tree
3440    for ie in &index.entries {
3441        let path = String::from_utf8_lossy(&ie.path).to_string();
3442        if ie.stage() == 0 && ie.intent_to_add() {
3443            // Intent-to-add entries are not "staged" for diff-index / status
3444            // (matches Git: `git diff --cached` is empty for `-N` paths).
3445            continue;
3446        }
3447        if ie.stage() != 0 {
3448            let rank = match ie.stage() {
3449                2 => 0u8,
3450                3 => 1u8,
3451                1 => 2u8,
3452                _ => 3u8,
3453            };
3454            match unmerged_modes.get(&path) {
3455                Some((existing_rank, _)) if *existing_rank <= rank => {}
3456                _ => {
3457                    unmerged_modes.insert(path, (rank, ie.mode));
3458                }
3459            }
3460            continue;
3461        }
3462        if ignore_submodules && ie.mode == 0o160000 {
3463            let _ = tree_map.remove(path.as_str());
3464            stage0_paths.insert(path.clone());
3465            continue;
3466        }
3467        stage0_paths.insert(path.clone());
3468        match tree_map.remove(path.as_str()) {
3469            Some(te) => {
3470                // Present in both — check for differences
3471                if te.oid != ie.oid || te.mode != ie.mode {
3472                    result.push(DiffEntry {
3473                        status: DiffStatus::for_content_change(te.mode, ie.mode),
3474                        old_path: Some(path.clone()),
3475                        new_path: Some(path),
3476                        old_mode: FileMode::new(te.mode),
3477                        new_mode: FileMode::new(ie.mode),
3478                        old_oid: te.oid,
3479                        new_oid: ie.oid,
3480                        score: None,
3481                    });
3482                }
3483            }
3484            None => {
3485                // In index but not tree → added
3486                result.push(DiffEntry {
3487                    status: DiffStatus::Added,
3488                    old_path: None,
3489                    new_path: Some(path),
3490                    old_mode: FileMode::ABSENT,
3491                    new_mode: FileMode::new(ie.mode),
3492                    old_oid: zero_oid(),
3493                    new_oid: ie.oid,
3494                    score: None,
3495                });
3496            }
3497        }
3498    }
3499
3500    diff_index_to_tree_finish_unmerged_and_deleted(
3501        ignore_submodules,
3502        &mut tree_map,
3503        &unmerged_modes,
3504        &stage0_paths,
3505        &mut result,
3506    );
3507
3508    sort_entries_by_path(&mut result);
3509    Ok(result)
3510}
3511
3512fn diff_index_to_tree_finish_unmerged_and_deleted(
3513    ignore_submodules: bool,
3514    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
3515    unmerged_modes: &std::collections::BTreeMap<String, (u8, u32)>,
3516    stage0_paths: &std::collections::BTreeSet<String>,
3517    result: &mut Vec<DiffEntry>,
3518) {
3519    for (path, (_, mode)) in unmerged_modes {
3520        if stage0_paths.contains(path) {
3521            continue;
3522        }
3523        tree_map.remove(path.as_str());
3524        result.push(DiffEntry {
3525            status: DiffStatus::Unmerged,
3526            old_path: Some(path.clone()),
3527            new_path: Some(path.clone()),
3528            old_mode: FileMode::ABSENT,
3529            new_mode: FileMode::new(*mode),
3530            old_oid: zero_oid(),
3531            new_oid: zero_oid(),
3532            score: None,
3533        });
3534    }
3535
3536    // Remaining tree entries not in index → deleted
3537    for (path, te) in std::mem::take(tree_map) {
3538        if ignore_submodules && te.mode == 0o160000 {
3539            continue;
3540        }
3541        result.push(DiffEntry {
3542            status: DiffStatus::Deleted,
3543            old_path: Some(path.to_owned()),
3544            new_path: None,
3545            old_mode: FileMode::new(te.mode),
3546            new_mode: FileMode::ABSENT,
3547            old_oid: te.oid,
3548            new_oid: zero_oid(),
3549            score: None,
3550        });
3551    }
3552}
3553
3554/// Cache-tree-aware index-vs-tree diff: walk HEAD tree and index together and skip
3555/// subtrees whose valid cache-tree node OID matches the tree entry (Git unpack-trees).
3556fn diff_index_to_tree_with_cache_tree(
3557    odb: &Odb,
3558    index: &Index,
3559    head_tree: &ObjectId,
3560    cache_root: &CacheTreeNode,
3561    ignore_submodules: bool,
3562) -> Result<Vec<DiffEntry>> {
3563    let mut result = Vec::new();
3564    let mut idx_cursor = 0usize;
3565
3566    let mut walk = DiffIndexTreeWalk {
3567        odb,
3568        index,
3569        idx_cursor: &mut idx_cursor,
3570        ignore_submodules,
3571        result: &mut result,
3572        abort_to_flatten: false,
3573    };
3574    diff_index_tree_at(&mut walk, head_tree, Some(cache_root), b"")?;
3575    if walk.abort_to_flatten {
3576        return diff_index_to_tree_flatten(odb, index, Some(head_tree), ignore_submodules);
3577    }
3578
3579    // Index entries not consumed by the tree walk (added paths).
3580    while idx_cursor < index.entries.len() {
3581        let ie = &index.entries[idx_cursor];
3582        idx_cursor += 1;
3583        let path = String::from_utf8_lossy(&ie.path).to_string();
3584        if ie.stage() == 0 && ie.intent_to_add() {
3585            continue;
3586        }
3587        if ie.stage() != 0 {
3588            continue;
3589        }
3590        if ignore_submodules && ie.mode == 0o160000 {
3591            continue;
3592        }
3593        result.push(DiffEntry {
3594            status: DiffStatus::Added,
3595            old_path: None,
3596            new_path: Some(path),
3597            old_mode: FileMode::ABSENT,
3598            new_mode: FileMode::new(ie.mode),
3599            old_oid: zero_oid(),
3600            new_oid: ie.oid,
3601            score: None,
3602        });
3603    }
3604
3605    sort_entries_by_path(&mut result);
3606    Ok(result)
3607}
3608
3609enum IndexLevelKind<'a> {
3610    Blob {
3611        entry: &'a IndexEntry,
3612        name: &'a [u8],
3613    },
3614    Tree {
3615        name: &'a [u8],
3616    },
3617}
3618
3619/// `path` relative to the directory `dir_prefix` (raw bytes, no trailing slash), or `None` when
3620/// `path` lies outside it. The directory's own path maps to an empty slice.
3621fn index_relative_in_dir<'a>(path: &'a [u8], dir_prefix: &[u8]) -> Option<&'a [u8]> {
3622    if dir_prefix.is_empty() {
3623        return Some(path);
3624    }
3625    let rest = path.strip_prefix(dir_prefix)?;
3626    match rest.split_first() {
3627        None => Some(rest),
3628        Some((b'/', tail)) => Some(tail),
3629        Some(_) => None,
3630    }
3631}
3632
3633fn join_index_path(dir_prefix: &[u8], name: &[u8]) -> Vec<u8> {
3634    if dir_prefix.is_empty() {
3635        return name.to_vec();
3636    }
3637    let mut path = Vec::with_capacity(dir_prefix.len() + 1 + name.len());
3638    path.extend_from_slice(dir_prefix);
3639    path.push(b'/');
3640    path.extend_from_slice(name);
3641    path
3642}
3643
3644fn peek_index_level_entry<'a>(
3645    index: &'a Index,
3646    cursor: &mut usize,
3647    dir_prefix: &[u8],
3648    ignore_submodules: bool,
3649) -> Option<IndexLevelKind<'a>> {
3650    while let Some(ie) = index.entries.get(*cursor) {
3651        let rel = index_relative_in_dir(&ie.path, dir_prefix)?;
3652        if ie.stage() != 0 || ie.intent_to_add() || rel.is_empty() {
3653            *cursor += 1;
3654            continue;
3655        }
3656        if let Some(slash) = rel.iter().position(|&b| b == b'/') {
3657            return Some(IndexLevelKind::Tree {
3658                name: &rel[..slash],
3659            });
3660        }
3661        if ignore_submodules && ie.mode == 0o160000 {
3662            *cursor += 1;
3663            continue;
3664        }
3665        return Some(IndexLevelKind::Blob {
3666            entry: ie,
3667            name: rel,
3668        });
3669    }
3670    None
3671}
3672
3673fn skip_index_subtree(index: &Index, cursor: &mut usize, dir_prefix: &[u8]) {
3674    while index
3675        .entries
3676        .get(*cursor)
3677        .is_some_and(|ie| index_relative_in_dir(&ie.path, dir_prefix).is_some())
3678    {
3679        *cursor += 1;
3680    }
3681}
3682
3683/// Children of a cache-tree node up to which a linear name scan beats sorting.
3684const CACHE_CHILD_LINEAR_SCAN_MAX: usize = 16;
3685
3686/// Name lookup over one cache-tree node's subtrees.
3687///
3688/// The index extension does not promise any child order, and a wide directory is looked up
3689/// once per tree entry, so nodes with many children are sorted once per visit instead of
3690/// being scanned for every entry. The first child with a matching name wins either way.
3691struct CacheChildren<'a> {
3692    children: &'a [CacheTreeNode],
3693    sorted: Vec<&'a CacheTreeNode>,
3694}
3695
3696impl<'a> CacheChildren<'a> {
3697    fn new(node: Option<&'a CacheTreeNode>) -> Self {
3698        let children = node.map_or(&[][..], |n| n.children.as_slice());
3699        let sorted = if children.len() > CACHE_CHILD_LINEAR_SCAN_MAX {
3700            let mut sorted: Vec<&CacheTreeNode> = children.iter().collect();
3701            sorted.sort_by(|a, b| a.name.cmp(&b.name));
3702            sorted
3703        } else {
3704            Vec::new()
3705        };
3706        Self { children, sorted }
3707    }
3708
3709    fn get(&self, name: &[u8]) -> Option<&'a CacheTreeNode> {
3710        if self.sorted.is_empty() {
3711            return self.children.iter().find(|c| c.name == name);
3712        }
3713        let pos = self.sorted.partition_point(|c| c.name.as_slice() < name);
3714        self.sorted.get(pos).copied().filter(|c| c.name == name)
3715    }
3716}
3717
3718struct DiffIndexTreeWalk<'a, 'b> {
3719    odb: &'a Odb,
3720    index: &'a Index,
3721    idx_cursor: &'b mut usize,
3722    ignore_submodules: bool,
3723    result: &'b mut Vec<DiffEntry>,
3724    /// Set when a tree object has duplicate entry names; the caller falls back to flatten.
3725    abort_to_flatten: bool,
3726}
3727
3728fn tree_entries_have_duplicate_names(entries: &[TreeEntry]) -> bool {
3729    let mut seen = std::collections::BTreeSet::<&[u8]>::new();
3730    for entry in entries {
3731        if !seen.insert(entry.name.as_slice()) {
3732            return true;
3733        }
3734    }
3735    false
3736}
3737
3738/// Walk the tree `tree_oid` against the index entries under `dir_prefix` (raw path bytes).
3739fn diff_index_tree_at(
3740    walk: &mut DiffIndexTreeWalk<'_, '_>,
3741    tree_oid: &ObjectId,
3742    cache_node: Option<&CacheTreeNode>,
3743    dir_prefix: &[u8],
3744) -> Result<()> {
3745    let tree_entries = read_tree(walk.odb, tree_oid)?;
3746    if tree_entries_have_duplicate_names(&tree_entries) {
3747        walk.abort_to_flatten = true;
3748        return Ok(());
3749    }
3750    let cache_children = CacheChildren::new(cache_node);
3751    let mut ti = 0usize;
3752
3753    loop {
3754        let te = tree_entries.get(ti);
3755        let ie_kind = peek_index_level_entry(
3756            walk.index,
3757            walk.idx_cursor,
3758            dir_prefix,
3759            walk.ignore_submodules,
3760        );
3761
3762        match (te, ie_kind) {
3763            (Some(t_entry), Some(ie)) => {
3764                let t_name = &t_entry.name;
3765                let (i_name, i_is_tree) = match &ie {
3766                    IndexLevelKind::Blob { name, .. } => (*name, false),
3767                    IndexLevelKind::Tree { name } => (*name, true),
3768                };
3769                let t_is_tree = is_tree_mode(t_entry.mode);
3770                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
3771                match cmp {
3772                    std::cmp::Ordering::Less => {
3773                        emit_index_tree_deleted(
3774                            walk.odb,
3775                            t_entry,
3776                            dir_prefix,
3777                            walk.ignore_submodules,
3778                            walk.result,
3779                        )?;
3780                        ti += 1;
3781                    }
3782                    std::cmp::Ordering::Greater => {
3783                        emit_index_tree_added(walk, dir_prefix)?;
3784                    }
3785                    std::cmp::Ordering::Equal => {
3786                        if t_is_tree && i_is_tree {
3787                            let full_path = join_index_path(dir_prefix, t_name);
3788                            let child_cache = cache_children.get(t_name);
3789                            if child_cache
3790                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
3791                            {
3792                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
3793                                ti += 1;
3794                                continue;
3795                            }
3796                            diff_index_tree_at(walk, &t_entry.oid, child_cache, &full_path)?;
3797                        } else if t_is_tree != i_is_tree {
3798                            emit_index_tree_deleted(
3799                                walk.odb,
3800                                t_entry,
3801                                dir_prefix,
3802                                walk.ignore_submodules,
3803                                walk.result,
3804                            )?;
3805                            emit_index_tree_added(walk, dir_prefix)?;
3806                        } else {
3807                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
3808                                ti += 1;
3809                                continue;
3810                            };
3811                            *walk.idx_cursor += 1;
3812                            if t_entry.oid != ie.oid || t_entry.mode != ie.mode {
3813                                let path = String::from_utf8_lossy(&ie.path).into_owned();
3814                                walk.result.push(DiffEntry {
3815                                    status: DiffStatus::for_content_change(t_entry.mode, ie.mode),
3816                                    old_path: Some(path.clone()),
3817                                    new_path: Some(path),
3818                                    old_mode: FileMode::new(t_entry.mode),
3819                                    new_mode: FileMode::new(ie.mode),
3820                                    old_oid: t_entry.oid,
3821                                    new_oid: ie.oid,
3822                                    score: None,
3823                                });
3824                            }
3825                        }
3826                        ti += 1;
3827                    }
3828                }
3829            }
3830            (Some(t_entry), None) => {
3831                emit_index_tree_deleted(
3832                    walk.odb,
3833                    t_entry,
3834                    dir_prefix,
3835                    walk.ignore_submodules,
3836                    walk.result,
3837                )?;
3838                ti += 1;
3839            }
3840            (None, Some(_)) => {
3841                emit_index_tree_added(walk, dir_prefix)?;
3842            }
3843            (None, None) => break,
3844        }
3845    }
3846
3847    Ok(())
3848}
3849
3850fn emit_index_tree_deleted(
3851    odb: &Odb,
3852    entry: &TreeEntry,
3853    dir_prefix: &[u8],
3854    ignore_submodules: bool,
3855    result: &mut Vec<DiffEntry>,
3856) -> Result<()> {
3857    if ignore_submodules && entry.mode == 0o160000 {
3858        return Ok(());
3859    }
3860    let path = String::from_utf8_lossy(&join_index_path(dir_prefix, &entry.name)).into_owned();
3861    if is_tree_mode(entry.mode) {
3862        for fe in flatten_tree(odb, &entry.oid, &path)? {
3863            if ignore_submodules && fe.mode == 0o160000 {
3864                continue;
3865            }
3866            result.push(DiffEntry {
3867                status: DiffStatus::Deleted,
3868                old_path: Some(fe.path.clone()),
3869                new_path: None,
3870                old_mode: FileMode::new(fe.mode),
3871                new_mode: FileMode::ABSENT,
3872                old_oid: fe.oid,
3873                new_oid: zero_oid(),
3874                score: None,
3875            });
3876        }
3877    } else {
3878        result.push(DiffEntry {
3879            status: DiffStatus::Deleted,
3880            old_path: Some(path),
3881            new_path: None,
3882            old_mode: FileMode::new(entry.mode),
3883            new_mode: FileMode::ABSENT,
3884            old_oid: entry.oid,
3885            new_oid: zero_oid(),
3886            score: None,
3887        });
3888    }
3889    Ok(())
3890}
3891
3892fn emit_index_tree_added(walk: &mut DiffIndexTreeWalk<'_, '_>, dir_prefix: &[u8]) -> Result<()> {
3893    let ie_kind = peek_index_level_entry(
3894        walk.index,
3895        walk.idx_cursor,
3896        dir_prefix,
3897        walk.ignore_submodules,
3898    )
3899    .ok_or_else(|| Error::IndexError("missing index entry during tree walk".to_owned()))?;
3900    match ie_kind {
3901        IndexLevelKind::Blob { entry: ie, .. } => {
3902            let path = String::from_utf8_lossy(&ie.path).into_owned();
3903            *walk.idx_cursor += 1;
3904            walk.result.push(DiffEntry {
3905                status: DiffStatus::Added,
3906                old_path: None,
3907                new_path: Some(path),
3908                old_mode: FileMode::ABSENT,
3909                new_mode: FileMode::new(ie.mode),
3910                old_oid: zero_oid(),
3911                new_oid: ie.oid,
3912                score: None,
3913            });
3914        }
3915        IndexLevelKind::Tree { name } => {
3916            let full_path = join_index_path(dir_prefix, name);
3917            let start = *walk.idx_cursor;
3918            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
3919            for ie in &walk.index.entries[start..*walk.idx_cursor] {
3920                if ie.stage() != 0 || ie.intent_to_add() {
3921                    continue;
3922                }
3923                if walk.ignore_submodules && ie.mode == 0o160000 {
3924                    continue;
3925                }
3926                let path = String::from_utf8_lossy(&ie.path).into_owned();
3927                walk.result.push(DiffEntry {
3928                    status: DiffStatus::Added,
3929                    old_path: None,
3930                    new_path: Some(path),
3931                    old_mode: FileMode::ABSENT,
3932                    new_mode: FileMode::new(ie.mode),
3933                    old_oid: zero_oid(),
3934                    new_oid: ie.oid,
3935                    score: None,
3936                });
3937            }
3938        }
3939    }
3940    Ok(())
3941}
3942
3943// ── Index-to-worktree diff (unstaged changes) ───────────────────────
3944
3945/// Compare the index against the working tree.
3946///
3947/// This shows "unstaged" changes — modifications not yet staged.
3948///
3949/// Entries with [`IndexEntry::assume_unchanged`] or [`IndexEntry::skip_worktree`] are treated as
3950/// matching the work tree without examining the filesystem (Git `CE_VALID` / skip-worktree).
3951///
3952/// # Parameters
3953///
3954/// - `odb` — object database (for hashing worktree files).
3955/// - `index` — the current index.
3956/// - `work_tree` — path to the working tree root.
3957/// - `ignore_submodule_untracked` — when true, gitlink entries are not dirty solely from untracked
3958///   files inside the submodule (matches `git status -uno`).
3959/// - `simplify_gitlinks` — when true, nested gitlink entries only compare the submodule checkout
3960///   HEAD to the recorded OID (ignore dirty work trees inside nested submodules). Used when
3961///   computing `submodule_porcelain_flags` so untracked files under a nested submodule do not set
3962///   the parent submodule's `modified` bit (Git `DIRTY_SUBMODULE_MODIFIED`; t7506).
3963///
3964/// # Errors
3965///
3966/// Returns errors from I/O or hashing.
3967pub fn diff_index_to_worktree(
3968    odb: &Odb,
3969    index: &mut Index,
3970    work_tree: &Path,
3971    ignore_submodule_untracked: bool,
3972    simplify_gitlinks: bool,
3973) -> Result<Vec<DiffEntry>> {
3974    diff_index_to_worktree_with_options(
3975        odb,
3976        index,
3977        work_tree,
3978        DiffIndexToWorktreeOptions {
3979            ignore_submodule_untracked,
3980            simplify_gitlinks,
3981            ..DiffIndexToWorktreeOptions::default()
3982        },
3983    )
3984    .map(|(entries, _)| entries)
3985}
3986
3987/// Additional inputs for [`diff_index_to_worktree_with_options`].
3988#[derive(Clone)]
3989pub struct DiffIndexToWorktreeOptions {
3990    /// Optional index mtime pair `(sec, nsec)` sampled when the index was read.
3991    ///
3992    /// When provided, entries with matching stat data are still considered dirty candidates if
3993    /// their recorded mtime is "racy" (at or after this timestamp), matching Git's
3994    /// `is_racy_timestamp` behavior.
3995    pub index_mtime: Option<(u32, u32)>,
3996    /// When true, gitlink entries are not dirty solely from untracked files inside the submodule.
3997    pub ignore_submodule_untracked: bool,
3998    /// When true, nested gitlink entries only compare the submodule checkout HEAD to the recorded OID.
3999    pub simplify_gitlinks: bool,
4000    /// When true, a populated gitlink checkout whose `.git` indirection cannot resolve to a HEAD
4001    /// is returned as an error instead of a normal modified gitlink.
4002    pub error_on_broken_gitlinks: bool,
4003    /// Repository git directory used to load config (separate git dir / linked worktrees).
4004    ///
4005    /// When unset, defaults to `work_tree/.git`, which is wrong for gitfile layouts.
4006    pub repository_git_dir: Option<std::path::PathBuf>,
4007    /// When true, adopt refreshed stat data for unchanged blobs during the same directory scan
4008    /// as the worktree diff (one lstat per tracked path).
4009    pub refresh_index_stat_in_pass: bool,
4010    /// When set, used instead of loading config from [`Self::repository_git_dir`].
4011    pub config: Option<std::sync::Arc<crate::config::ConfigSet>>,
4012    /// Cap parallel stat workers (`Some(1)` forces serial), also applied to hashing the
4013    /// racily clean entries the stat refresh verifies. When `None`, honors
4014    /// `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
4015    pub stat_parallel_threads: Option<usize>,
4016    /// CLI / embedder thread override applied to stat preload and hashing in this diff.
4017    pub thread_policy: Option<crate::parallel::ThreadPolicy>,
4018    /// Shared per-operation attribute/conversion context (see [`crate::worktree_rules::WorktreeRules`]).
4019    pub worktree_rules:
4020        Option<std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
4021    /// When true, omit worktree-vs-index diffs for unmerged paths (used by `status` only).
4022    pub for_status: bool,
4023    /// When true (default), query `core.fsmonitor` before scanning the worktree.
4024    pub refresh_fsmonitor: bool,
4025    /// Injectable subprocess runner for fsmonitor hooks; defaults to the system runner.
4026    pub command_runner: Option<std::sync::Arc<dyn crate::command_runner::CommandRunner>>,
4027    /// When set, used as the hook protocol v1 query boundary; otherwise [`crate::environment::wall_clock_unix_timestamp_nanos`] is sampled once per diff.
4028    pub fsmonitor_query_boundary: Option<crate::fsmonitor::FsmonitorQueryBoundary>,
4029    /// When set, only index paths in this set are compared against the work tree.
4030    ///
4031    /// Used by branch switch to verify dirty paths only where the tree-to-tree diff would
4032    /// update the worktree, avoiding a full tracked scan on large indexes. Callers must
4033    /// refuse the operation when the index has unmerged entries; this filter does not
4034    /// substitute for a full unmerged scan.
4035    pub restrict_to_paths: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
4036    /// When true with staging materialization, compute blob OIDs without writing loose objects.
4037    pub hash_blobs_without_write: bool,
4038    /// When set, long loops abort with [`crate::error::Error::Cancelled`] once the flag is raised
4039    /// (see [`crate::progress::cancel_watch_arc`] and [`crate::progress::poll_cancel_watch`]).
4040    pub cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
4041}
4042
4043impl Default for DiffIndexToWorktreeOptions {
4044    fn default() -> Self {
4045        Self {
4046            index_mtime: None,
4047            ignore_submodule_untracked: false,
4048            simplify_gitlinks: false,
4049            error_on_broken_gitlinks: false,
4050            repository_git_dir: None,
4051            refresh_index_stat_in_pass: false,
4052            config: None,
4053            stat_parallel_threads: None,
4054            thread_policy: None,
4055            worktree_rules: None,
4056            for_status: false,
4057            refresh_fsmonitor: true,
4058            command_runner: None,
4059            fsmonitor_query_boundary: None,
4060            restrict_to_paths: None,
4061            hash_blobs_without_write: false,
4062            cancel_watch: None,
4063        }
4064    }
4065}
4066
4067#[inline]
4068fn poll_diff_cancel_watch(
4069    watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
4070) -> crate::error::Result<()> {
4071    if let Some(flag) = watch {
4072        crate::progress::poll_watch(flag)?;
4073    }
4074    Ok(())
4075}
4076
4077struct StagingHashJob {
4078    entry_index: usize,
4079    rel_path: String,
4080    file_path: PathBuf,
4081    entry: IndexEntry,
4082    /// Worktree mode after [`crate::index::WorktreeModePolicy`] against `entry.mode`.
4083    worktree_mode: u32,
4084    file_attrs: crate::crlf::FileAttrs,
4085}
4086
4087#[allow(clippy::too_many_arguments)]
4088fn parallel_process_blob_worktree_jobs(
4089    odb: &Odb,
4090    jobs: &[StagingHashJob],
4091    thread_policy: &crate::parallel::ThreadPolicy,
4092    config: &crate::config::ConfigSet,
4093    materialize_dirty_blobs: bool,
4094    hash_blobs_without_write: bool,
4095    conv: &crate::crlf::ConversionConfig,
4096    filter_process: Option<&crate::filter_process::FilterProcessState>,
4097    result: &mut Vec<DiffEntry>,
4098) -> Result<()> {
4099    use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
4100
4101    if jobs.is_empty() {
4102        return Ok(());
4103    }
4104    let mut par_opts = thread_policy.resolve(ParallelOp::IndexRefresh, config);
4105    par_opts.min_items = 0;
4106    par_opts.min_total_bytes = None;
4107    let est_bytes = jobs.len().saturating_mul(8192);
4108    let store_dirty_blobs = (materialize_dirty_blobs && !hash_blobs_without_write)
4109        .then_some(crate::odb::BULK_NEW_LOOSE_WRITE);
4110    let stream_from = if store_dirty_blobs.is_some() {
4111        odb.big_file_threshold()?
4112    } else {
4113        hash_stream_threshold(odb)?
4114    };
4115
4116    let rows = try_par_map::<StagingHashJob, Option<(usize, DiffEntry)>, Error, _>(
4117        jobs,
4118        par_opts,
4119        est_bytes,
4120        |job| {
4121            let modified = |new_oid| {
4122                Ok(Some((
4123                    job.entry_index,
4124                    worktree_content_change_entry(
4125                        &job.entry,
4126                        &job.rel_path,
4127                        job.worktree_mode,
4128                        new_oid,
4129                    ),
4130                )))
4131            };
4132            let content = match read_regular_worktree_blob(
4133                odb,
4134                &job.file_path,
4135                conv,
4136                &job.file_attrs,
4137                &job.rel_path,
4138                Some(&job.entry),
4139                filter_process,
4140                stream_from,
4141            ) {
4142                Ok(content) => content,
4143                Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
4144                    return modified(zero_oid());
4145                }
4146                Err(err) => return Err(err),
4147            };
4148            let eff_oid = content.object_id(odb, store_dirty_blobs)?;
4149            if eff_oid != job.entry.oid || job.worktree_mode != job.entry.mode {
4150                let new_oid = if eff_oid != job.entry.oid && !materialize_dirty_blobs {
4151                    zero_oid()
4152                } else {
4153                    eff_oid
4154                };
4155                modified(new_oid)
4156            } else {
4157                Ok(None)
4158            }
4159        },
4160    )
4161    .map_err(|e| match e {
4162        ParallelHashError::Task(err) => err,
4163    })?;
4164
4165    let mut rows: Vec<(usize, DiffEntry)> = rows.into_iter().flatten().collect();
4166    rows.sort_by_key(|(idx, _)| *idx);
4167    result.extend(rows.into_iter().map(|(_, d)| d));
4168    Ok(())
4169}
4170
4171/// Index-vs-worktree entry for a tracked path whose content or mode differs from `ie`.
4172fn worktree_content_change_entry(
4173    ie: &IndexEntry,
4174    path: &str,
4175    worktree_mode: u32,
4176    new_oid: ObjectId,
4177) -> DiffEntry {
4178    DiffEntry {
4179        status: DiffStatus::for_content_change(ie.mode, worktree_mode),
4180        old_path: Some(path.to_owned()),
4181        new_path: Some(path.to_owned()),
4182        old_mode: FileMode::new(ie.mode),
4183        new_mode: FileMode::new(worktree_mode),
4184        old_oid: ie.oid,
4185        new_oid,
4186        score: None,
4187    }
4188}
4189
4190/// Whether a failed read of a tracked file's content reports the path as modified (with an
4191/// unknown worktree oid) instead of failing the whole scan.
4192///
4193/// A file that cannot be opened (no permission, held exclusively by another process) cannot
4194/// be proven to match the index, so it counts as changed, as in Git. Callers that need the
4195/// bytes themselves (`materialize_dirty_blobs`, i.e. staging) still get the error.
4196fn unreadable_worktree_blob_is_modified(err: &Error, materialize_dirty_blobs: bool) -> bool {
4197    !materialize_dirty_blobs
4198        && matches!(
4199            err,
4200            Error::WorktreePathIo {
4201                operation: crate::worktree::WorktreePathOperation::Read,
4202                ..
4203            }
4204        )
4205}
4206
4207/// Git `compare_stat_data`: a size mismatch means modified without reading blob contents.
4208#[must_use]
4209fn try_push_status_modified_on_size_mismatch(
4210    ie: &IndexEntry,
4211    path_str_ref: &str,
4212    meta: &fs::Metadata,
4213    worktree_mode: u32,
4214    for_status: bool,
4215    result: &mut Vec<DiffEntry>,
4216) -> bool {
4217    if ie.stat_cache_unset() {
4218        return false;
4219    }
4220    if !for_status || crate::index::munge_index_size(meta.len()) == ie.size {
4221        return false;
4222    }
4223    result.push(worktree_content_change_entry(
4224        ie,
4225        path_str_ref,
4226        worktree_mode,
4227        ie.oid,
4228    ));
4229    true
4230}
4231
4232fn try_push_status_modified_on_size_mismatch_stat(
4233    ie: &IndexEntry,
4234    path_str_ref: &str,
4235    stat: &crate::worktree_scan::BlobDiskStat,
4236    worktree_mode: u32,
4237    for_status: bool,
4238    result: &mut Vec<DiffEntry>,
4239) -> bool {
4240    if ie.stat_cache_unset() {
4241        return false;
4242    }
4243    if !for_status || stat.worktree_size() == ie.size {
4244        return false;
4245    }
4246    result.push(worktree_content_change_entry(
4247        ie,
4248        path_str_ref,
4249        worktree_mode,
4250        ie.oid,
4251    ));
4252    true
4253}
4254
4255#[allow(clippy::too_many_arguments)]
4256fn push_index_blob_worktree_diff(
4257    odb: &Odb,
4258    ie: &IndexEntry,
4259    path_str_ref: &str,
4260    file_path: &Path,
4261    meta: fs::Metadata,
4262    index_mtime: Option<(u32, u32)>,
4263    mode_policy: crate::index::WorktreeModePolicy,
4264    stat_policy: crate::index::StatMatchPolicy,
4265    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
4266    conv: &crate::crlf::ConversionConfig,
4267    materialize_dirty_blobs: bool,
4268    hash_blobs_without_write: bool,
4269    for_status: bool,
4270    filter_process: Option<&crate::filter_process::FilterProcessState>,
4271    result: &mut Vec<DiffEntry>,
4272) -> Result<()> {
4273    if meta.is_dir() {
4274        if file_path.join(".git").exists() {
4275            let head = read_submodule_head_oid(file_path).unwrap_or_else(zero_oid);
4276            let path_owned = path_str_ref.to_owned();
4277            result.push(DiffEntry {
4278                status: DiffStatus::TypeChanged,
4279                old_path: Some(path_owned.clone()),
4280                new_path: Some(path_owned),
4281                old_mode: FileMode::new(ie.mode),
4282                new_mode: FileMode::new(0o160000),
4283                old_oid: ie.oid,
4284                new_oid: head,
4285                score: None,
4286            });
4287            return Ok(());
4288        }
4289        result.push(DiffEntry {
4290            status: DiffStatus::Deleted,
4291            old_path: Some(path_str_ref.to_owned()),
4292            new_path: None,
4293            old_mode: FileMode::new(ie.mode),
4294            new_mode: FileMode::ABSENT,
4295            old_oid: ie.oid,
4296            new_oid: zero_oid(),
4297            score: None,
4298        });
4299        return Ok(());
4300    }
4301
4302    let worktree_mode =
4303        mode_policy.index_mode_for_worktree(mode_from_metadata(&meta), Some(ie.mode));
4304    let stat_same = stat_matches_with_policy(ie, &meta, stat_policy);
4305    if stat_same && worktree_mode != ie.mode {
4306        result.push(worktree_content_change_entry(
4307            ie,
4308            path_str_ref,
4309            worktree_mode,
4310            ie.oid,
4311        ));
4312        return Ok(());
4313    }
4314
4315    if stat_same
4316        && index_entry_skips_worktree_content_hash_after_stat_match(ie, index_mtime, stat_policy)
4317    {
4318        return Ok(());
4319    }
4320
4321    if try_push_status_modified_on_size_mismatch(
4322        ie,
4323        path_str_ref,
4324        &meta,
4325        worktree_mode,
4326        for_status,
4327        result,
4328    ) {
4329        return Ok(());
4330    }
4331
4332    let file_attrs = file_attrs_for(path_str_ref);
4333    let store = (materialize_dirty_blobs && !hash_blobs_without_write)
4334        .then_some(crate::odb::BULK_NEW_LOOSE_WRITE);
4335    let stream_from = if store.is_some() {
4336        odb.big_file_threshold()?
4337    } else {
4338        hash_stream_threshold(odb)?
4339    };
4340    let content = match worktree_blob_content_from(
4341        odb,
4342        file_path,
4343        &meta,
4344        conv,
4345        &file_attrs,
4346        path_str_ref,
4347        Some(ie),
4348        filter_process,
4349        stream_from,
4350    ) {
4351        Ok(content) => content,
4352        Err(err) if unreadable_worktree_blob_is_modified(&err, materialize_dirty_blobs) => {
4353            result.push(worktree_content_change_entry(
4354                ie,
4355                path_str_ref,
4356                worktree_mode,
4357                zero_oid(),
4358            ));
4359            return Ok(());
4360        }
4361        Err(err) => return Err(err),
4362    };
4363    let eff_oid = content.object_id(odb, store)?;
4364
4365    if eff_oid != ie.oid || worktree_mode != ie.mode {
4366        // `git diff-files --raw` uses the null OID for the worktree side when content differs.
4367        // Staging (`diff_index_to_worktree_for_staging`) materializes blobs and needs the real OID.
4368        let new_oid = if eff_oid != ie.oid && !materialize_dirty_blobs {
4369            zero_oid()
4370        } else {
4371            eff_oid
4372        };
4373        result.push(worktree_content_change_entry(
4374            ie,
4375            path_str_ref,
4376            worktree_mode,
4377            new_oid,
4378        ));
4379    }
4380    Ok(())
4381}
4382
4383/// Compare tracked index entries with the worktree, using link-target bytes for symlinks.
4384///
4385/// Symlink targets bypass regular-file filters and line-ending conversion. Parallel and
4386/// serial scans use the same file-type classification, including directory replacements
4387/// and regular-file placeholders checked out with `core.symlinks=false`.
4388///
4389/// # Errors
4390///
4391/// Returns errors from I/O or hashing.
4392pub fn diff_index_to_worktree_with_options(
4393    odb: &Odb,
4394    index: &mut Index,
4395    work_tree: &Path,
4396    options: DiffIndexToWorktreeOptions,
4397) -> Result<(Vec<DiffEntry>, bool)> {
4398    let default_git_dir = work_tree.join(".git");
4399    let git_dir = options
4400        .repository_git_dir
4401        .clone()
4402        .unwrap_or(default_git_dir);
4403    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false)
4404        .map(|(entries, changed, _)| (entries, changed))
4405}
4406
4407/// Like [`diff_index_to_worktree_with_options`], but loads config from `repository_git_dir`
4408/// (the main repository for linked worktrees) and persists each hashed worktree blob into `odb`
4409/// so staging can update the index without re-reading file contents.
4410/// Symlinks store their target bytes. With [`DiffIndexToWorktreeOptions::hash_blobs_without_write`],
4411/// returns the same blob IDs without writing objects, for staging previews.
4412pub fn diff_index_to_worktree_for_staging(
4413    odb: &Odb,
4414    repository_git_dir: &Path,
4415    index: &mut Index,
4416    work_tree: &Path,
4417    options: DiffIndexToWorktreeOptions,
4418) -> Result<(
4419    Vec<DiffEntry>,
4420    bool,
4421    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
4422)> {
4423    diff_index_to_worktree_inner(odb, index, work_tree, repository_git_dir, options, true)
4424}
4425
4426/// First pass of [`diff_index_to_worktree_inner`]: gitlinks and intent-to-add paths only.
4427#[allow(clippy::too_many_arguments)]
4428fn worktree_diff_pass1_index_entry(
4429    odb: &Odb,
4430    work_tree: &Path,
4431    ie: &IndexEntry,
4432    path_str_ref: &str,
4433    simplify_gitlinks: bool,
4434    ignore_submodule_untracked: bool,
4435    error_on_broken_gitlinks: bool,
4436    materialize_dirty_blobs: bool,
4437    filter_fp: Option<&crate::filter_process::FilterProcessState>,
4438    conv: &crate::crlf::ConversionConfig,
4439    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
4440    precompose_unicode: bool,
4441    ignorecase: bool,
4442    result: &mut Vec<DiffEntry>,
4443) -> Result<()> {
4444    if ie.skip_worktree() || ie.assume_unchanged() {
4445        return Ok(());
4446    }
4447    let is_intent_to_add = ie.intent_to_add();
4448
4449    if ie.mode == 0o160000 {
4450        let sub_dir =
4451            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
4452        match fs::symlink_metadata(&sub_dir) {
4453            Err(e)
4454                if e.kind() == std::io::ErrorKind::NotFound
4455                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
4456            {
4457                if !simplify_gitlinks {
4458                    let path_owned = path_str_ref.to_owned();
4459                    result.push(DiffEntry {
4460                        status: DiffStatus::Deleted,
4461                        old_path: Some(path_owned.clone()),
4462                        new_path: Some(path_owned),
4463                        old_mode: FileMode::new(ie.mode),
4464                        new_mode: FileMode::ABSENT,
4465                        old_oid: ie.oid,
4466                        new_oid: zero_oid(),
4467                        score: None,
4468                    });
4469                }
4470                return Ok(());
4471            }
4472            Err(e) => return Err(Error::Io(e)),
4473            Ok(meta) if !meta.is_dir() => {
4474                let worktree_mode = mode_from_metadata(&meta);
4475                let file_attrs = file_attrs_for(path_str_ref);
4476                let worktree_oid = worktree_file_oid(
4477                    odb,
4478                    &sub_dir,
4479                    &meta,
4480                    conv,
4481                    &file_attrs,
4482                    path_str_ref,
4483                    None,
4484                    materialize_dirty_blobs,
4485                    filter_fp,
4486                )?;
4487                let path_owned = path_str_ref.to_owned();
4488                result.push(DiffEntry {
4489                    status: DiffStatus::TypeChanged,
4490                    old_path: Some(path_owned.clone()),
4491                    new_path: Some(path_owned),
4492                    old_mode: FileMode::new(ie.mode),
4493                    new_mode: FileMode::new(worktree_mode),
4494                    old_oid: ie.oid,
4495                    new_oid: worktree_oid,
4496                    score: None,
4497                });
4498                return Ok(());
4499            }
4500            Ok(_) => {}
4501        }
4502        let sub_head_oid = read_submodule_head_oid(&sub_dir);
4503        let ref_matches = if let Some(oid) = sub_head_oid {
4504            oid == ie.oid
4505        } else {
4506            let is_placeholder = submodule_worktree_is_unpopulated_placeholder(&sub_dir);
4507            if error_on_broken_gitlinks
4508                && !is_placeholder
4509                && submodule_embedded_git_dir(&sub_dir).is_some()
4510            {
4511                return Err(Error::Config(
4512                    format!("could not read submodule HEAD for '{path_str_ref}'").into(),
4513                ));
4514            }
4515            is_placeholder
4516        };
4517        if simplify_gitlinks {
4518            if !ref_matches {
4519                let path_owned = path_str_ref.to_owned();
4520                let new_oid = sub_head_oid.unwrap_or_else(zero_oid);
4521                result.push(DiffEntry {
4522                    status: DiffStatus::Modified,
4523                    old_path: Some(path_owned.clone()),
4524                    new_path: Some(path_owned),
4525                    old_mode: FileMode::new(ie.mode),
4526                    new_mode: FileMode::new(ie.mode),
4527                    old_oid: ie.oid,
4528                    new_oid,
4529                    score: None,
4530                });
4531            }
4532            return Ok(());
4533        }
4534        if sub_head_oid.is_some() && submodule_head_object_broken(&sub_dir) {
4535            return Err(Error::Config(
4536                format!("'git status --porcelain=2' failed in submodule {path_str_ref}").into(),
4537            ));
4538        }
4539        let mut flags = submodule_porcelain_flags(work_tree, path_str_ref, ie.oid);
4540        if ignore_submodule_untracked {
4541            flags.untracked = false;
4542        }
4543        let inner_dirty = flags.modified || flags.untracked;
4544        if !ref_matches || inner_dirty {
4545            let path_owned = path_str_ref.to_owned();
4546            let new_oid = if !ref_matches {
4547                sub_head_oid.unwrap_or_else(zero_oid)
4548            } else {
4549                zero_oid()
4550            };
4551            result.push(DiffEntry {
4552                status: DiffStatus::Modified,
4553                old_path: Some(path_owned.clone()),
4554                new_path: Some(path_owned),
4555                old_mode: FileMode::new(ie.mode),
4556                new_mode: FileMode::new(ie.mode),
4557                old_oid: ie.oid,
4558                new_oid,
4559                score: None,
4560            });
4561        }
4562        return Ok(());
4563    }
4564
4565    let file_path =
4566        index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
4567    let path_match = crate::repo_relpath::as_utf8_for_matching(path_str_ref);
4568
4569    if is_intent_to_add {
4570        match fs::symlink_metadata(&file_path) {
4571            Ok(meta) => {
4572                let file_attrs = file_attrs_for(path_match.as_ref());
4573                let worktree_oid = worktree_file_oid(
4574                    odb,
4575                    &file_path,
4576                    &meta,
4577                    conv,
4578                    &file_attrs,
4579                    path_match.as_ref(),
4580                    None,
4581                    materialize_dirty_blobs,
4582                    filter_fp,
4583                )?;
4584                let worktree_mode = mode_from_metadata(&meta);
4585                result.push(DiffEntry {
4586                    status: DiffStatus::Added,
4587                    old_path: None,
4588                    new_path: Some(path_str_ref.to_owned()),
4589                    old_mode: FileMode::ABSENT,
4590                    new_mode: FileMode::new(worktree_mode),
4591                    old_oid: zero_oid(),
4592                    new_oid: worktree_oid,
4593                    score: None,
4594                });
4595            }
4596            Err(e)
4597                if e.kind() == std::io::ErrorKind::NotFound
4598                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
4599            {
4600                result.push(DiffEntry {
4601                    status: DiffStatus::Deleted,
4602                    old_path: Some(path_str_ref.to_owned()),
4603                    new_path: None,
4604                    old_mode: FileMode::new(ie.mode),
4605                    new_mode: FileMode::ABSENT,
4606                    old_oid: ie.oid,
4607                    new_oid: zero_oid(),
4608                    score: None,
4609                });
4610            }
4611            Err(e) => return Err(Error::Io(e)),
4612        }
4613    }
4614    Ok(())
4615}
4616
4617#[inline]
4618fn index_entry_needs_worktree_diff_pass1(ie: &IndexEntry) -> bool {
4619    ie.mode == crate::index::MODE_GITLINK || ie.intent_to_add()
4620}
4621
4622fn diff_index_to_worktree_inner(
4623    odb: &Odb,
4624    index: &mut Index,
4625    work_tree: &Path,
4626    repository_git_dir: &Path,
4627    options: DiffIndexToWorktreeOptions,
4628    materialize_dirty_blobs: bool,
4629) -> Result<(
4630    Vec<DiffEntry>,
4631    bool,
4632    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
4633)> {
4634    use crate::config::ConfigSet;
4635    use crate::crlf;
4636    use std::collections::BTreeMap;
4637
4638    let mut staging_stat_cache = BTreeMap::new();
4639    let cancel_watch = options.cancel_watch.as_ref();
4640    poll_diff_cancel_watch(cancel_watch)?;
4641    let index_mtime = index_mtime_for_diff(index, options.index_mtime);
4642
4643    let ignore_submodule_untracked = options.ignore_submodule_untracked;
4644    let simplify_gitlinks = options.simplify_gitlinks;
4645
4646    let rules_lock = options
4647        .worktree_rules
4648        .as_ref()
4649        .map(|shared| {
4650            shared
4651                .lock()
4652                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
4653        })
4654        .transpose()?;
4655    let config = if let Some(rules) = rules_lock.as_ref() {
4656        rules.config().clone()
4657    } else if let Some(c) = options.config.as_ref() {
4658        c.as_ref().clone()
4659    } else {
4660        ConfigSet::load(
4661            &crate::environment::Environment::empty(),
4662            Some(repository_git_dir),
4663            true,
4664        )
4665        .unwrap_or_else(|_| ConfigSet::new())
4666    };
4667    let conv = rules_lock
4668        .as_ref()
4669        .map(|r| r.conversion().clone())
4670        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
4671    let legacy_attrs = if rules_lock.is_none() {
4672        Some(crlf::load_gitattributes_parsed(work_tree))
4673    } else {
4674        None
4675    };
4676    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
4677        if let Some(rules) = rules_lock.as_ref() {
4678            rules.file_attrs(path, false)
4679        } else if let Some(parsed) = legacy_attrs.as_ref() {
4680            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
4681        } else {
4682            crlf::FileAttrs::default()
4683        }
4684    };
4685    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
4686    let precompose_unicode = config
4687        .get_bool("core.precomposeunicode")
4688        .and_then(|r| r.ok())
4689        .unwrap_or(false);
4690    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
4691    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
4692    let ignorecase = config
4693        .get_bool("core.ignorecase")
4694        .and_then(|r| r.ok())
4695        .unwrap_or(false);
4696    let hash_blobs_without_write = options.hash_blobs_without_write;
4697
4698    let mut result = Vec::new();
4699    let refresh_index_stat_in_pass = options.refresh_index_stat_in_pass;
4700    let mut index_stat_refresh_changed = false;
4701    let mut honor_fsmonitor_valid = false;
4702    if options.refresh_fsmonitor {
4703        let default_runner = crate::command_runner::system_command_runner();
4704        let runner = options
4705            .command_runner
4706            .as_deref()
4707            .unwrap_or(default_runner.as_ref());
4708        let boundary = options.fsmonitor_query_boundary.unwrap_or_else(|| {
4709            crate::fsmonitor::FsmonitorQueryBoundary(
4710                crate::environment::wall_clock_unix_timestamp_nanos(),
4711            )
4712        });
4713        let refresh_opts = crate::fsmonitor::FsmonitorRefreshOptions::with_query_boundary(boundary);
4714        let refresh = crate::fsmonitor::refresh_index(
4715            index,
4716            repository_git_dir,
4717            work_tree,
4718            &config,
4719            Some(runner),
4720            &refresh_opts,
4721        )?;
4722        if refresh.index_changed {
4723            index_stat_refresh_changed = true;
4724        }
4725        honor_fsmonitor_valid = refresh.honor_valid_bits;
4726    }
4727    let use_fsmonitor_stat_skip = honor_fsmonitor_valid;
4728    let mut unmerged_base: std::collections::BTreeMap<String, (u8, IndexEntry)> =
4729        std::collections::BTreeMap::new();
4730
4731    // Cache of ancestor-directory symlink-ness so we stat each directory at most
4732    // once, rather than re-lstat'ing every ancestor of every index entry
4733    // (O(unique dirs) instead of O(files × depth)).
4734    let dir_symlinks = SymlinkDirCache::default();
4735
4736    let error_on_broken_gitlinks = options.error_on_broken_gitlinks;
4737    if options.restrict_to_paths.is_none() {
4738        for ie in &index.entries {
4739            poll_diff_cancel_watch(cancel_watch)?;
4740            if ie.stage() != 0 {
4741                let path = String::from_utf8_lossy(&ie.path).to_string();
4742                let rank = match ie.stage() {
4743                    2 => 0u8,
4744                    3 => 1u8,
4745                    1 => 2u8,
4746                    _ => 3u8,
4747                };
4748                match unmerged_base.get(&path) {
4749                    Some((existing_rank, _)) if *existing_rank <= rank => {}
4750                    _ => {
4751                        unmerged_base.insert(path, (rank, ie.clone()));
4752                    }
4753                }
4754            }
4755        }
4756    }
4757
4758    if let Some(limit) = options.restrict_to_paths.as_ref() {
4759        for path in limit.iter() {
4760            let Some(ie) = index.get(path.as_bytes(), 0) else {
4761                continue;
4762            };
4763            if !index_entry_needs_worktree_diff_pass1(ie) {
4764                continue;
4765            }
4766            worktree_diff_pass1_index_entry(
4767                odb,
4768                work_tree,
4769                ie,
4770                path.as_str(),
4771                simplify_gitlinks,
4772                ignore_submodule_untracked,
4773                error_on_broken_gitlinks,
4774                materialize_dirty_blobs,
4775                filter_fp,
4776                &conv,
4777                &file_attrs_for,
4778                precompose_unicode,
4779                ignorecase,
4780                &mut result,
4781            )?;
4782        }
4783    } else {
4784        for ie in &index.entries {
4785            poll_diff_cancel_watch(cancel_watch)?;
4786            if ie.stage() != 0 {
4787                continue;
4788            }
4789            if !index_entry_needs_worktree_diff_pass1(ie) {
4790                continue;
4791            }
4792            let path_stored = crate::repo_relpath::storage_string(&ie.path);
4793            worktree_diff_pass1_index_entry(
4794                odb,
4795                work_tree,
4796                ie,
4797                &path_stored,
4798                simplify_gitlinks,
4799                ignore_submodule_untracked,
4800                error_on_broken_gitlinks,
4801                materialize_dirty_blobs,
4802                filter_fp,
4803                &conv,
4804                &file_attrs_for,
4805                precompose_unicode,
4806                ignorecase,
4807                &mut result,
4808            )?;
4809        }
4810    }
4811
4812    use crate::worktree_scan::{
4813        collect_blob_lookup_batches_with_literal_paths, group_blob_entries_by_dir,
4814        stat_scan_thread_count, visit_blob_lookup_batches, BlobDiskLookup, BlobScanSparseMode,
4815        WorktreeBlobScanOptions, PARALLEL_STAT_MIN_ENTRIES,
4816    };
4817    let preload_index = config
4818        .get_bool("core.preloadIndex")
4819        .and_then(|r| r.ok())
4820        .unwrap_or(true);
4821    let blob_scan = WorktreeBlobScanOptions {
4822        preload_index,
4823        stat_parallel_threads: options.stat_parallel_threads,
4824    };
4825    let blob_dirs = if let Some(limit) = options.restrict_to_paths.as_ref() {
4826        crate::worktree_scan::group_blob_entries_by_dir_for_paths(index, limit)
4827    } else {
4828        group_blob_entries_by_dir(index, honor_fsmonitor_valid)
4829    };
4830    let work_tree_owned = work_tree.to_path_buf();
4831    let dir_abs = move |dir: &[u8]| {
4832        if dir.is_empty() {
4833            work_tree_owned.clone()
4834        } else {
4835            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
4836        }
4837    };
4838    let work_tree_for_files = work_tree.to_path_buf();
4839    let file_abs = move |rel: &[u8]| {
4840        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
4841    };
4842    let blob_count = blob_dirs.len();
4843    let thread_policy = options.thread_policy.unwrap_or_default();
4844    let hash_threads = stat_scan_thread_count(&thread_policy, &config, blob_scan, blob_count);
4845    let parallel_blob_work = blob_count >= PARALLEL_STAT_MIN_ENTRIES
4846        && hash_threads > 1
4847        && (materialize_dirty_blobs || options.for_status);
4848    let mut staging_hash_jobs: Vec<StagingHashJob> = Vec::new();
4849    // Entries the stat refresh just hashed and found matching their recorded blob.
4850    let mut refresh_content_verified = vec![false; index.entries.len()];
4851    // Parallel preload must lstat ancestor directories without following symlinks. When a
4852    // tracked directory is replaced by a symlink, indexed paths under it are deletions even if
4853    // the link target still holds a file at the same relative path with an unchanged stat.
4854    // This is independent of `core.symlinks` (checkout support); detection must stay enabled.
4855    let sparse = if parallel_blob_work {
4856        BlobScanSparseMode::StatusDiff {
4857            stat_policy,
4858            index_mtime,
4859            mode_policy,
4860            for_status: options.for_status,
4861            refresh_index_stat_in_pass,
4862            parallel_blob_work,
4863            honor_fsmonitor_stat_skip: use_fsmonitor_stat_skip,
4864        }
4865    } else {
4866        BlobScanSparseMode::Dense
4867    };
4868    let blob_stat_batches = collect_blob_lookup_batches_with_literal_paths(
4869        index,
4870        &blob_dirs,
4871        blob_scan,
4872        &thread_policy,
4873        &config,
4874        dir_abs,
4875        file_abs,
4876        !precompose_unicode && !ignorecase,
4877        |rel_path| {
4878            dir_symlinks.has_symlink_in_path_bytes(
4879                work_tree,
4880                rel_path,
4881                precompose_unicode,
4882                ignorecase,
4883            )
4884        },
4885        sparse,
4886    )?;
4887    let refresh_checks = if refresh_index_stat_in_pass {
4888        let par_opts =
4889            refresh_hash_parallel_options(&thread_policy, &config, options.stat_parallel_threads);
4890        refresh_content_checks(
4891            odb,
4892            index,
4893            &blob_stat_batches,
4894            index_mtime,
4895            stat_policy,
4896            &|ie, stat| {
4897                !(use_fsmonitor_stat_skip && crate::fsmonitor::entry_skips_worktree_stat(ie, index))
4898                    && worktree_diff_refreshes_entry(
4899                        ie,
4900                        stat,
4901                        index_mtime,
4902                        stat_policy,
4903                        options.for_status,
4904                    )
4905            },
4906            &WorktreeHashContext {
4907                work_tree,
4908                precompose_unicode,
4909                ignorecase,
4910                conv: &conv,
4911                file_attrs_for: &file_attrs_for,
4912                filter_process: filter_fp,
4913            },
4914            par_opts,
4915        )
4916    } else {
4917        Vec::new()
4918    };
4919    poll_diff_cancel_watch(cancel_watch)?;
4920    visit_blob_lookup_batches(blob_stat_batches, |entry_index, lookup| {
4921        poll_diff_cancel_watch(cancel_watch)?;
4922        let path_owned = crate::repo_relpath::storage_string(&index.entries[entry_index].path);
4923        let path_str_ref = path_owned.as_str();
4924        if use_fsmonitor_stat_skip {
4925            let ie_skip = &index.entries[entry_index];
4926            if crate::fsmonitor::entry_skips_worktree_stat(ie_skip, index) {
4927                return Ok(());
4928            }
4929        }
4930        match lookup {
4931            BlobDiskLookup::Missing => {
4932                let ie = &index.entries[entry_index];
4933                result.push(DiffEntry {
4934                    status: DiffStatus::Deleted,
4935                    old_path: Some(path_str_ref.to_owned()),
4936                    new_path: None,
4937                    old_mode: FileMode::new(ie.mode),
4938                    new_mode: FileMode::ABSENT,
4939                    old_oid: ie.oid,
4940                    new_oid: zero_oid(),
4941                    score: None,
4942                });
4943            }
4944            BlobDiskLookup::Io(e) => return Err(Error::Io(e)),
4945            BlobDiskLookup::Present(stat) => {
4946                if materialize_dirty_blobs {
4947                    staging_stat_cache.insert(path_str_ref.to_owned(), stat.fields);
4948                }
4949                if refresh_index_stat_in_pass
4950                    && worktree_diff_refreshes_entry(
4951                        &index.entries[entry_index],
4952                        &stat,
4953                        index_mtime,
4954                        stat_policy,
4955                        options.for_status,
4956                    )
4957                {
4958                    let refresh = refresh_index_blob_stat_from_scan(
4959                        &mut index.entries[entry_index],
4960                        &stat,
4961                        index_mtime,
4962                        stat_policy,
4963                        refresh_checks[entry_index].unwrap_or(false),
4964                    );
4965                    if refresh.index_changed {
4966                        index_stat_refresh_changed = true;
4967                    }
4968                    if refresh.content_verified {
4969                        refresh_content_verified[entry_index] = true;
4970                        let path = index.entries[entry_index].path.clone();
4971                        let oid = index.entries[entry_index].oid;
4972                        index.mark_content_verified(&path, oid);
4973                    }
4974                    if index.fsmonitor_last_update.is_some()
4975                        && stat_matches_blob_stat(&index.entries[entry_index], &stat, stat_policy)
4976                        && !refresh.index_changed
4977                    {
4978                        index.entries[entry_index].set_fsmonitor_valid(true);
4979                    }
4980                }
4981                let ie = &index.entries[entry_index];
4982                let worktree_mode =
4983                    mode_policy.index_mode_for_worktree(stat.git_mode, Some(ie.mode));
4984                // Streaming jobs read regular files. Links and directories need the shared
4985                // classifier; use the physical type so core.symlinks=false placeholders
4986                // keep the regular-file fast path even with an effective symlink mode.
4987                if parallel_blob_work && !stat.is_dir && stat.git_mode != crate::index::MODE_SYMLINK
4988                {
4989                    let stat_same = stat_matches_blob_stat(ie, &stat, stat_policy);
4990                    if stat_same
4991                        && worktree_mode == ie.mode
4992                        && index_entry_skips_worktree_content_hash_after_stat_match(
4993                            ie,
4994                            index_mtime,
4995                            stat_policy,
4996                        )
4997                    {
4998                        return Ok(());
4999                    }
5000                    if stat_same && worktree_mode != ie.mode {
5001                        result.push(worktree_content_change_entry(
5002                            ie,
5003                            path_str_ref,
5004                            worktree_mode,
5005                            ie.oid,
5006                        ));
5007                    } else if try_push_status_modified_on_size_mismatch_stat(
5008                        ie,
5009                        path_str_ref,
5010                        &stat,
5011                        worktree_mode,
5012                        options.for_status,
5013                        &mut result,
5014                    ) {
5015                    } else if !refresh_content_verified[entry_index] {
5016                        let file_path = index_entry_worktree_abs_bytes(
5017                            work_tree,
5018                            &index.entries[entry_index].path,
5019                            precompose_unicode,
5020                            ignorecase,
5021                        );
5022                        staging_hash_jobs.push(StagingHashJob {
5023                            entry_index,
5024                            rel_path: path_str_ref.to_owned(),
5025                            file_path,
5026                            entry: ie.clone(),
5027                            worktree_mode,
5028                            file_attrs: file_attrs_for(path_str_ref),
5029                        });
5030                    }
5031                } else {
5032                    // The scan's lstat already proves an unchanged, non-racy entry clean, and the
5033                    // stat refresh above may have just verified a racy entry by content; only
5034                    // re-stat paths that `push_index_blob_worktree_diff` has to inspect.
5035                    let same_file_type = (worktree_mode == crate::index::MODE_SYMLINK)
5036                        == (ie.mode == crate::index::MODE_SYMLINK);
5037                    if !stat.is_dir
5038                        && worktree_mode == ie.mode
5039                        && ((refresh_content_verified[entry_index] && same_file_type)
5040                            || (stat_matches_blob_stat(ie, &stat, stat_policy)
5041                                && index_entry_skips_worktree_content_hash_after_stat_match(
5042                                    ie,
5043                                    index_mtime,
5044                                    stat_policy,
5045                                )))
5046                    {
5047                        return Ok(());
5048                    }
5049                    let file_path = index_entry_worktree_abs_bytes(
5050                        work_tree,
5051                        &index.entries[entry_index].path,
5052                        precompose_unicode,
5053                        ignorecase,
5054                    );
5055                    let meta = fs::symlink_metadata(&file_path).map_err(Error::Io)?;
5056                    push_index_blob_worktree_diff(
5057                        odb,
5058                        ie,
5059                        path_str_ref,
5060                        &file_path,
5061                        meta,
5062                        index_mtime,
5063                        mode_policy,
5064                        stat_policy,
5065                        &file_attrs_for,
5066                        &conv,
5067                        materialize_dirty_blobs,
5068                        hash_blobs_without_write,
5069                        options.for_status,
5070                        filter_fp,
5071                        &mut result,
5072                    )?;
5073                }
5074            }
5075        }
5076        Ok(())
5077    })?;
5078    if parallel_blob_work && !staging_hash_jobs.is_empty() {
5079        parallel_process_blob_worktree_jobs(
5080            odb,
5081            &staging_hash_jobs,
5082            &thread_policy,
5083            &config,
5084            materialize_dirty_blobs,
5085            hash_blobs_without_write,
5086            &conv,
5087            filter_fp,
5088            &mut result,
5089        )?;
5090    }
5091
5092    for (path, (_, base_entry)) in unmerged_base {
5093        let file_path = index_entry_worktree_abs(work_tree, &path, precompose_unicode, ignorecase);
5094        let wt_meta = match fs::symlink_metadata(&file_path) {
5095            Ok(meta) => Some(meta),
5096            Err(e)
5097                if e.kind() == std::io::ErrorKind::NotFound
5098                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5099            {
5100                None
5101            }
5102            Err(e) => return Err(Error::Io(e)),
5103        };
5104
5105        let new_mode = wt_meta.as_ref().map_or_else(
5106            || FileMode::ABSENT,
5107            |meta| FileMode::new(mode_from_metadata(meta)),
5108        );
5109        result.push(DiffEntry {
5110            status: DiffStatus::Unmerged,
5111            old_path: Some(path.clone()),
5112            new_path: Some(path.clone()),
5113            old_mode: FileMode::ABSENT,
5114            new_mode,
5115            old_oid: zero_oid(),
5116            new_oid: zero_oid(),
5117            score: None,
5118        });
5119
5120        if !options.for_status {
5121            if let Some(meta) = wt_meta {
5122                let file_attrs = file_attrs_for(&path);
5123                let wt_oid = worktree_file_oid(
5124                    odb,
5125                    &file_path,
5126                    &meta,
5127                    &conv,
5128                    &file_attrs,
5129                    &path,
5130                    Some(&base_entry),
5131                    materialize_dirty_blobs,
5132                    filter_fp,
5133                )?;
5134                let wt_mode = mode_from_metadata(&meta);
5135                if wt_oid != base_entry.oid || wt_mode != base_entry.mode {
5136                    result.push(DiffEntry {
5137                        status: DiffStatus::for_content_change(base_entry.mode, wt_mode),
5138                        old_path: Some(path.clone()),
5139                        new_path: Some(path),
5140                        old_mode: FileMode::new(base_entry.mode),
5141                        new_mode: FileMode::new(wt_mode),
5142                        old_oid: base_entry.oid,
5143                        new_oid: wt_oid,
5144                        score: None,
5145                    });
5146                }
5147            }
5148        }
5149    }
5150
5151    Ok((result, index_stat_refresh_changed, staging_stat_cache))
5152}
5153
5154/// Memoized cache of which ancestor directories are symlinks, so each directory
5155/// is probed at most once per worktree scan. Shared by parallel stat workers.
5156#[derive(Default)]
5157pub(crate) struct SymlinkDirCache {
5158    /// Repository-relative directory (raw index bytes) to whether it is a symlink.
5159    dirs: std::sync::Mutex<hashbrown::HashMap<Vec<u8>, bool>>,
5160}
5161
5162pub(crate) fn index_entry_worktree_abs(
5163    work_tree: &Path,
5164    index_relpath: &str,
5165    precompose_unicode: bool,
5166    ignorecase: bool,
5167) -> PathBuf {
5168    index_entry_worktree_abs_bytes(
5169        work_tree,
5170        index_relpath.as_bytes(),
5171        precompose_unicode,
5172        ignorecase,
5173    )
5174}
5175
5176pub(crate) fn index_entry_worktree_abs_bytes(
5177    work_tree: &Path,
5178    index_relpath: &[u8],
5179    precompose_unicode: bool,
5180    ignorecase: bool,
5181) -> PathBuf {
5182    if precompose_unicode || ignorecase {
5183        if let Ok(s) = std::str::from_utf8(index_relpath) {
5184            if precompose_unicode {
5185                return crate::unicode_normalization::resolve_worktree_path_for_staging(
5186                    work_tree, s, true,
5187                )
5188                .abs;
5189            }
5190            return crate::path_icase::worktree_path_for_index_entry(work_tree, s, ignorecase);
5191        }
5192    }
5193    crate::index::worktree_path_from_index_rel(work_tree, index_relpath)
5194}
5195
5196/// Effective index-file mtime for racy-git checks.
5197///
5198/// Uses an explicit override when provided, otherwise [`Index::source_mtime`] from the last
5199/// on-disk read.
5200#[must_use]
5201pub fn index_mtime_for_diff(index: &Index, explicit: Option<(u32, u32)>) -> Option<(u32, u32)> {
5202    explicit.or(index.source_mtime)
5203}
5204
5205impl SymlinkDirCache {
5206    /// Whether any parent directory of `rel_path` (raw index path bytes) is a symlink.
5207    ///
5208    /// Probes each ancestor at most once across all callers; the filesystem query runs
5209    /// outside the lock so parallel workers do not serialize on it.
5210    pub(crate) fn has_symlink_in_path_bytes(
5211        &self,
5212        work_tree: &Path,
5213        rel_path: &[u8],
5214        precompose_unicode: bool,
5215        ignorecase: bool,
5216    ) -> bool {
5217        let Some(dir_len) = rel_path.iter().rposition(|&b| b == b'/') else {
5218            return false;
5219        };
5220        let prefix_ends = rel_path[..dir_len]
5221            .iter()
5222            .enumerate()
5223            .filter_map(|(i, &b)| (b == b'/').then_some(i))
5224            .chain(std::iter::once(dir_len));
5225        for end in prefix_ends {
5226            let prefix = &rel_path[..end];
5227            if prefix.is_empty() || prefix.ends_with(b"/") {
5228                continue;
5229            }
5230            let cached = self
5231                .dirs
5232                .lock()
5233                .unwrap_or_else(|e| e.into_inner())
5234                .get(prefix)
5235                .copied();
5236            let is_symlink = cached.unwrap_or_else(|| {
5237                let abs = index_entry_worktree_abs_bytes(
5238                    work_tree,
5239                    prefix,
5240                    precompose_unicode,
5241                    ignorecase,
5242                );
5243                let probed = crate::worktree_scan::path_is_symlink(&abs);
5244                self.dirs
5245                    .lock()
5246                    .unwrap_or_else(|e| e.into_inner())
5247                    .insert(prefix.to_vec(), probed);
5248                probed
5249            });
5250            if is_symlink {
5251                return true;
5252            }
5253        }
5254        false
5255    }
5256}
5257
5258/// Whether an index entry's cached mtime is at or after the on-disk index file's mtime.
5259///
5260/// Git labels such entries racy: stat data can look clean even though the worktree changed
5261/// within the same second the index was written. Callers pass the index file mtime sampled when
5262/// the index was read (before any rewrite). When `index_mtime` is `None`, returns `false`.
5263#[must_use]
5264pub fn entry_is_racy(ie: &IndexEntry, index_mtime: Option<(u32, u32)>) -> bool {
5265    entry_is_racy_with_policy(ie, index_mtime, crate::index::StatMatchPolicy::default())
5266}
5267
5268/// Like [`entry_is_racy`], honoring `core.usenanosec` for sub-second racy detection.
5269#[must_use]
5270pub fn entry_is_racy_with_policy(
5271    ie: &IndexEntry,
5272    index_mtime: Option<(u32, u32)>,
5273    policy: crate::index::StatMatchPolicy,
5274) -> bool {
5275    let Some((index_mtime_sec, index_mtime_nsec)) = index_mtime else {
5276        return false;
5277    };
5278    if index_mtime_sec == 0 {
5279        return false;
5280    }
5281    if policy.use_nanosec {
5282        index_mtime_sec < ie.mtime_sec
5283            || (index_mtime_sec == ie.mtime_sec && index_mtime_nsec <= ie.mtime_nsec)
5284    } else {
5285        index_mtime_sec <= ie.mtime_sec
5286    }
5287}
5288
5289/// Clear racily-clean cache lines on entries whose blob no longer matches the worktree.
5290///
5291/// Before Git writes the index, it walks stage-0 file and symlink entries and sets the cached
5292/// size to zero when stat data still matches the worktree but content does not match the
5293/// recorded OID and the entry timestamp is racy relative to `index_mtime`. That forces the
5294/// next diff or status pass to re-hash the path instead of trusting stat alone.
5295///
5296/// Skip-worktree, assume-unchanged, and intent-to-add entries are left unchanged, and so are
5297/// entries this process just hashed from the worktree to their current OID (Git skips its
5298/// `CE_UPTODATE` entries the same way). When `index_mtime` is `None` (no prior on-disk index),
5299/// nothing is smudged.
5300///
5301/// Content equality uses `git_dir` config and worktree attributes with [`hash_worktree_file`]
5302/// so the repository hash algorithm and clean (CRLF/filter) conversion match index OIDs.
5303///
5304/// Returns `true` when at least one entry was smudged.
5305pub fn smudge_racily_clean_entries(
5306    odb: &Odb,
5307    git_dir: &Path,
5308    index: &mut Index,
5309    work_tree: &Path,
5310    index_mtime: Option<(u32, u32)>,
5311    config: Option<&ConfigSet>,
5312    filter_process: Option<&crate::filter_process::FilterProcessState>,
5313) -> bool {
5314    use crate::crlf;
5315    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
5316
5317    let Some(index_mtime) = index_mtime else {
5318        return false;
5319    };
5320
5321    let config = config.cloned().unwrap_or_else(|| {
5322        ConfigSet::load(
5323            &crate::environment::Environment::empty(),
5324            Some(git_dir),
5325            true,
5326        )
5327        .unwrap_or_default()
5328    });
5329    let conv = crlf::ConversionConfig::from_config(&config);
5330    let attrs = crlf::load_gitattributes(work_tree);
5331    let verified = std::mem::take(&mut index.content_verified);
5332
5333    let mut changed = false;
5334    for ie in &mut index.entries {
5335        if ie.stage() != 0 || ie.skip_worktree() || ie.assume_unchanged() || ie.intent_to_add() {
5336            continue;
5337        }
5338        if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE && ie.mode != MODE_SYMLINK {
5339            continue;
5340        }
5341        if ie.size == 0 {
5342            continue;
5343        }
5344        if !entry_is_racy(ie, Some(index_mtime)) {
5345            continue;
5346        }
5347        if verified.get(&ie.path) == Some(&ie.oid) {
5348            continue;
5349        }
5350        let Ok(path) = std::str::from_utf8(&ie.path) else {
5351            continue;
5352        };
5353        let abs = work_tree.join(path);
5354        let Ok(meta) = fs::symlink_metadata(&abs) else {
5355            continue;
5356        };
5357        if !stat_matches(ie, &meta) {
5358            continue;
5359        }
5360        let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
5361        let content_matches = hash_worktree_file(
5362            odb,
5363            &abs,
5364            &meta,
5365            &conv,
5366            &file_attrs,
5367            path,
5368            Some(ie),
5369            filter_process,
5370        )
5371        .map(|oid| oid == ie.oid)
5372        .unwrap_or(false);
5373        if content_matches {
5374            continue;
5375        }
5376        ie.size = 0;
5377        changed = true;
5378    }
5379    changed
5380}
5381/// Quick stat check: does the index entry's cached stat data match the file?
5382/// Returns true when the file at `ie`'s path differs from the index entry (mode or blob).
5383///
5384/// Used by commands such as `git mv` to detect "dirty" paths under sparse checkout.
5385/// Symlinks and submodules are compared in a Git-compatible way.
5386///
5387/// `ignore_submodule_untracked` mirrors [`diff_index_to_worktree`]'s same flag for gitlinks.
5388pub fn worktree_differs_from_index_entry(
5389    odb: &Odb,
5390    repository_git_dir: &Path,
5391    work_tree: &Path,
5392    ie: &IndexEntry,
5393    ignore_submodule_untracked: bool,
5394    config: Option<&ConfigSet>,
5395    filter_process: Option<&crate::filter_process::FilterProcessState>,
5396) -> Result<bool> {
5397    WorktreeEntryComparer::new(repository_git_dir, work_tree, config).differs(
5398        odb,
5399        ie,
5400        ignore_submodule_untracked,
5401        filter_process,
5402    )
5403}
5404
5405/// Repeated [`worktree_differs_from_index_entry`] checks against one worktree.
5406///
5407/// Loads config-derived settings and the root `.gitattributes` once instead of per entry.
5408pub(crate) struct WorktreeEntryComparer<'a> {
5409    work_tree: &'a Path,
5410    config: ConfigSet,
5411    mode_policy: crate::index::WorktreeModePolicy,
5412    precompose_unicode: bool,
5413    ignorecase: bool,
5414    conv: std::cell::OnceCell<crate::crlf::ConversionConfig>,
5415    attrs: std::cell::OnceCell<Vec<crate::crlf::AttrRule>>,
5416}
5417
5418impl<'a> WorktreeEntryComparer<'a> {
5419    /// Capture settings for `work_tree`; `config` falls back to the repository config files.
5420    pub(crate) fn new(
5421        repository_git_dir: &Path,
5422        work_tree: &'a Path,
5423        config: Option<&ConfigSet>,
5424    ) -> Self {
5425        let config = config.cloned().unwrap_or_else(|| {
5426            ConfigSet::load(
5427                &crate::environment::Environment::empty(),
5428                Some(repository_git_dir),
5429                true,
5430            )
5431            .unwrap_or_else(|_| ConfigSet::new())
5432        });
5433        let flag =
5434            |key: &str, default: bool| config.get_bool(key).and_then(|r| r.ok()).unwrap_or(default);
5435        Self {
5436            work_tree,
5437            mode_policy: crate::index::WorktreeModePolicy::from_config(&config),
5438            precompose_unicode: flag("core.precomposeunicode", false),
5439            ignorecase: flag("core.ignorecase", false),
5440            config,
5441            conv: std::cell::OnceCell::new(),
5442            attrs: std::cell::OnceCell::new(),
5443        }
5444    }
5445
5446    /// Same contract as [`worktree_differs_from_index_entry`].
5447    pub(crate) fn differs(
5448        &self,
5449        odb: &Odb,
5450        ie: &IndexEntry,
5451        ignore_submodule_untracked: bool,
5452        filter_process: Option<&crate::filter_process::FilterProcessState>,
5453    ) -> Result<bool> {
5454        use crate::crlf;
5455
5456        let work_tree = self.work_tree;
5457        let path_stored = crate::repo_relpath::storage_string(&ie.path);
5458        let path_str_ref = crate::repo_relpath::as_utf8_for_matching(&path_stored);
5459        let file_path = index_entry_worktree_abs_bytes(
5460            work_tree,
5461            &ie.path,
5462            self.precompose_unicode,
5463            self.ignorecase,
5464        );
5465
5466        if ie.mode == 0o160000 {
5467            let sub_head_oid = read_submodule_head(&file_path);
5468            let ref_matches = match sub_head_oid {
5469                Some(oid) => oid == ie.oid,
5470                None => submodule_worktree_is_unpopulated_placeholder(&file_path),
5471            };
5472            let mut flags = submodule_porcelain_flags(work_tree, path_str_ref.as_ref(), ie.oid);
5473            if ignore_submodule_untracked {
5474                flags.untracked = false;
5475            }
5476            return Ok(!ref_matches || flags.modified || flags.untracked);
5477        }
5478
5479        let meta = match fs::symlink_metadata(&file_path) {
5480            Ok(m) => m,
5481            Err(e)
5482                if e.kind() == std::io::ErrorKind::NotFound
5483                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5484            {
5485                return Ok(true);
5486            }
5487            Err(e) => return Err(Error::Io(e)),
5488        };
5489
5490        if meta.is_dir() {
5491            return Ok(true);
5492        }
5493
5494        if !self
5495            .mode_policy
5496            .worktree_mode_matches(mode_from_metadata(&meta), ie.mode)
5497        {
5498            return Ok(true);
5499        }
5500
5501        let conv = self
5502            .conv
5503            .get_or_init(|| crlf::ConversionConfig::from_config(&self.config));
5504        let attrs = self
5505            .attrs
5506            .get_or_init(|| crlf::load_gitattributes(work_tree));
5507        let file_attrs = crlf::get_file_attrs(attrs, path_str_ref.as_ref(), false, &self.config);
5508        let worktree_oid = hash_worktree_file(
5509            odb,
5510            &file_path,
5511            &meta,
5512            conv,
5513            &file_attrs,
5514            path_str_ref.as_ref(),
5515            Some(ie),
5516            filter_process,
5517        )?;
5518
5519        let mut eff_oid = worktree_oid;
5520        if eff_oid != ie.oid {
5521            if let Ok(raw) = fs::read(&file_path) {
5522                let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
5523                if raw_oid == ie.oid {
5524                    eff_oid = ie.oid;
5525                }
5526            }
5527        }
5528
5529        Ok(eff_oid != ie.oid)
5530    }
5531}
5532
5533/// Inputs for [`classify_worktree_entry_for_add`].
5534pub struct WorktreeAddRefreshParams<'a> {
5535    /// Object database used to verify blob OIDs when stat data is inconclusive.
5536    pub odb: &'a Odb,
5537    /// Index entry being compared.
5538    pub ie: &'a IndexEntry,
5539    /// `lstat` metadata for the worktree path.
5540    pub meta: &'a fs::Metadata,
5541    /// Absolute worktree path to the file.
5542    pub abs_path: &'a Path,
5543    /// Repository-relative path string.
5544    pub rel_path: &'a str,
5545    /// CRLF / filter conversion settings.
5546    pub conv: &'a crate::crlf::ConversionConfig,
5547    /// Per-path attribute state.
5548    pub file_attrs: &'a crate::crlf::FileAttrs,
5549    /// On-disk index mtime when the index was loaded; `None` skips racy checks.
5550    pub index_mtime: Option<(u32, u32)>,
5551    /// Mode that would be written, after [`crate::index::WorktreeModePolicy`] against `ie.mode`
5552    /// and any `--chmod` hint.
5553    pub staged_mode: u32,
5554    /// Repository-scoped filter-process registry (when staging through a [`crate::repo::Repository`]).
5555    pub filter_process: Option<&'a crate::filter_process::FilterProcessState>,
5556}
5557
5558/// Outcome of comparing a tracked index entry to its worktree path for staging refresh.
5559#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5560pub enum WorktreeAddRefresh {
5561    /// Index already reflects the worktree; caller may skip staging.
5562    UpToDate,
5563    /// Executable bit differs while blob content is unchanged.
5564    ModeOnly {
5565        /// Mode to store in the index.
5566        mode: u32,
5567    },
5568    /// Blob OID and mode are unchanged; only cached stat fields should be refreshed.
5569    StatOnly,
5570    /// Content, type, or mode/blob mismatch requires a full restage (read, convert, hash).
5571    NeedsRestage,
5572}
5573
5574/// Whether stat-only fast paths may skip re-hashing during staging.
5575#[must_use]
5576pub(crate) fn index_stat_cache_trustworthy(index_mtime: Option<(u32, u32)>) -> bool {
5577    match index_mtime {
5578        None => false,
5579        Some((0, _)) => false,
5580        Some(_) => true,
5581    }
5582}
5583
5584/// When true, after worktree metadata has been read and matches the index entry, blob content
5585/// hashing may be skipped (Git `diff-files` / `update-index --refresh` style).
5586///
5587/// Does not permit skipping worktree observation; fsmonitor-valid entries use a separate path.
5588/// Independent of `core.filemode`: callers compare the executable bit separately.
5589///
5590/// A zero cached size is trusted only for the empty blob; any other blob with size zero was
5591/// smudged by a racy index write and must be re-hashed (Git `ce_match_stat_basic`).
5592#[must_use]
5593pub(crate) fn index_entry_skips_worktree_content_hash_after_stat_match(
5594    ie: &IndexEntry,
5595    index_mtime: Option<(u32, u32)>,
5596    stat_policy: crate::index::StatMatchPolicy,
5597) -> bool {
5598    if ie.stat_cache_unset() || (ie.size == 0 && !ie.oid.is_empty_blob()) {
5599        return false;
5600    }
5601    if !index_stat_cache_trustworthy(index_mtime) {
5602        return false;
5603    }
5604    !entry_is_racy_with_policy(ie, index_mtime, stat_policy)
5605}
5606
5607/// Decide whether staging must re-read and re-hash a tracked path.
5608///
5609/// Mirrors the fast paths in [`diff_index_to_worktree`] and Git's `refresh_index` /
5610/// `add_file_to_index`: trust matching stat when the entry is not racy and
5611/// `index_stat_cache_trustworthy` allows it; re-hash when stat matches but the entry is racy
5612/// or when stat differs; apply CRLF-aware hashing like staging.
5613///
5614/// Returns [`WorktreeAddRefresh::UpToDate`] when the index entry already matches the worktree and
5615/// no restaging is required; other variants describe mode/stat-only updates or a full restage.
5616///
5617/// # Errors
5618///
5619/// Returns [`Error::Io`] when the worktree file cannot be read for content verification.
5620pub fn classify_worktree_entry_for_add(
5621    params: &WorktreeAddRefreshParams<'_>,
5622) -> Result<WorktreeAddRefresh> {
5623    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
5624
5625    let ie = params.ie;
5626    let meta = params.meta;
5627    let staged_mode = params.staged_mode;
5628
5629    if ie.intent_to_add() {
5630        return Ok(WorktreeAddRefresh::NeedsRestage);
5631    }
5632
5633    let racy = entry_is_racy(ie, params.index_mtime);
5634    let stat_same = stat_matches(ie, meta);
5635    let trust_stat = index_stat_cache_trustworthy(params.index_mtime);
5636
5637    if ie.mode == MODE_SYMLINK {
5638        // With `core.symlinks=false` the policy keeps a plain file holding the link target as
5639        // a symlink entry, so it is compared by content like a real link.
5640        if !meta.file_type().is_symlink() && staged_mode != MODE_SYMLINK {
5641            return Ok(WorktreeAddRefresh::NeedsRestage);
5642        }
5643        if stat_same && !racy && trust_stat && staged_mode == ie.mode {
5644            return Ok(WorktreeAddRefresh::UpToDate);
5645        }
5646        if stat_same && !racy && trust_stat && staged_mode != ie.mode {
5647            return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
5648        }
5649        return worktree_effective_oid_matches_index(params);
5650    }
5651
5652    if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE {
5653        return Ok(WorktreeAddRefresh::NeedsRestage);
5654    }
5655
5656    if meta.file_type().is_symlink() || meta.is_dir() {
5657        return Ok(WorktreeAddRefresh::NeedsRestage);
5658    }
5659
5660    if stat_same && !racy && trust_stat {
5661        if staged_mode == ie.mode {
5662            return Ok(WorktreeAddRefresh::UpToDate);
5663        }
5664        return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
5665    }
5666
5667    worktree_effective_oid_matches_index(params)
5668}
5669
5670fn worktree_effective_oid_matches_index(
5671    params: &WorktreeAddRefreshParams<'_>,
5672) -> Result<WorktreeAddRefresh> {
5673    let WorktreeAddRefreshParams {
5674        odb,
5675        ie,
5676        meta,
5677        abs_path,
5678        rel_path,
5679        conv,
5680        file_attrs,
5681        staged_mode,
5682        ..
5683    } = params;
5684    let worktree_oid = hash_worktree_file(
5685        odb,
5686        abs_path,
5687        meta,
5688        conv,
5689        file_attrs,
5690        rel_path,
5691        Some(ie),
5692        params.filter_process,
5693    )?;
5694    let mut eff_oid = worktree_oid;
5695    if eff_oid != ie.oid {
5696        if let Ok(raw) = fs::read(abs_path) {
5697            let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
5698            if raw_oid == ie.oid {
5699                eff_oid = ie.oid;
5700            }
5701        }
5702    }
5703    if eff_oid != ie.oid {
5704        return Ok(WorktreeAddRefresh::NeedsRestage);
5705    }
5706    if *staged_mode != ie.mode {
5707        return Ok(WorktreeAddRefresh::ModeOnly { mode: *staged_mode });
5708    }
5709    if stat_matches(ie, meta) {
5710        Ok(WorktreeAddRefresh::UpToDate)
5711    } else {
5712        Ok(WorktreeAddRefresh::StatOnly)
5713    }
5714}
5715
5716/// Whether worktree metadata matches cached index stat data (Git `ie_match_stat` / `match_stat_data`).
5717#[must_use]
5718pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
5719    stat_matches_with_policy(ie, meta, crate::index::StatMatchPolicy::default())
5720}
5721
5722/// Whether compact worktree stat from a directory scan matches the index row.
5723pub(crate) fn stat_matches_blob_stat(
5724    ie: &IndexEntry,
5725    stat: &crate::worktree_scan::BlobDiskStat,
5726    policy: crate::index::StatMatchPolicy,
5727) -> bool {
5728    stat.fields.matches_index_entry(ie, policy)
5729}
5730
5731/// Like [`stat_matches`], honoring `core.trustctime`, `core.checkstat`, and `core.usenanosec`.
5732#[must_use]
5733pub fn stat_matches_with_policy(
5734    ie: &IndexEntry,
5735    meta: &fs::Metadata,
5736    policy: crate::index::StatMatchPolicy,
5737) -> bool {
5738    if crate::index::munge_index_size(meta.len()) != ie.size {
5739        return false;
5740    }
5741    #[cfg(unix)]
5742    {
5743        use std::os::unix::fs::MetadataExt;
5744        if meta.mtime() as u32 != ie.mtime_sec {
5745            return false;
5746        }
5747        if policy.use_nanosec && policy.check_stat && meta.mtime_nsec() as u32 != ie.mtime_nsec {
5748            return false;
5749        }
5750        if policy.trust_ctime && policy.check_stat {
5751            if meta.ctime() as u32 != ie.ctime_sec {
5752                return false;
5753            }
5754            if policy.use_nanosec && meta.ctime_nsec() as u32 != ie.ctime_nsec {
5755                return false;
5756            }
5757        }
5758        if policy.check_stat && meta.ino() as u32 != ie.ino {
5759            return false;
5760        }
5761    }
5762    #[cfg(not(unix))]
5763    {
5764        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = crate::index::metadata_times(meta);
5765        if mtime_sec != ie.mtime_sec {
5766            return false;
5767        }
5768        if policy.use_nanosec && policy.check_stat && mtime_nsec != ie.mtime_nsec {
5769            return false;
5770        }
5771        if policy.trust_ctime && policy.check_stat {
5772            if ctime_sec != ie.ctime_sec {
5773                return false;
5774            }
5775            if policy.use_nanosec && ctime_nsec != ie.ctime_nsec {
5776                return false;
5777            }
5778        }
5779    }
5780    true
5781}
5782
5783/// Refresh cached stat data for stage-0 file/symlink entries whose worktree content still matches
5784/// the recorded OID but whose on-disk stat went stale.
5785///
5786/// This mirrors Git's `refresh_index` / `refresh_cache_ent`: an entry is only marked clean (stat
5787/// adopted from the worktree) after its content is re-verified against the index OID. A genuinely
5788/// modified entry keeps its stale stat so `diff-files` / `status` continue to report it. Operations
5789/// that rewrite the worktree (`status`, `reset --mixed`, `stash`) call this before writing the
5790/// index so a subsequent `git diff-files` sees refreshed entries as clean.
5791///
5792/// Gitlinks, sparse (`skip_worktree`), `assume_unchanged` and intent-to-add entries are skipped.
5793/// The blob comparison is a raw-content hash, so a CRLF-smudged match is conservatively missed
5794/// (the entry simply stays stat-dirty and is re-hashed next time — never the reverse).
5795///
5796/// `index_mtime` is the on-disk index file's `(mtime_sec, mtime_nsec)` (see
5797/// `entry_is_racy` / Git `is_racy_timestamp`); pass `None` when unknown — racy detection is
5798/// then skipped, which is conservative for tree-built indexes whose zeroed stat never matches.
5799///
5800/// `stat_parallel_threads` overrides parallel stat workers (`Some(1)` forces serial); when `None`,
5801/// honors `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
5802///
5803/// Returns `Ok(true)` when at least one entry was refreshed or invalidated, so callers can write
5804/// the index opportunistically (Git only persists a refresh that changed something).
5805pub fn refresh_index_stat_content_verified(
5806    odb: &Odb,
5807    git_dir: &Path,
5808    index: &mut Index,
5809    work_tree: &Path,
5810    index_mtime: Option<(u32, u32)>,
5811    config: Option<&ConfigSet>,
5812    _stat_parallel_threads: Option<usize>,
5813) -> Result<bool> {
5814    let _ = _stat_parallel_threads;
5815    refresh_index_stat_content_verified_with_rules(
5816        odb,
5817        git_dir,
5818        index,
5819        work_tree,
5820        index_mtime,
5821        config,
5822        _stat_parallel_threads,
5823        None,
5824    )
5825}
5826
5827/// Like [`refresh_index_stat_content_verified`], resolving attributes and conversion through a
5828/// shared [`crate::worktree_rules::WorktreeRules`] when one is supplied (its config snapshot then
5829/// takes precedence over `config`).
5830///
5831/// # Errors
5832///
5833/// Propagates I/O errors from the directory-grouped worktree scan.
5834#[allow(clippy::too_many_arguments)]
5835pub fn refresh_index_stat_content_verified_with_rules(
5836    odb: &Odb,
5837    git_dir: &Path,
5838    index: &mut Index,
5839    work_tree: &Path,
5840    index_mtime: Option<(u32, u32)>,
5841    config: Option<&ConfigSet>,
5842    stat_parallel_threads: Option<usize>,
5843    worktree_rules: Option<&std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
5844) -> Result<bool> {
5845    use crate::config::ConfigSet;
5846    use crate::crlf;
5847    use crate::worktree_scan::{
5848        collect_blob_lookup_batches, group_blob_entries_by_dir, visit_blob_lookup_batches,
5849        BlobDiskLookup, BlobScanSparseMode, WorktreeBlobScanOptions,
5850    };
5851
5852    let index_mtime = index_mtime_for_diff(index, index_mtime);
5853    let rules_lock = worktree_rules
5854        .map(|shared| {
5855            shared
5856                .lock()
5857                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
5858        })
5859        .transpose()?;
5860    let config = rules_lock
5861        .as_ref()
5862        .map(|r| r.config().clone())
5863        .unwrap_or_else(|| {
5864            config.cloned().unwrap_or_else(|| {
5865                ConfigSet::load(
5866                    &crate::environment::Environment::empty(),
5867                    Some(git_dir),
5868                    true,
5869                )
5870                .unwrap_or_default()
5871            })
5872        });
5873    let preload_index = config
5874        .get_bool("core.preloadIndex")
5875        .and_then(|r| r.ok())
5876        .unwrap_or(true);
5877    let blob_scan = WorktreeBlobScanOptions {
5878        preload_index,
5879        stat_parallel_threads,
5880    };
5881    let conv = rules_lock
5882        .as_ref()
5883        .map(|r| r.conversion().clone())
5884        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
5885    let legacy_attrs = if rules_lock.is_none() {
5886        Some(crlf::load_gitattributes_parsed(work_tree))
5887    } else {
5888        None
5889    };
5890    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
5891        if let Some(rules) = rules_lock.as_ref() {
5892            rules.file_attrs(path, false)
5893        } else if let Some(parsed) = legacy_attrs.as_ref() {
5894            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
5895        } else {
5896            crlf::FileAttrs::default()
5897        }
5898    };
5899    let precompose_unicode = config
5900        .get_bool("core.precomposeunicode")
5901        .and_then(|r| r.ok())
5902        .unwrap_or(false);
5903    let ignorecase = config
5904        .get_bool("core.ignorecase")
5905        .and_then(|r| r.ok())
5906        .unwrap_or(false);
5907    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
5908
5909    let blob_dirs = group_blob_entries_by_dir(index, false);
5910    let work_tree_owned = work_tree.to_path_buf();
5911    let dir_abs = move |dir: &[u8]| {
5912        if dir.is_empty() {
5913            work_tree_owned.clone()
5914        } else {
5915            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
5916        }
5917    };
5918    let work_tree_for_files = work_tree.to_path_buf();
5919    let file_abs = move |rel: &[u8]| {
5920        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
5921    };
5922    let dir_symlinks = SymlinkDirCache::default();
5923    let mut changed = false;
5924    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
5925    let thread_policy = crate::parallel::ThreadPolicy::default();
5926    // Symlink ancestor detection must run even when core.symlinks is false.
5927    let refresh_batches = collect_blob_lookup_batches(
5928        index,
5929        &blob_dirs,
5930        blob_scan,
5931        &thread_policy,
5932        &config,
5933        dir_abs,
5934        file_abs,
5935        |rel_path| {
5936            dir_symlinks.has_symlink_in_path_bytes(
5937                work_tree,
5938                rel_path,
5939                precompose_unicode,
5940                ignorecase,
5941            )
5942        },
5943        BlobScanSparseMode::StatRefresh {
5944            stat_policy,
5945            index_mtime,
5946        },
5947    )?;
5948    let par_opts = refresh_hash_parallel_options(&thread_policy, &config, stat_parallel_threads);
5949    let refresh_checks = refresh_content_checks(
5950        odb,
5951        index,
5952        &refresh_batches,
5953        index_mtime,
5954        stat_policy,
5955        &|_, _| true,
5956        &WorktreeHashContext {
5957            work_tree,
5958            precompose_unicode,
5959            ignorecase,
5960            conv: &conv,
5961            file_attrs_for: &file_attrs_for,
5962            filter_process: filter_fp,
5963        },
5964        par_opts,
5965    );
5966    visit_blob_lookup_batches(refresh_batches, |entry_index, lookup| {
5967        let BlobDiskLookup::Present(stat) = lookup else {
5968            return Ok(());
5969        };
5970        let refresh = refresh_index_blob_stat_from_scan(
5971            &mut index.entries[entry_index],
5972            &stat,
5973            index_mtime,
5974            stat_policy,
5975            refresh_checks[entry_index].unwrap_or(false),
5976        );
5977        if refresh.index_changed {
5978            changed = true;
5979        }
5980        if refresh.content_verified {
5981            let path = index.entries[entry_index].path.clone();
5982            let oid = index.entries[entry_index].oid;
5983            index.mark_content_verified(&path, oid);
5984        }
5985        Ok(())
5986    })?;
5987    Ok(changed)
5988}
5989
5990/// Result of refreshing one index entry's cached stat during a worktree scan.
5991struct BlobStatRefreshOutcome {
5992    /// Whether the in-memory index entry was updated (stat fields or invalidation).
5993    index_changed: bool,
5994    /// Blob content was read and matches the recorded OID (skip a second hash in the same pass).
5995    content_verified: bool,
5996}
5997
5998/// Whether the stat refresh may adopt worktree stat data for `ie` at all.
5999fn refresh_index_entry_eligible(ie: &IndexEntry) -> bool {
6000    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
6001    ie.stage() == 0
6002        && !ie.skip_worktree()
6003        && !ie.assume_unchanged()
6004        && !ie.intent_to_add()
6005        && matches!(ie.mode, MODE_REGULAR | MODE_EXECUTABLE | MODE_SYMLINK)
6006}
6007
6008/// Whether refreshing `ie` from the scanned `stat` must first prove by content that the
6009/// worktree still holds `ie.oid`: the stat data changed, or it matches but is racy.
6010fn refresh_needs_content_check(
6011    ie: &IndexEntry,
6012    stat: &crate::worktree_scan::BlobDiskStat,
6013    index_mtime: Option<(u32, u32)>,
6014    stat_policy: crate::index::StatMatchPolicy,
6015) -> bool {
6016    refresh_index_entry_eligible(ie)
6017        && !stat.is_dir
6018        && (!stat_matches_blob_stat(ie, stat, stat_policy)
6019            || entry_is_racy_with_policy(ie, index_mtime, stat_policy))
6020}
6021
6022/// Whether a worktree diff with `refresh_index_stat_in_pass` refreshes `ie` from `stat`.
6023///
6024/// Status leaves out entries whose stat data already decides the answer: clean and not racy,
6025/// or a changed size (reported as modified without reading the file).
6026pub(crate) fn worktree_diff_refreshes_entry(
6027    ie: &IndexEntry,
6028    stat: &crate::worktree_scan::BlobDiskStat,
6029    index_mtime: Option<(u32, u32)>,
6030    stat_policy: crate::index::StatMatchPolicy,
6031    for_status: bool,
6032) -> bool {
6033    if !for_status {
6034        return true;
6035    }
6036    if stat_matches_blob_stat(ie, stat, stat_policy) {
6037        return entry_is_racy_with_policy(ie, index_mtime, stat_policy);
6038    }
6039    stat.worktree_size() == ie.size || ie.stat_cache_unset()
6040}
6041
6042/// Pool options for the stat refresh's content checks.
6043///
6044/// `thread_policy` and config pick the worker count; `stat_parallel_threads` caps it the same
6045/// way it caps the stat scan (`Some(0)` or `Some(1)` keeps hashing on the calling thread).
6046/// There is no byte threshold: opening each file, not hashing its bytes, dominates for the
6047/// small files a checkout leaves racy.
6048fn refresh_hash_parallel_options(
6049    thread_policy: &crate::parallel::ThreadPolicy,
6050    config: &ConfigSet,
6051    stat_parallel_threads: Option<usize>,
6052) -> crate::parallel::ParallelOptions {
6053    let policy = crate::parallel::ThreadPolicy {
6054        min_items_override: thread_policy.min_items_override.or(Some(0)),
6055        min_total_bytes_override: thread_policy.min_total_bytes_override.or(Some(0)),
6056        ..*thread_policy
6057    };
6058    let mut par_opts = policy.resolve(crate::parallel::ParallelOp::IndexRefresh, config);
6059    if let Some(cap) = stat_parallel_threads {
6060        par_opts.threads = par_opts
6061            .threads
6062            .min(std::num::NonZeroUsize::new(cap).unwrap_or(std::num::NonZeroUsize::MIN));
6063    }
6064    par_opts.min_items = 0;
6065    par_opts.min_total_bytes = None;
6066    par_opts
6067}
6068
6069/// Worktree layout and attribute context for hashing index entries during a scan.
6070struct WorktreeHashContext<'a> {
6071    work_tree: &'a Path,
6072    precompose_unicode: bool,
6073    ignorecase: bool,
6074    conv: &'a crate::crlf::ConversionConfig,
6075    file_attrs_for: &'a dyn Fn(&str) -> crate::crlf::FileAttrs,
6076    filter_process: Option<&'a crate::filter_process::FilterProcessState>,
6077}
6078
6079/// Run every content check the stat refresh needs before the serial visit of `batches`.
6080///
6081/// Racily clean entries are the common case right after a checkout writes the index: every
6082/// file then shares the index's timestamp second. Hashing them here, in parallel when
6083/// `par_opts` and the total size allow it, keeps that first scan from reading files one by one.
6084///
6085/// Returns one slot per index entry: `Some(matches)` for scanned entries that `include` admits
6086/// and [`refresh_needs_content_check`] selects, `None` elsewhere. Read and filter failures count
6087/// as a mismatch, so those entries stay unverified.
6088#[allow(clippy::too_many_arguments)]
6089fn refresh_content_checks(
6090    odb: &Odb,
6091    index: &Index,
6092    batches: &[Vec<(usize, crate::worktree_scan::BlobDiskLookup)>],
6093    index_mtime: Option<(u32, u32)>,
6094    stat_policy: crate::index::StatMatchPolicy,
6095    include: &dyn Fn(&IndexEntry, &crate::worktree_scan::BlobDiskStat) -> bool,
6096    ctx: &WorktreeHashContext<'_>,
6097    par_opts: crate::parallel::ParallelOptions,
6098) -> Vec<Option<bool>> {
6099    use crate::worktree_scan::BlobDiskLookup;
6100
6101    /// Attributes are resolved on the calling thread: the shared rules are not `Sync`.
6102    struct ContentCheck {
6103        entry_index: usize,
6104        worktree_mode: u32,
6105        file_attrs: crate::crlf::FileAttrs,
6106    }
6107
6108    let mut jobs = Vec::new();
6109    let mut total_bytes = 0usize;
6110    for (entry_index, lookup) in batches.iter().flatten() {
6111        let BlobDiskLookup::Present(stat) = lookup else {
6112            continue;
6113        };
6114        let ie = &index.entries[*entry_index];
6115        if !refresh_needs_content_check(ie, stat, index_mtime, stat_policy) || !include(ie, stat) {
6116            continue;
6117        }
6118        total_bytes = total_bytes.saturating_add(ie.size as usize);
6119        jobs.push(ContentCheck {
6120            entry_index: *entry_index,
6121            worktree_mode: stat.git_mode,
6122            file_attrs: (ctx.file_attrs_for)(&crate::repo_relpath::storage_string(&ie.path)),
6123        });
6124    }
6125    let mut checks = vec![None; index.entries.len()];
6126    if jobs.is_empty() {
6127        return checks;
6128    }
6129    let (work_tree, precompose_unicode, ignorecase) =
6130        (ctx.work_tree, ctx.precompose_unicode, ctx.ignorecase);
6131    let (conv, filter_process) = (ctx.conv, ctx.filter_process);
6132    let entries = index.entries.as_slice();
6133    let matches = if par_opts.threads.get() > 1 && jobs.len() > 1 {
6134        crate::parallel::try_par_map_force::<_, _, std::convert::Infallible, _>(
6135            &jobs,
6136            par_opts,
6137            |job| {
6138                let ie = &entries[job.entry_index];
6139                let file_path = index_entry_worktree_abs_bytes(
6140                    work_tree,
6141                    &ie.path,
6142                    precompose_unicode,
6143                    ignorecase,
6144                );
6145                Ok(hash_worktree_file_with_mode(
6146                    odb,
6147                    &file_path,
6148                    job.worktree_mode,
6149                    conv,
6150                    &job.file_attrs,
6151                    &crate::repo_relpath::storage_string(&ie.path),
6152                    Some(ie),
6153                    filter_process,
6154                )
6155                .is_ok_and(|oid| oid == ie.oid))
6156            },
6157        )
6158        .unwrap_or_else(|e| match e {
6159            crate::parallel::ParallelHashError::Task(infallible) => match infallible {},
6160        })
6161    } else {
6162        jobs.iter()
6163            .map(|job| {
6164                let ie = &entries[job.entry_index];
6165                let file_path = index_entry_worktree_abs_bytes(
6166                    work_tree,
6167                    &ie.path,
6168                    precompose_unicode,
6169                    ignorecase,
6170                );
6171                hash_worktree_file_with_mode(
6172                    odb,
6173                    &file_path,
6174                    job.worktree_mode,
6175                    conv,
6176                    &job.file_attrs,
6177                    &crate::repo_relpath::storage_string(&ie.path),
6178                    Some(ie),
6179                    filter_process,
6180                )
6181                .is_ok_and(|oid| oid == ie.oid)
6182            })
6183            .collect()
6184    };
6185    for (job, matched) in jobs.iter().zip(matches) {
6186        checks[job.entry_index] = Some(matched);
6187    }
6188    checks
6189}
6190
6191/// Adopt the scanned `stat` for `ie` where that cannot hide a worktree change.
6192///
6193/// `content_matches` is the [`refresh_content_checks`] result for this entry; it is only
6194/// consulted when [`refresh_needs_content_check`] holds. A racy entry whose content no longer
6195/// matches has its stat cache invalidated so later scans re-hash it.
6196fn refresh_index_blob_stat_from_scan(
6197    ie: &mut IndexEntry,
6198    stat: &crate::worktree_scan::BlobDiskStat,
6199    index_mtime: Option<(u32, u32)>,
6200    stat_policy: crate::index::StatMatchPolicy,
6201    content_matches: bool,
6202) -> BlobStatRefreshOutcome {
6203    const UNCHANGED: BlobStatRefreshOutcome = BlobStatRefreshOutcome {
6204        index_changed: false,
6205        content_verified: false,
6206    };
6207    if !refresh_index_entry_eligible(ie) || stat.is_dir {
6208        return UNCHANGED;
6209    }
6210    let cached = crate::index::IndexStatFields::of_entry(ie);
6211    let stat_same = cached.equivalent_to(&stat.fields, stat_policy);
6212    let stat_matches = stat_matches_blob_stat(ie, stat, stat_policy);
6213    if !stat_matches || entry_is_racy_with_policy(ie, index_mtime, stat_policy) {
6214        if !content_matches {
6215            if stat_matches {
6216                invalidate_index_stat_cache(ie);
6217                return BlobStatRefreshOutcome {
6218                    index_changed: true,
6219                    content_verified: false,
6220                };
6221            }
6222            return UNCHANGED;
6223        }
6224        if !stat_same {
6225            stat.fields.store_in(ie);
6226        }
6227        return BlobStatRefreshOutcome {
6228            index_changed: !stat_same,
6229            content_verified: true,
6230        };
6231    }
6232    if stat_same {
6233        return BlobStatRefreshOutcome {
6234            index_changed: false,
6235            content_verified: true,
6236        };
6237    }
6238    stat.fields.store_in(ie);
6239    BlobStatRefreshOutcome {
6240        index_changed: true,
6241        content_verified: false,
6242    }
6243}
6244
6245/// Whether path-only checkout can skip writing a blob because the worktree is up to date.
6246///
6247/// Git's `checkout_entry` trusts matching stat data via `ie_match_stat` unless the entry is
6248/// racy relative to the on-disk index mtime, in which case blob content must match the expected
6249/// OID using the repository hash algorithm and clean/filter conversion ([`hash_worktree_file`]).
6250///
6251/// Returns `Ok(true)` when the caller should skip rewriting the path.
6252///
6253/// # Errors
6254///
6255/// Propagates I/O or hashing failures from [`hash_worktree_file`].
6256#[allow(clippy::too_many_arguments)] // mirrors Git `checkout_entry` stat/OID inputs
6257pub fn path_checkout_skip_blob_write_when_up_to_date(
6258    odb: &Odb,
6259    git_dir: &Path,
6260    work_tree: &Path,
6261    index: &Index,
6262    entry: &IndexEntry,
6263    expected_oid: &ObjectId,
6264    expected_mode: u32,
6265    rel_path: &str,
6266    filter_process: Option<&crate::filter_process::FilterProcessState>,
6267) -> Result<bool> {
6268    path_checkout_skip_blob_write_when_up_to_date_with_config(
6269        odb,
6270        git_dir,
6271        work_tree,
6272        index,
6273        entry,
6274        expected_oid,
6275        expected_mode,
6276        rel_path,
6277        filter_process,
6278        None,
6279    )
6280}
6281
6282/// Like [`path_checkout_skip_blob_write_when_up_to_date`], with an optional preloaded config.
6283#[allow(clippy::too_many_arguments)]
6284pub(crate) fn path_checkout_skip_blob_write_when_up_to_date_with_config(
6285    odb: &Odb,
6286    git_dir: &Path,
6287    work_tree: &Path,
6288    index: &Index,
6289    entry: &IndexEntry,
6290    expected_oid: &ObjectId,
6291    expected_mode: u32,
6292    rel_path: &str,
6293    filter_process: Option<&crate::filter_process::FilterProcessState>,
6294    preloaded_config: Option<&crate::config::ConfigSet>,
6295) -> Result<bool> {
6296    use crate::config::ConfigSet;
6297    use crate::crlf;
6298    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR};
6299
6300    if entry.oid != *expected_oid || entry.mode != expected_mode {
6301        return Ok(false);
6302    }
6303    if expected_mode != MODE_REGULAR && expected_mode != MODE_EXECUTABLE {
6304        return Ok(false);
6305    }
6306    let abs_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
6307    let meta = match fs::symlink_metadata(&abs_path) {
6308        Ok(m) => m,
6309        Err(_) => return Ok(false),
6310    };
6311    if !meta.is_file() || !stat_matches(entry, &meta) {
6312        return Ok(false);
6313    }
6314    let owned_config;
6315    let config = match preloaded_config {
6316        Some(cfg) => cfg,
6317        None => {
6318            owned_config = ConfigSet::load(
6319                &crate::environment::Environment::empty(),
6320                Some(git_dir),
6321                true,
6322            )
6323            .unwrap_or_default();
6324            &owned_config
6325        }
6326    };
6327    if !crate::index::WorktreeModePolicy::from_config(config)
6328        .worktree_mode_matches(mode_from_metadata(&meta), expected_mode)
6329    {
6330        return Ok(false);
6331    }
6332    let index_mtime = index_mtime_for_diff(index, None);
6333    if !entry_is_racy(entry, index_mtime) {
6334        return Ok(true);
6335    }
6336    let conv = crlf::ConversionConfig::from_config(config);
6337    let attrs = crlf::load_gitattributes(work_tree);
6338    let file_attrs = crlf::get_file_attrs(&attrs, rel_path, false, config);
6339    let wt_oid = hash_worktree_file(
6340        odb,
6341        &abs_path,
6342        &meta,
6343        &conv,
6344        &file_attrs,
6345        rel_path,
6346        Some(entry),
6347        filter_process,
6348    )?;
6349    Ok(wt_oid == *expected_oid)
6350}
6351
6352/// Clear cached stat fields so the next diff/status pass re-reads the work tree.
6353fn invalidate_index_stat_cache(ie: &mut IndexEntry) {
6354    ie.ctime_sec = 0;
6355    ie.ctime_nsec = 0;
6356    ie.mtime_sec = 0;
6357    ie.mtime_nsec = 0;
6358    ie.dev = 0;
6359    ie.ino = 0;
6360    ie.size = 0;
6361}
6362
6363/// Read worktree content for diffing, honoring Git tree modes.
6364///
6365/// For [`MODE_SYMLINK`] entries backed by a symlink on disk this returns the link target path
6366/// bytes (via [`read_link`](std::fs::read_link)), not the contents of the followed file. A
6367/// symlink entry checked out as a plain file (`core.symlinks=false`) already holds the target,
6368/// so it is read like other modes, with a plain [`read`](std::fs::read) of the path.
6369///
6370/// # Errors
6371///
6372/// Returns [`std::io::Error`] when the path cannot be read.
6373pub fn read_worktree_bytes_for_mode(path: &Path, mode: u32) -> std::io::Result<Vec<u8>> {
6374    if mode == MODE_SYMLINK && fs::symlink_metadata(path)?.file_type().is_symlink() {
6375        let target = fs::read_link(path)?;
6376        return Ok(symlink_target_bytes(&target));
6377    }
6378    fs::read(path)
6379}
6380
6381/// Read worktree file bytes in Git (repository) form for line diffs against index or tree blobs.
6382///
6383/// Applies the same `.gitattributes`, filter, and line-ending rules as
6384/// [`diff_index_to_worktree`]. Pass the stage-0 [`IndexEntry`] when available so
6385/// `autocrlf` index-guard normalization matches status and raw diff collection.
6386///
6387/// # Errors
6388///
6389/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
6390pub fn read_worktree_side_bytes_for_diff(
6391    odb: &Odb,
6392    work_tree: &Path,
6393    rel_path: &str,
6394    mode: u32,
6395    config: &crate::config::ConfigSet,
6396    index_entry: Option<&IndexEntry>,
6397    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
6398) -> Result<Vec<u8>> {
6399    let file_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
6400    let rel_match = crate::repo_relpath::as_utf8_for_matching(rel_path);
6401    let meta = fs::symlink_metadata(&file_path).map_err(|source| {
6402        crate::worktree::map_worktree_io(
6403            rel_match.as_ref(),
6404            crate::worktree::WorktreePathOperation::Metadata,
6405            source,
6406        )
6407    })?;
6408    if mode == MODE_SYMLINK && meta.file_type().is_symlink() {
6409        let target = fs::read_link(&file_path).map_err(|source| {
6410            crate::worktree::map_worktree_io(
6411                rel_match.as_ref(),
6412                crate::worktree::WorktreePathOperation::Read,
6413                source,
6414            )
6415        })?;
6416        return Ok(symlink_target_bytes(&target));
6417    }
6418    if let Some(rules) = worktree_rules {
6419        return worktree_blob_bytes(
6420            odb,
6421            &file_path,
6422            &meta,
6423            rules.conversion(),
6424            &rules.file_attrs(rel_match.as_ref(), meta.is_dir()),
6425            rel_match.as_ref(),
6426            index_entry,
6427            Some(rules.filter_process()),
6428        );
6429    }
6430    let conv = crate::crlf::ConversionConfig::from_config(config);
6431    let attrs = crate::crlf::load_gitattributes(work_tree);
6432    let file_attrs = crate::crlf::get_file_attrs(&attrs, rel_match.as_ref(), meta.is_dir(), config);
6433    worktree_blob_bytes(
6434        odb,
6435        &file_path,
6436        &meta,
6437        &conv,
6438        &file_attrs,
6439        rel_match.as_ref(),
6440        index_entry,
6441        None,
6442    )
6443}
6444
6445/// Symlink target as the byte string Git hashes for the blob OID.
6446///
6447/// On Unix the raw `OsStr` bytes are used verbatim. On Windows `OsStr` is WTF-8
6448/// and has no stable byte view, so the lossy UTF-8 form is used (symlink targets
6449/// in Git trees are UTF-8 in practice).
6450pub(crate) fn symlink_target_bytes(target: &Path) -> Vec<u8> {
6451    #[cfg(unix)]
6452    {
6453        use std::os::unix::ffi::OsStrExt as _;
6454        target.as_os_str().as_bytes().to_vec()
6455    }
6456    #[cfg(not(unix))]
6457    {
6458        target.as_os_str().to_string_lossy().as_bytes().to_vec()
6459    }
6460}
6461
6462/// A worktree file's blob content: normalized bytes, or an open file that is hashed or
6463/// stored as a stream.
6464///
6465/// Regular files of at least the caller's size threshold whose bytes can be stored without clean
6466/// conversion ([`crate::crlf::worktree_allows_raw_byte_stream`], including binary files under
6467/// `core.autocrlf`) are streamed so memory use does not depend on the file size.
6468pub(crate) enum WorktreeBlobContent {
6469    /// Content after the clean conversion (filters, encoding, line endings).
6470    Bytes(Vec<u8>),
6471    /// Unconverted big file, positioned at its start.
6472    Stream {
6473        /// Open handle on the worktree file.
6474        file: fs::File,
6475        /// File size when it was opened.
6476        size: u64,
6477    },
6478}
6479
6480impl WorktreeBlobContent {
6481    /// Object id of the content, without storing it.
6482    pub(crate) fn hash(self, odb: &Odb) -> Result<ObjectId> {
6483        match self {
6484            Self::Bytes(data) => odb.hash(ObjectKind::Blob, &data),
6485            Self::Stream { mut file, size } => odb.hash_stream(ObjectKind::Blob, size, &mut file),
6486        }
6487    }
6488
6489    /// Store the content as a blob and return its id.
6490    pub(crate) fn store(self, odb: &Odb, options: crate::odb::WriteOptions) -> Result<ObjectId> {
6491        match self {
6492            Self::Bytes(data) => odb.write_with_options(ObjectKind::Blob, &data, options),
6493            Self::Stream { mut file, size } => {
6494                odb.write_stream(ObjectKind::Blob, size, &mut file, options)
6495            }
6496        }
6497    }
6498
6499    /// [`Self::store`] with `options` when given, otherwise [`Self::hash`].
6500    fn object_id(self, odb: &Odb, store: Option<crate::odb::WriteOptions>) -> Result<ObjectId> {
6501        match store {
6502            Some(options) => self.store(odb, options),
6503            None => self.hash(odb),
6504        }
6505    }
6506}
6507
6508/// Read and clean-convert an open regular worktree file of `size` bytes.
6509#[allow(clippy::too_many_arguments)]
6510fn read_regular_worktree_bytes(
6511    odb: &Odb,
6512    mut file: fs::File,
6513    size: u64,
6514    conv: &crate::crlf::ConversionConfig,
6515    file_attrs: &crate::crlf::FileAttrs,
6516    rel_path: &str,
6517    index_entry: Option<&IndexEntry>,
6518    filter_process: Option<&crate::filter_process::FilterProcessState>,
6519) -> Result<Vec<u8>> {
6520    let needs_index_blob =
6521        index_entry.is_some_and(|_| crate::crlf::clean_uses_autocrlf_index_guard(file_attrs, conv));
6522    let prior_blob: Option<Vec<u8>> = if needs_index_blob {
6523        index_entry
6524            .filter(|e| e.oid != zero_oid())
6525            .and_then(|e| odb.read(&e.oid).ok().map(|o| o.data))
6526    } else {
6527        None
6528    };
6529    let mut raw = Vec::with_capacity(usize::try_from(size).unwrap_or_default());
6530    std::io::Read::read_to_end(&mut file, &mut raw).map_err(|source| {
6531        crate::worktree::map_worktree_io(
6532            rel_path,
6533            crate::worktree::WorktreePathOperation::Read,
6534            source,
6535        )
6536    })?;
6537    #[cfg(test)]
6538    odb.hot_path_metrics().record_blob_content_read();
6539    let opts = crate::crlf::ConvertToGitOpts {
6540        index_blob: prior_blob.as_deref(),
6541        renormalize: false,
6542        check_safecrlf: false,
6543        filter_process,
6544        command_runner: None,
6545    };
6546    Ok(crate::crlf::convert_to_git_owned(
6547        raw, rel_path, conv, file_attrs, opts,
6548    )?)
6549}
6550
6551fn open_with_size(path: &Path, rel_path: &str) -> Result<(fs::File, u64)> {
6552    let file = fs::File::open(path).map_err(|source| {
6553        crate::worktree::map_worktree_io(
6554            rel_path,
6555            crate::worktree::WorktreePathOperation::Read,
6556            source,
6557        )
6558    })?;
6559    let size = file
6560        .metadata()
6561        .map_err(|source| {
6562            crate::worktree::map_worktree_io(
6563                rel_path,
6564                crate::worktree::WorktreePathOperation::Metadata,
6565                source,
6566            )
6567        })?
6568        .len();
6569    Ok((file, size))
6570}
6571
6572/// Open a regular worktree file and clean-convert it, or hand it out as a stream when it is
6573/// at least `stream_from` bytes and its clean conversion is the identity.
6574#[allow(clippy::too_many_arguments)]
6575fn read_regular_worktree_blob(
6576    odb: &Odb,
6577    path: &Path,
6578    conv: &crate::crlf::ConversionConfig,
6579    file_attrs: &crate::crlf::FileAttrs,
6580    rel_path: &str,
6581    index_entry: Option<&IndexEntry>,
6582    filter_process: Option<&crate::filter_process::FilterProcessState>,
6583    stream_from: u64,
6584) -> Result<WorktreeBlobContent> {
6585    let (mut file, size) = open_with_size(path, rel_path)?;
6586    if size >= stream_from
6587        && crate::crlf::worktree_allows_raw_byte_stream(
6588            conv,
6589            file_attrs,
6590            index_entry,
6591            &mut file,
6592            size,
6593        )?
6594    {
6595        #[cfg(test)]
6596        odb.hot_path_metrics().record_blob_content_read();
6597        return Ok(WorktreeBlobContent::Stream { file, size });
6598    }
6599    read_regular_worktree_bytes(
6600        odb,
6601        file,
6602        size,
6603        conv,
6604        file_attrs,
6605        rel_path,
6606        index_entry,
6607        filter_process,
6608    )
6609    .map(WorktreeBlobContent::Bytes)
6610}
6611
6612/// Blob content for a worktree path: symlink target, empty for a directory, or the
6613/// clean-converted (or streamed, see [`WorktreeBlobContent`]) file content.
6614#[allow(clippy::too_many_arguments)]
6615pub(crate) fn worktree_blob_content(
6616    odb: &Odb,
6617    path: &Path,
6618    meta: &fs::Metadata,
6619    conv: &crate::crlf::ConversionConfig,
6620    file_attrs: &crate::crlf::FileAttrs,
6621    rel_path: &str,
6622    index_entry: Option<&IndexEntry>,
6623    filter_process: Option<&crate::filter_process::FilterProcessState>,
6624) -> Result<WorktreeBlobContent> {
6625    worktree_blob_content_from(
6626        odb,
6627        path,
6628        meta,
6629        conv,
6630        file_attrs,
6631        rel_path,
6632        index_entry,
6633        filter_process,
6634        hash_stream_threshold(odb)?,
6635    )
6636}
6637
6638/// [`worktree_blob_content`], streaming unconverted regular files from `stream_from` bytes.
6639#[allow(clippy::too_many_arguments)]
6640fn worktree_blob_content_from(
6641    odb: &Odb,
6642    path: &Path,
6643    meta: &fs::Metadata,
6644    conv: &crate::crlf::ConversionConfig,
6645    file_attrs: &crate::crlf::FileAttrs,
6646    rel_path: &str,
6647    index_entry: Option<&IndexEntry>,
6648    filter_process: Option<&crate::filter_process::FilterProcessState>,
6649    stream_from: u64,
6650) -> Result<WorktreeBlobContent> {
6651    if meta.file_type().is_symlink() {
6652        let target = fs::read_link(path).map_err(|source| {
6653            crate::worktree::map_worktree_io(
6654                rel_path,
6655                crate::worktree::WorktreePathOperation::Read,
6656                source,
6657            )
6658        })?;
6659        return Ok(WorktreeBlobContent::Bytes(symlink_target_bytes(&target)));
6660    }
6661    if meta.is_dir() {
6662        return Ok(WorktreeBlobContent::Bytes(Vec::new()));
6663    }
6664    read_regular_worktree_blob(
6665        odb,
6666        path,
6667        conv,
6668        file_attrs,
6669        rel_path,
6670        index_entry,
6671        filter_process,
6672        stream_from,
6673    )
6674}
6675
6676/// Like [`worktree_blob_content`], always returning the bytes (for callers that need the
6677/// content itself, such as line diffs).
6678#[allow(clippy::too_many_arguments)]
6679pub(crate) fn worktree_blob_bytes(
6680    odb: &Odb,
6681    path: &Path,
6682    meta: &fs::Metadata,
6683    conv: &crate::crlf::ConversionConfig,
6684    file_attrs: &crate::crlf::FileAttrs,
6685    rel_path: &str,
6686    index_entry: Option<&IndexEntry>,
6687    filter_process: Option<&crate::filter_process::FilterProcessState>,
6688) -> Result<Vec<u8>> {
6689    if meta.file_type().is_symlink() {
6690        let target = fs::read_link(path).map_err(|source| {
6691            crate::worktree::map_worktree_io(
6692                rel_path,
6693                crate::worktree::WorktreePathOperation::Read,
6694                source,
6695            )
6696        })?;
6697        return Ok(symlink_target_bytes(&target));
6698    }
6699    if meta.is_dir() {
6700        return Ok(Vec::new());
6701    }
6702    let (file, size) = open_with_size(path, rel_path)?;
6703    read_regular_worktree_bytes(
6704        odb,
6705        file,
6706        size,
6707        conv,
6708        file_attrs,
6709        rel_path,
6710        index_entry,
6711        filter_process,
6712    )
6713}
6714
6715#[allow(clippy::too_many_arguments)]
6716fn worktree_file_oid(
6717    odb: &Odb,
6718    path: &Path,
6719    meta: &fs::Metadata,
6720    conv: &crate::crlf::ConversionConfig,
6721    file_attrs: &crate::crlf::FileAttrs,
6722    rel_path: &str,
6723    index_entry: Option<&IndexEntry>,
6724    materialize: bool,
6725    filter_process: Option<&crate::filter_process::FilterProcessState>,
6726) -> Result<ObjectId> {
6727    let stream_from = if materialize {
6728        odb.big_file_threshold()?
6729    } else {
6730        hash_stream_threshold(odb)?
6731    };
6732    worktree_blob_content_from(
6733        odb,
6734        path,
6735        meta,
6736        conv,
6737        file_attrs,
6738        rel_path,
6739        index_entry,
6740        filter_process,
6741        stream_from,
6742    )?
6743    .object_id(odb, materialize.then_some(crate::odb::BULK_NEW_LOOSE_WRITE))
6744}
6745
6746/// Hash normalized worktree bytes for a path (does not write to the object database).
6747///
6748/// Files that need no clean conversion are hashed as a stream, without reading them into
6749/// memory, from 1 MiB (or `core.bigFileThreshold` when that is smaller).
6750///
6751/// # Errors
6752///
6753/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
6754/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
6755/// stored or hashed unfiltered in that case.
6756#[allow(clippy::too_many_arguments)]
6757pub fn hash_worktree_file(
6758    odb: &Odb,
6759    path: &Path,
6760    meta: &fs::Metadata,
6761    conv: &crate::crlf::ConversionConfig,
6762    file_attrs: &crate::crlf::FileAttrs,
6763    rel_path: &str,
6764    index_entry: Option<&IndexEntry>,
6765    filter_process: Option<&crate::filter_process::FilterProcessState>,
6766) -> Result<ObjectId> {
6767    worktree_file_oid(
6768        odb,
6769        path,
6770        meta,
6771        conv,
6772        file_attrs,
6773        rel_path,
6774        index_entry,
6775        false,
6776        filter_process,
6777    )
6778}
6779
6780/// Work-tree files at least this large are hashed as a stream (when their content goes into
6781/// Git unchanged) even below `core.bigFileThreshold`, which only governs how blobs are stored.
6782const STREAMED_HASH_MIN_SIZE: u64 = 1024 * 1024;
6783
6784/// Size from which an unconverted work-tree file is hashed as a stream rather than read whole.
6785fn hash_stream_threshold(odb: &Odb) -> Result<u64> {
6786    Ok(odb.big_file_threshold()?.min(STREAMED_HASH_MIN_SIZE))
6787}
6788
6789/// Like [`hash_worktree_file`], for callers that already know the worktree file type from a
6790/// directory scan (`worktree_mode` is the Git mode derived from that stat), so no metadata
6791/// probe is needed.
6792#[allow(clippy::too_many_arguments)]
6793pub(crate) fn hash_worktree_file_with_mode(
6794    odb: &Odb,
6795    path: &Path,
6796    worktree_mode: u32,
6797    conv: &crate::crlf::ConversionConfig,
6798    file_attrs: &crate::crlf::FileAttrs,
6799    rel_path: &str,
6800    index_entry: Option<&IndexEntry>,
6801    filter_process: Option<&crate::filter_process::FilterProcessState>,
6802) -> Result<ObjectId> {
6803    if worktree_mode == MODE_SYMLINK {
6804        let data = read_worktree_bytes_for_mode(path, worktree_mode)?;
6805        return odb.hash(ObjectKind::Blob, &data);
6806    }
6807    read_regular_worktree_blob(
6808        odb,
6809        path,
6810        conv,
6811        file_attrs,
6812        rel_path,
6813        index_entry,
6814        filter_process,
6815        hash_stream_threshold(odb)?,
6816    )?
6817    .hash(odb)
6818}
6819
6820/// Read, normalize (CRLF/attributes), and store a worktree blob once.
6821///
6822/// # Errors
6823///
6824/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
6825/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
6826/// stored or hashed unfiltered in that case.
6827#[allow(clippy::too_many_arguments)]
6828pub fn materialize_worktree_blob(
6829    odb: &Odb,
6830    path: &Path,
6831    meta: &fs::Metadata,
6832    conv: &crate::crlf::ConversionConfig,
6833    file_attrs: &crate::crlf::FileAttrs,
6834    rel_path: &str,
6835    index_entry: Option<&IndexEntry>,
6836    filter_process: Option<&crate::filter_process::FilterProcessState>,
6837) -> Result<ObjectId> {
6838    worktree_file_oid(
6839        odb,
6840        path,
6841        meta,
6842        conv,
6843        file_attrs,
6844        rel_path,
6845        index_entry,
6846        true,
6847        filter_process,
6848    )
6849}
6850
6851/// Derive a Git file mode from filesystem metadata.
6852pub fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
6853    if meta.file_type().is_symlink() {
6854        0o120000
6855    } else {
6856        #[cfg(unix)]
6857        {
6858            if meta.mode() & 0o111 != 0 {
6859                return 0o100755;
6860            }
6861        }
6862        0o100644
6863    }
6864}
6865
6866/// Compare a tree against the working tree.
6867///
6868/// Shows changes from `tree_oid` to the current working directory state.
6869/// Files tracked in the index but not in the tree are shown as Added.
6870/// Files in the tree but missing from the working tree are shown as Deleted.
6871///
6872/// # Parameters
6873///
6874/// - `odb` — object database.
6875/// - `tree_oid` — the tree to compare against (`None` for empty tree).
6876/// - `work_tree` — path to the working tree root.
6877/// - `index` — current index (used to discover new tracked files not in tree).
6878///
6879/// # Errors
6880///
6881/// Returns errors from ODB reads or I/O.
6882pub fn diff_tree_to_worktree(
6883    odb: &Odb,
6884    tree_oid: Option<&ObjectId>,
6885    work_tree: &Path,
6886    index: &Index,
6887) -> Result<Vec<DiffEntry>> {
6888    diff_tree_to_worktree_with_git_dir(
6889        odb,
6890        tree_oid,
6891        work_tree,
6892        &work_tree.join(".git"),
6893        index,
6894        None,
6895    )
6896}
6897
6898/// Like [`diff_tree_to_worktree`], but loads config from `repository_git_dir` (linked worktrees / gitfiles).
6899///
6900/// Pass `config` when the caller already holds a repository config snapshot.
6901pub fn diff_tree_to_worktree_with_git_dir(
6902    odb: &Odb,
6903    tree_oid: Option<&ObjectId>,
6904    work_tree: &Path,
6905    repository_git_dir: &Path,
6906    index: &Index,
6907    config: Option<&ConfigSet>,
6908) -> Result<Vec<DiffEntry>> {
6909    diff_tree_to_worktree_with_git_dir_and_rules(
6910        odb,
6911        tree_oid,
6912        work_tree,
6913        repository_git_dir,
6914        index,
6915        config,
6916        None,
6917    )
6918}
6919
6920/// Like [`diff_tree_to_worktree_with_git_dir`], with optional shared [`crate::worktree_rules::WorktreeRules`].
6921pub fn diff_tree_to_worktree_with_git_dir_and_rules(
6922    odb: &Odb,
6923    tree_oid: Option<&ObjectId>,
6924    work_tree: &Path,
6925    repository_git_dir: &Path,
6926    index: &Index,
6927    config: Option<&ConfigSet>,
6928    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
6929) -> Result<Vec<DiffEntry>> {
6930    use crate::config::ConfigSet;
6931    use crate::crlf;
6932
6933    if worktree_rules.is_none() {
6934        if let Some(tree_oid) = tree_oid {
6935            if index.cache_tree_root.as_ref() == Some(tree_oid)
6936                && crate::write_tree::cache_tree_fully_valid(odb, index.cache_tree.as_ref())
6937            {
6938                let mut idx = index.clone();
6939                let options = DiffIndexToWorktreeOptions {
6940                    repository_git_dir: Some(repository_git_dir.to_path_buf()),
6941                    refresh_index_stat_in_pass: false,
6942                    // Tree↔worktree raw lines use a null worktree OID when content differs; the
6943                    // status fast path (`for_status`) may report size-only drift with both sides
6944                    // set to the index blob (see `try_push_status_modified_on_size_mismatch`).
6945                    for_status: false,
6946                    config: config.cloned().map(std::sync::Arc::new),
6947                    ..DiffIndexToWorktreeOptions::default()
6948                };
6949                let (entries, _, _) = diff_index_to_worktree_inner(
6950                    odb,
6951                    &mut idx,
6952                    work_tree,
6953                    repository_git_dir,
6954                    options,
6955                    false,
6956                )?;
6957                return Ok(entries);
6958            }
6959        }
6960    }
6961
6962    let config = worktree_rules
6963        .map(|r| r.config().clone())
6964        .unwrap_or_else(|| {
6965            config.cloned().unwrap_or_else(|| {
6966                ConfigSet::load(
6967                    &crate::environment::Environment::empty(),
6968                    Some(repository_git_dir),
6969                    true,
6970                )
6971                .unwrap_or_else(|_| ConfigSet::new())
6972            })
6973        });
6974    let conv = worktree_rules
6975        .map(|r| r.conversion().clone())
6976        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
6977    let legacy_attrs = if worktree_rules.is_none() {
6978        Some(crlf::load_gitattributes_parsed(work_tree))
6979    } else {
6980        None
6981    };
6982    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
6983        if let Some(rules) = worktree_rules {
6984            rules.file_attrs(path, false)
6985        } else if let Some(parsed) = legacy_attrs.as_ref() {
6986            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
6987        } else {
6988            crlf::FileAttrs::default()
6989        }
6990    };
6991    let filter_fp = worktree_rules.map(|r| r.filter_process());
6992
6993    // Flatten the tree into a BTreeMap keyed by path
6994    let tree_flat = match tree_oid {
6995        Some(oid) => flatten_tree(odb, oid, "")?,
6996        None => Vec::new(),
6997    };
6998    let tree_map: std::collections::BTreeMap<String, &FlatEntry> =
6999        tree_flat.iter().map(|e| (e.path.clone(), e)).collect();
7000
7001    // Build index lookup: path → &IndexEntry (stage 0 only)
7002    let mut index_entries: std::collections::BTreeMap<&[u8], &IndexEntry> =
7003        std::collections::BTreeMap::new();
7004    let mut index_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
7005    let mut stage0_paths: std::collections::BTreeSet<Vec<u8>> = std::collections::BTreeSet::new();
7006    for ie in &index.entries {
7007        if ie.stage() != 0 {
7008            continue;
7009        }
7010        let path = String::from_utf8_lossy(&ie.path).to_string();
7011        index_entries.insert(&ie.path, ie);
7012        index_paths.insert(path);
7013        stage0_paths.insert(ie.path.clone());
7014    }
7015
7016    // Paths with only unmerged stages (1–3) and no stage 0 — `git diff <rev>` must still list them
7017    // so combined `diff --cc` conflict hunks can be emitted (`t4108-apply-threeway`).
7018    let mut unmerged_only_paths: std::collections::BTreeSet<String> =
7019        std::collections::BTreeSet::new();
7020    for ie in &index.entries {
7021        if !(1..=3).contains(&ie.stage()) {
7022            continue;
7023        }
7024        if stage0_paths.contains(&ie.path) {
7025            continue;
7026        }
7027        unmerged_only_paths.insert(String::from_utf8_lossy(&ie.path).into_owned());
7028    }
7029
7030    // Union of tree paths + index paths
7031    let mut all_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
7032    all_paths.extend(tree_map.keys().cloned());
7033    all_paths.extend(index_paths.iter().cloned());
7034    all_paths.extend(unmerged_only_paths.iter().cloned());
7035
7036    let index_mtime = index_mtime_for_diff(index, None);
7037    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
7038    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
7039    let mut result = Vec::new();
7040
7041    for path in &all_paths {
7042        if index_entries
7043            .get(path.as_bytes())
7044            .is_some_and(|ie| ie.skip_worktree())
7045        {
7046            // Sparse checkout: `git diff <rev>` does not report tree↔worktree drift for
7047            // skip-worktree paths (they are outside the sparse cone). Matches t7012 stash flow.
7048            continue;
7049        }
7050        if index_entries
7051            .get(path.as_bytes())
7052            .is_some_and(|ie| ie.assume_unchanged())
7053        {
7054            continue;
7055        }
7056
7057        let tree_entry = tree_map.get(path.as_str());
7058
7059        // Gitlink entries (submodules) — compare HEAD commit, not file content.
7060        let is_gitlink = tree_entry.is_some_and(|te| te.mode == 0o160000)
7061            || index_entries
7062                .get(path.as_bytes())
7063                .is_some_and(|ie| ie.mode == 0o160000);
7064        if is_gitlink {
7065            let sub_dir = work_tree.join(path);
7066            let index_gitlink_oid = index_entries
7067                .get(path.as_bytes())
7068                .filter(|ie| ie.mode == 0o160000)
7069                .map(|ie| ie.oid);
7070            match (tree_entry, index_gitlink_oid) {
7071                (Some(te), _) => {
7072                    let sub_head = read_submodule_head_oid(&sub_dir);
7073                    // A gitlink whose worktree directory no longer exists is a deleted submodule:
7074                    // Git's `diff-lib.c` reports it as a deletion (status `D`, new mode 000000) and
7075                    // `--submodule` renders the `(submodule deleted)` summary (t4041 #46).
7076                    if sub_head.is_none() && !sub_dir.exists() {
7077                        result.push(DiffEntry {
7078                            status: DiffStatus::Deleted,
7079                            old_path: Some(path.clone()),
7080                            new_path: Some(path.clone()),
7081                            old_mode: FileMode::new(te.mode),
7082                            new_mode: FileMode::ABSENT,
7083                            old_oid: te.oid,
7084                            new_oid: zero_oid(),
7085                            score: None,
7086                        });
7087                        continue;
7088                    }
7089                    let ref_matches = if let Some(head) = sub_head {
7090                        head == te.oid
7091                    } else {
7092                        submodule_worktree_is_unpopulated_placeholder(&sub_dir)
7093                    };
7094                    let index_matches_tree = index_gitlink_oid.is_some_and(|oid| oid == te.oid);
7095                    // Default tree↔worktree diff matches Git: untracked-only paths inside a
7096                    // checked-out submodule do not make the gitlink differ (see `git diff <tree>`).
7097                    let sub_flags = submodule_porcelain_flags(work_tree, path, te.oid);
7098                    let dirty_while_aligned =
7099                        index_matches_tree && ref_matches && sub_flags.modified;
7100                    if !ref_matches || dirty_while_aligned {
7101                        // Raw `git diff <tree>` lines use a null OID on the worktree side when the
7102                        // checked-out submodule HEAD differs from the tree's gitlink; patch output
7103                        // still resolves the real commit from the submodule directory.
7104                        let new_oid = if !ref_matches {
7105                            sub_head.unwrap_or_else(zero_oid)
7106                        } else {
7107                            zero_oid()
7108                        };
7109                        result.push(DiffEntry {
7110                            status: DiffStatus::Modified,
7111                            old_path: Some(path.clone()),
7112                            new_path: Some(path.clone()),
7113                            old_mode: FileMode::new(te.mode),
7114                            new_mode: FileMode::new(te.mode),
7115                            old_oid: te.oid,
7116                            new_oid,
7117                            score: None,
7118                        });
7119                    }
7120                }
7121                (None, Some(idx_oid)) => {
7122                    // Gitlink staged in the index but absent from the tree: a new submodule. Git
7123                    // reports it as an addition (status `A`) with the index gitlink on the new side,
7124                    // so `--submodule` renders `(new submodule)` (t4041 #46). The patch renderer
7125                    // resolves the real commit from the index OID / submodule HEAD.
7126                    let new_oid = read_submodule_head_oid(&sub_dir).unwrap_or(idx_oid);
7127                    result.push(DiffEntry {
7128                        status: DiffStatus::Added,
7129                        old_path: Some(path.clone()),
7130                        new_path: Some(path.clone()),
7131                        old_mode: FileMode::ABSENT,
7132                        new_mode: FileMode::new(0o160000),
7133                        old_oid: zero_oid(),
7134                        new_oid,
7135                        score: None,
7136                    });
7137                }
7138                (None, None) => {}
7139            }
7140            continue;
7141        }
7142
7143        let file_path = work_tree.join(path);
7144
7145        let wt_meta = match fs::symlink_metadata(&file_path) {
7146            Ok(m) => Some(m),
7147            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
7148            Err(e) => return Err(Error::Io(e)),
7149        };
7150
7151        if unmerged_only_paths.contains(path) {
7152            if let (Some(te), Some(meta)) = (tree_entry, wt_meta.as_ref()) {
7153                let file_attrs = file_attrs_for(path);
7154                let wt_oid = hash_worktree_file(
7155                    odb,
7156                    &file_path,
7157                    meta,
7158                    &conv,
7159                    &file_attrs,
7160                    path,
7161                    None,
7162                    filter_fp,
7163                )?;
7164                let wt_mode =
7165                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(te.mode));
7166                if wt_oid != te.oid || wt_mode != te.mode {
7167                    result.push(DiffEntry {
7168                        status: DiffStatus::for_content_change(te.mode, wt_mode),
7169                        old_path: Some(path.clone()),
7170                        new_path: Some(path.clone()),
7171                        old_mode: FileMode::new(te.mode),
7172                        new_mode: FileMode::new(wt_mode),
7173                        old_oid: te.oid,
7174                        new_oid: wt_oid,
7175                        score: None,
7176                    });
7177                }
7178            }
7179            continue;
7180        }
7181
7182        match (tree_entry, wt_meta) {
7183            (Some(te), Some(ref meta)) => {
7184                let Some(ie) = index_entries.get(path.as_bytes()) else {
7185                    continue;
7186                };
7187                let wt_mode =
7188                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(ie.mode));
7189
7190                let index_matches_tree = ie.oid == te.oid && ie.mode == te.mode;
7191
7192                // Fully clean: index matches `HEAD`, worktree matches index, stat cache fresh.
7193                let stat_trusted = stat_matches_with_policy(ie, meta, stat_policy)
7194                    && !entry_is_racy_with_policy(ie, index_mtime, stat_policy);
7195                if index_matches_tree && stat_trusted && wt_mode == te.mode {
7196                    continue;
7197                }
7198
7199                let file_attrs = file_attrs_for(path);
7200                let idx_ent = index_entries.get(path.as_bytes()).copied();
7201
7202                // Staged mode (same blob as `HEAD`, different mode recorded in the index) with
7203                // the worktree still matching the index. `core.filemode=false` does not discard
7204                // an explicit index chmod vs HEAD. Once the worktree drifts from the index, the
7205                // diff must describe the worktree side, so fall through.
7206                if ie.oid == te.oid && ie.mode != te.mode && stat_trusted && wt_mode == ie.mode {
7207                    result.push(DiffEntry {
7208                        status: DiffStatus::for_content_change(te.mode, ie.mode),
7209                        old_path: Some(path.clone()),
7210                        new_path: Some(path.clone()),
7211                        old_mode: FileMode::new(te.mode),
7212                        new_mode: FileMode::new(ie.mode),
7213                        old_oid: te.oid,
7214                        new_oid: te.oid,
7215                        score: None,
7216                    });
7217                    continue;
7218                }
7219
7220                // Index still matches `HEAD`: only unstaged worktree drift (content and/or
7221                // worktree-only exec bit when `update-index` was not run — t4049 harness).
7222                if index_matches_tree {
7223                    let wt_oid = hash_worktree_file(
7224                        odb,
7225                        &file_path,
7226                        meta,
7227                        &conv,
7228                        &file_attrs,
7229                        path,
7230                        None,
7231                        filter_fp,
7232                    )?;
7233                    if wt_oid != te.oid {
7234                        result.push(DiffEntry {
7235                            status: DiffStatus::for_content_change(te.mode, wt_mode),
7236                            old_path: Some(path.clone()),
7237                            new_path: Some(path.clone()),
7238                            old_mode: FileMode::new(te.mode),
7239                            new_mode: FileMode::new(wt_mode),
7240                            old_oid: te.oid,
7241                            // Raw `git diff <tree>` uses a null OID on the worktree side (patch
7242                            // output resolves content from the working tree).
7243                            new_oid: zero_oid(),
7244                            score: None,
7245                        });
7246                    } else if wt_mode != te.mode {
7247                        result.push(DiffEntry {
7248                            status: DiffStatus::for_content_change(te.mode, wt_mode),
7249                            old_path: Some(path.clone()),
7250                            new_path: Some(path.clone()),
7251                            old_mode: FileMode::new(te.mode),
7252                            new_mode: FileMode::new(wt_mode),
7253                            old_oid: te.oid,
7254                            new_oid: te.oid,
7255                            score: None,
7256                        });
7257                    }
7258                    continue;
7259                }
7260
7261                // Staged content (and possibly mode): `git diff <rev>` is tree vs working tree.
7262                let wt_oid = hash_worktree_file(
7263                    odb,
7264                    &file_path,
7265                    meta,
7266                    &conv,
7267                    &file_attrs,
7268                    path,
7269                    idx_ent,
7270                    filter_fp,
7271                )?;
7272                if wt_oid != te.oid || wt_mode != te.mode {
7273                    let new_oid = if wt_oid != te.oid { zero_oid() } else { te.oid };
7274                    result.push(DiffEntry {
7275                        status: DiffStatus::for_content_change(te.mode, wt_mode),
7276                        old_path: Some(path.clone()),
7277                        new_path: Some(path.clone()),
7278                        old_mode: FileMode::new(te.mode),
7279                        new_mode: FileMode::new(wt_mode),
7280                        old_oid: te.oid,
7281                        new_oid,
7282                        score: None,
7283                    });
7284                }
7285            }
7286            (Some(te), None) => {
7287                // In tree but missing from worktree
7288                result.push(DiffEntry {
7289                    status: DiffStatus::Deleted,
7290                    old_path: Some(path.clone()),
7291                    new_path: None,
7292                    old_mode: FileMode::new(te.mode),
7293                    new_mode: FileMode::ABSENT,
7294                    old_oid: te.oid,
7295                    new_oid: zero_oid(),
7296                    score: None,
7297                });
7298            }
7299            (None, Some(ref meta)) => {
7300                // In index but not in tree, and exists in worktree
7301                let file_attrs = file_attrs_for(path);
7302                let idx_ent = index_entries.get(path.as_bytes()).copied();
7303                let wt_oid = materialize_worktree_blob(
7304                    odb,
7305                    &file_path,
7306                    meta,
7307                    &conv,
7308                    &file_attrs,
7309                    path,
7310                    idx_ent,
7311                    filter_fp,
7312                )?;
7313                let wt_mode = mode_from_metadata(meta);
7314                result.push(DiffEntry {
7315                    status: DiffStatus::Added,
7316                    old_path: None,
7317                    new_path: Some(path.clone()),
7318                    old_mode: FileMode::ABSENT,
7319                    new_mode: FileMode::new(wt_mode),
7320                    old_oid: zero_oid(),
7321                    new_oid: wt_oid,
7322                    score: None,
7323                });
7324            }
7325            (None, None) => {
7326                // Tracked in index but neither in tree nor worktree — skip
7327            }
7328        }
7329    }
7330
7331    sort_entries_by_path(&mut result);
7332    Ok(result)
7333}
7334
7335// ── Rename detection ────────────────────────────────────────────────
7336
7337fn read_added_entry_bytes(
7338    odb: &Odb,
7339    entry: &DiffEntry,
7340    work_root: Option<&Path>,
7341) -> Option<Vec<u8>> {
7342    if entry.new_oid != zero_oid() {
7343        if let Ok(obj) = odb.read(&entry.new_oid) {
7344            return Some(obj.data);
7345        }
7346    }
7347    let path = entry.new_path.as_deref()?;
7348    let root = work_root?;
7349    fs::read(root.join(path)).ok()
7350}
7351
7352fn modified_as_copy_from_sources(
7353    odb: &Odb,
7354    work_root: Option<&Path>,
7355    e: &DiffEntry,
7356    threshold: u32,
7357    sources: &[(String, ObjectId, bool)],
7358    source_contents: &[Option<Vec<u8>>],
7359    source_tree_entries: &[(String, FileMode, ObjectId)],
7360) -> Result<Option<DiffEntry>> {
7361    fn regular_file_mode(mode: FileMode) -> bool {
7362        mode == FileMode::REGULAR || mode == FileMode::EXECUTABLE
7363    }
7364
7365    if e.status != DiffStatus::Modified || !regular_file_mode(e.new_mode) {
7366        return Ok(None);
7367    }
7368    let Some(new_data) = read_added_entry_bytes(odb, e, work_root) else {
7369        return Ok(None);
7370    };
7371    let new_oid_eff = if e.new_oid != zero_oid() {
7372        e.new_oid
7373    } else {
7374        odb.hash(ObjectKind::Blob, &new_data)?
7375    };
7376
7377    let mut best: Option<(usize, u32)> = None;
7378    for (si, (src_path, src_oid, is_deleted)) in sources.iter().enumerate() {
7379        if *is_deleted {
7380            continue;
7381        }
7382        if e.new_path.as_deref() == Some(src_path.as_str()) {
7383            continue;
7384        }
7385        let src_mode = source_tree_entries
7386            .iter()
7387            .find(|(p, _, _)| p == src_path)
7388            .map_or(FileMode::REGULAR, |(_, m, _)| *m);
7389        if !regular_file_mode(src_mode) {
7390            continue;
7391        }
7392
7393        let score = if *src_oid == new_oid_eff {
7394            100
7395        } else {
7396            match (&source_contents[si], Some(new_data.as_slice())) {
7397                (Some(old_data), Some(nd)) => compute_similarity(old_data, nd),
7398                _ => 0,
7399            }
7400        };
7401        if score >= threshold {
7402            let replace = match best {
7403                None => true,
7404                Some((_, s)) => score > s,
7405            };
7406            if replace {
7407                best = Some((si, score));
7408            }
7409        }
7410    }
7411
7412    let Some((si, score)) = best else {
7413        return Ok(None);
7414    };
7415    let (src_path, src_oid, _) = &sources[si];
7416    let src_mode = source_tree_entries
7417        .iter()
7418        .find(|(p, _, _)| p == src_path)
7419        .map_or(e.old_mode, |(_, m, _)| *m);
7420
7421    Ok(Some(DiffEntry {
7422        status: DiffStatus::Copied,
7423        old_path: Some(src_path.clone()),
7424        new_path: e.new_path.clone(),
7425        old_mode: src_mode,
7426        new_mode: e.new_mode,
7427        old_oid: *src_oid,
7428        new_oid: e.new_oid,
7429        score: Some(score),
7430    }))
7431}
7432
7433/// Detect renames by pairing Deleted and Added entries with similar content.
7434///
7435/// `threshold` is the minimum similarity percentage (0–100) for a pair to
7436/// be considered a rename (Git's default is 50%).  The function reads blob
7437/// content from the ODB to compute a line-level similarity score.
7438///
7439/// Exact-OID matches are always 100% similar regardless of content.
7440///
7441/// When `work_root` is set, added entries whose `new_oid` is the zero placeholder (as in
7442/// uncached `diff-index` when the work tree diverged from the index) load content from disk
7443/// under that root instead of the object database.
7444#[allow(clippy::expect_used)]
7445pub fn detect_renames(
7446    odb: &Odb,
7447    work_root: Option<&Path>,
7448    entries: Vec<DiffEntry>,
7449    threshold: u32,
7450) -> Vec<DiffEntry> {
7451    let config = ConfigSet::new();
7452    let policy = ThreadPolicy::default();
7453    detect_renames_with_policy(odb, work_root, entries, threshold, &config, &policy, None)
7454        .expect("rename detection")
7455}
7456
7457#[inline]
7458fn poll_rename_cancel(
7459    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
7460) -> crate::error::Result<()> {
7461    if let Some(watch) = cancel_watch {
7462        crate::progress::poll_watch(watch)?;
7463    }
7464    Ok(())
7465}
7466
7467/// Like [`detect_renames`], but honors `index.threads` / `--threads` via `thread_policy`.
7468///
7469/// A list without both an added and a deleted entry has nothing to pair. When its paths
7470/// are also unique and in [`sort_entries_by_path`] order, as tree diffs return them, the
7471/// list comes back unchanged without reading blobs or allocating.
7472///
7473/// # Errors
7474///
7475/// Returns object database errors from reading candidate blobs, and the cancellation
7476/// error when `cancel_watch` is set.
7477pub fn detect_renames_with_policy(
7478    odb: &Odb,
7479    work_root: Option<&Path>,
7480    entries: Vec<DiffEntry>,
7481    threshold: u32,
7482    config: &ConfigSet,
7483    thread_policy: &ThreadPolicy,
7484    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
7485) -> Result<Vec<DiffEntry>> {
7486    poll_rename_cancel(cancel_watch)?;
7487    let has_deleted = entries.iter().any(|e| e.status == DiffStatus::Deleted);
7488    let has_added = entries.iter().any(|e| e.status == DiffStatus::Added);
7489    if (!has_deleted || !has_added) && entries_strictly_sorted_by_path(&entries) {
7490        // With unique sorted paths, regrouping and re-sorting would rebuild the same list.
7491        return Ok(entries);
7492    }
7493    // Split entries into deleted, added, and others.
7494    let mut deleted: Vec<DiffEntry> = Vec::new();
7495    let mut added: Vec<DiffEntry> = Vec::new();
7496    let mut others: Vec<DiffEntry> = Vec::new();
7497
7498    for entry in entries {
7499        match entry.status {
7500            DiffStatus::Deleted => deleted.push(entry),
7501            DiffStatus::Added => added.push(entry),
7502            _ => others.push(entry),
7503        }
7504    }
7505
7506    if deleted.is_empty() || added.is_empty() {
7507        // Nothing to pair — return original order.
7508        let mut result = others;
7509        result.extend(deleted);
7510        result.extend(added);
7511        sort_entries_by_path(&mut result);
7512        return Ok(result);
7513    }
7514
7515    fn is_regularish_mode(mode: FileMode) -> bool {
7516        mode == FileMode::REGULAR || mode == FileMode::EXECUTABLE
7517    }
7518
7519    fn same_path_same_blob(del: &DiffEntry, add: &DiffEntry) -> bool {
7520        del.old_path == add.new_path && del.old_oid == add.new_oid && del.old_mode == add.new_mode
7521    }
7522
7523    fn sort_rename_scores(
7524        scores: &mut [(u32, usize, usize)],
7525        deleted: &[DiffEntry],
7526        added: &[DiffEntry],
7527    ) {
7528        scores.sort_by(|a, b| {
7529            let a_noop = same_path_same_blob(&deleted[a.1], &added[a.2]);
7530            let b_noop = same_path_same_blob(&deleted[b.1], &added[b.2]);
7531            let a_same = same_basename(&deleted[a.1], &added[a.2]);
7532            let b_same = same_basename(&deleted[b.1], &added[b.2]);
7533            a_noop
7534                .cmp(&b_noop)
7535                .then_with(|| b_same.cmp(&a_same))
7536                .then_with(|| b.0.cmp(&a.0))
7537        });
7538    }
7539
7540    fn apply_rename_scores(
7541        scores: &[(u32, usize, usize)],
7542        deleted: &[DiffEntry],
7543        added: &[DiffEntry],
7544        used_deleted: &mut [bool],
7545        used_added: &mut [bool],
7546        renames: &mut Vec<DiffEntry>,
7547    ) {
7548        for (score, di, ai) in scores {
7549            if used_deleted[*di] || used_added[*ai] {
7550                continue;
7551            }
7552            used_deleted[*di] = true;
7553            used_added[*ai] = true;
7554
7555            let del = &deleted[*di];
7556            let add = &added[*ai];
7557            if same_path_same_blob(del, add) {
7558                continue;
7559            }
7560
7561            renames.push(DiffEntry {
7562                status: DiffStatus::Renamed,
7563                old_path: del.old_path.clone(),
7564                new_path: add.new_path.clone(),
7565                old_mode: del.old_mode,
7566                new_mode: add.new_mode,
7567                old_oid: del.old_oid,
7568                new_oid: add.new_oid,
7569                score: Some(*score),
7570            });
7571        }
7572    }
7573
7574    let mut used_deleted = vec![false; deleted.len()];
7575    let mut used_added = vec![false; added.len()];
7576    let mut renames: Vec<DiffEntry> = Vec::new();
7577
7578    // Git always runs exact rename detection, regardless of renameLimit.
7579    let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
7580    for (di, del) in deleted.iter().enumerate() {
7581        poll_rename_cancel(cancel_watch)?;
7582        for (ai, add) in added.iter().enumerate() {
7583            if del.old_oid == add.new_oid {
7584                exact_scores.push((100, di, ai));
7585            }
7586        }
7587    }
7588    sort_rename_scores(&mut exact_scores, &deleted, &added);
7589    apply_rename_scores(
7590        &exact_scores,
7591        &deleted,
7592        &added,
7593        &mut used_deleted,
7594        &mut used_added,
7595        &mut renames,
7596    );
7597
7598    let remaining_deleted: Vec<usize> = used_deleted
7599        .iter()
7600        .enumerate()
7601        .filter_map(|(i, used)| (!*used).then_some(i))
7602        .collect();
7603    let remaining_added: Vec<usize> = used_added
7604        .iter()
7605        .enumerate()
7606        .filter_map(|(i, used)| (!*used).then_some(i))
7607        .collect();
7608
7609    let rename_limit = parse_rename_limit(config);
7610    if !remaining_deleted.is_empty()
7611        && !remaining_added.is_empty()
7612        && !inexact_matrix_exceeds_limit(
7613            remaining_added.len(),
7614            remaining_deleted.len(),
7615            rename_limit,
7616        )
7617    {
7618        let par_opts = diff_rename_parallel_options(config, thread_policy);
7619
7620        let deleted_sizes: Vec<Option<usize>> = remaining_deleted
7621            .iter()
7622            .map(|&di| blob_payload_size(odb, &deleted[di].old_oid))
7623            .collect();
7624        let added_sizes: Vec<Option<usize>> = remaining_added
7625            .iter()
7626            .map(|&ai| added_entry_payload_size(odb, &added[ai], work_root))
7627            .collect();
7628
7629        let mut load_deleted = vec![false; remaining_deleted.len()];
7630        let mut load_added = vec![false; remaining_added.len()];
7631        let mut candidate_pairs: Vec<(usize, usize)> = Vec::new();
7632        for (row, &di) in remaining_deleted.iter().enumerate() {
7633            poll_rename_cancel(cancel_watch)?;
7634            let del = &deleted[di];
7635            if !is_regularish_mode(del.old_mode) {
7636                continue;
7637            }
7638            let Some(ds) = deleted_sizes[row] else {
7639                continue;
7640            };
7641            for (col, &ai) in remaining_added.iter().enumerate() {
7642                let add = &added[ai];
7643                if !is_regularish_mode(add.new_mode) {
7644                    continue;
7645                }
7646                let Some(asz) = added_sizes[col] else {
7647                    continue;
7648                };
7649                if rename_pair_size_reachable(ds, asz, threshold) {
7650                    load_deleted[row] = true;
7651                    load_added[col] = true;
7652                    candidate_pairs.push((row, col));
7653                }
7654            }
7655        }
7656
7657        if !candidate_pairs.is_empty() {
7658            let loads_needed = load_deleted.iter().filter(|&&b| b).count()
7659                + load_added.iter().filter(|&&b| b).count();
7660            let load_deleted_blob = |i: usize| -> Option<Vec<u8>> {
7661                if !load_deleted[i] {
7662                    return None;
7663                }
7664                odb.read(&deleted[remaining_deleted[i]].old_oid)
7665                    .ok()
7666                    .map(|obj| normalize_bytes_for_rename(obj.data))
7667            };
7668            let load_added_blob = |i: usize| -> Option<Vec<u8>> {
7669                if !load_added[i] {
7670                    return None;
7671                }
7672                read_added_entry_bytes(odb, &added[remaining_added[i]], work_root)
7673                    .map(normalize_bytes_for_rename)
7674            };
7675            let (deleted_contents, added_contents) = if loads_needed <= 32 {
7676                (
7677                    (0..remaining_deleted.len())
7678                        .map(load_deleted_blob)
7679                        .collect(),
7680                    (0..remaining_added.len()).map(load_added_blob).collect(),
7681                )
7682            } else {
7683                (
7684                    parallel_load_optional_blobs(
7685                        odb,
7686                        remaining_deleted.len(),
7687                        par_opts,
7688                        0,
7689                        load_deleted_blob,
7690                    ),
7691                    parallel_load_optional_blobs(
7692                        odb,
7693                        remaining_added.len(),
7694                        par_opts,
7695                        0,
7696                        load_added_blob,
7697                    ),
7698                )
7699            };
7700
7701            let est_table_bytes: usize = deleted_contents
7702                .iter()
7703                .chain(added_contents.iter())
7704                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
7705                .sum();
7706            let build_deleted_table = |row: usize| -> std::result::Result<
7707                Option<RenameLineTable>,
7708                std::convert::Infallible,
7709            > {
7710                Ok(deleted_contents[row]
7711                    .as_ref()
7712                    .map(|d| RenameLineTable::from_normalized_blob(d)))
7713            };
7714            let build_added_table = |col: usize| -> std::result::Result<
7715                Option<RenameLineTable>,
7716                std::convert::Infallible,
7717            > {
7718                Ok(added_contents[col]
7719                    .as_ref()
7720                    .map(|d| RenameLineTable::from_normalized_blob(d)))
7721            };
7722            let deleted_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
7723                (0..remaining_deleted.len())
7724                    .map(|row| build_deleted_table(row).unwrap())
7725                    .collect()
7726            } else {
7727                parallel_map_indices(
7728                    odb,
7729                    remaining_deleted.len(),
7730                    par_opts,
7731                    est_table_bytes,
7732                    build_deleted_table,
7733                )
7734                .expect("deleted rename line tables")
7735            };
7736            let added_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
7737                (0..remaining_added.len())
7738                    .map(|col| build_added_table(col).unwrap())
7739                    .collect()
7740            } else {
7741                parallel_map_indices(
7742                    odb,
7743                    remaining_added.len(),
7744                    par_opts,
7745                    est_table_bytes,
7746                    build_added_table,
7747                )
7748                .expect("added rename line tables")
7749            };
7750
7751            let mut inexact_scores: Vec<(u32, usize, usize)> = Vec::new();
7752            for (row, col) in candidate_pairs {
7753                poll_rename_cancel(cancel_watch)?;
7754                let di = remaining_deleted[row];
7755                let ai = remaining_added[col];
7756                let (Some(old_table), Some(new_table)) = (&deleted_tables[row], &added_tables[col])
7757                else {
7758                    continue;
7759                };
7760                let score = RenameLineTable::similarity_score(old_table, new_table);
7761                if score >= threshold {
7762                    inexact_scores.push((score, di, ai));
7763                }
7764            }
7765            sort_rename_scores(&mut inexact_scores, &deleted, &added);
7766            apply_rename_scores(
7767                &inexact_scores,
7768                &deleted,
7769                &added,
7770                &mut used_deleted,
7771                &mut used_added,
7772                &mut renames,
7773            );
7774        }
7775    }
7776
7777    // Collect unmatched entries.
7778    let mut result = others;
7779    result.extend(renames);
7780    for (i, entry) in deleted.into_iter().enumerate() {
7781        if !used_deleted[i] {
7782            result.push(entry);
7783        }
7784    }
7785    for (i, entry) in added.into_iter().enumerate() {
7786        if !used_added[i] {
7787            result.push(entry);
7788        }
7789    }
7790
7791    result = prune_break_deletions_obviated_by_renames(result);
7792    sort_entries_by_path(&mut result);
7793    Ok(result)
7794}
7795
7796/// Detect copies among diff entries.
7797///
7798/// This first runs rename detection (pairing Deleted+Added), then for any
7799/// remaining Added entries, looks for copy sources.
7800///
7801/// - `find_copies_harder` = false: only Modified entries are copy source candidates.
7802/// - `find_copies_harder` = true: also examine unmodified files from `source_tree_entries`.
7803///
7804/// `source_tree_entries` should be a list of (path, mode, oid) from the source tree;
7805/// used when `find_copies_harder` is true to consider unmodified files as copy sources.
7806/// # Errors
7807///
7808/// Returns [`Error::HashCollision`] when hashing worktree blob content for copy detection
7809/// would produce a known-bad SHA-1 object id.
7810pub fn detect_copies(
7811    odb: &Odb,
7812    work_root: Option<&Path>,
7813    entries: Vec<DiffEntry>,
7814    threshold: u32,
7815    find_copies_harder: bool,
7816    source_tree_entries: &[(String, FileMode, ObjectId)],
7817) -> Result<Vec<DiffEntry>> {
7818    let config = ConfigSet::new();
7819    let policy = ThreadPolicy::default();
7820    detect_copies_with_policy(
7821        odb,
7822        work_root,
7823        entries,
7824        threshold,
7825        find_copies_harder,
7826        source_tree_entries,
7827        &config,
7828        &policy,
7829    )
7830}
7831
7832/// Like [`detect_copies`], but honors parallel thread policy and rename limits.
7833///
7834/// # Errors
7835///
7836/// Same as [`detect_copies`].
7837#[expect(clippy::too_many_arguments)]
7838pub fn detect_copies_with_policy(
7839    odb: &Odb,
7840    work_root: Option<&Path>,
7841    entries: Vec<DiffEntry>,
7842    threshold: u32,
7843    find_copies_harder: bool,
7844    source_tree_entries: &[(String, FileMode, ObjectId)],
7845    config: &ConfigSet,
7846    thread_policy: &ThreadPolicy,
7847) -> Result<Vec<DiffEntry>> {
7848    use std::collections::{HashMap, HashSet};
7849
7850    // Separate entries by status.
7851    let mut deleted: Vec<DiffEntry> = Vec::new();
7852    let mut added: Vec<DiffEntry> = Vec::new();
7853    let mut others: Vec<DiffEntry> = Vec::new();
7854
7855    for entry in entries {
7856        match entry.status {
7857            DiffStatus::Deleted => deleted.push(entry),
7858            DiffStatus::Added => added.push(entry),
7859            _ => others.push(entry),
7860        }
7861    }
7862
7863    // Build source candidates: deleted files, modified files, and optionally tree entries.
7864    // Track which sources are from deleted files (can become renames).
7865    let mut sources: Vec<(String, ObjectId, bool)> = Vec::new(); // (path, oid, is_deleted)
7866    let mut deleted_source_idx: HashMap<String, usize> = HashMap::new();
7867
7868    for entry in &deleted {
7869        if let Some(ref path) = entry.old_path {
7870            deleted_source_idx.insert(path.clone(), sources.len());
7871            sources.push((path.clone(), entry.old_oid, true));
7872        }
7873    }
7874
7875    // Modified and type-changed files are candidates for `-C` (e.g. symlink rewrite leaves the
7876    // old blob available as a copy source for another path; see `t4008-diff-break-rewrite`).
7877    for entry in &others {
7878        if matches!(entry.status, DiffStatus::Modified | DiffStatus::TypeChanged) {
7879            if let Some(ref old_path) = entry.old_path {
7880                if !sources.iter().any(|(p, _, _)| p == old_path) {
7881                    sources.push((old_path.clone(), entry.old_oid, false));
7882                }
7883            }
7884        }
7885    }
7886
7887    // With find_copies_harder, add all source tree entries.
7888    if find_copies_harder {
7889        for (path, _mode, oid) in source_tree_entries {
7890            if !sources.iter().any(|(p, _, _)| p == path) {
7891                sources.push((path.clone(), *oid, false));
7892            }
7893        }
7894    }
7895
7896    // Modes of every source, for the old side of copy and rename entries: the deleted and
7897    // modified entries carry their own, and `source_tree_entries` the rest.
7898    let mut source_modes: Vec<(String, FileMode, ObjectId)> = source_tree_entries.to_vec();
7899    for entry in deleted.iter().chain(&others) {
7900        if let Some(ref path) = entry.old_path {
7901            if !source_modes.iter().any(|(p, _, _)| p == path) {
7902                source_modes.push((path.clone(), entry.old_mode, entry.old_oid));
7903            }
7904        }
7905    }
7906
7907    if sources.is_empty() {
7908        let mut result = others;
7909        result.extend(deleted);
7910        result.extend(added);
7911        sort_entries_by_path(&mut result);
7912        return Ok(result);
7913    }
7914
7915    let par_opts = diff_rename_parallel_options(config, thread_policy);
7916    let rename_limit = parse_rename_limit(config);
7917
7918    fn apply_copy_score_assignments(
7919        scores: &[(u32, usize, usize)],
7920        sources: &[(String, ObjectId, bool)],
7921        added: &[DiffEntry],
7922        source_tree_entries: &[(String, FileMode, ObjectId)],
7923        used_added2: &mut [bool],
7924        renamed_deleted: &mut HashSet<usize>,
7925        result_entries: &mut Vec<DiffEntry>,
7926    ) {
7927        let mut used_added = vec![false; added.len()];
7928        let mut source_to_added: HashMap<usize, Vec<(usize, u32)>> = HashMap::new();
7929        for &(score, si, ai) in scores {
7930            if used_added[ai] {
7931                continue;
7932            }
7933            used_added[ai] = true;
7934            source_to_added.entry(si).or_default().push((ai, score));
7935        }
7936
7937        for (&si, assignments_for_src) in &source_to_added {
7938            let (_, _, is_deleted) = &sources[si];
7939            if *is_deleted && !assignments_for_src.is_empty() {
7940                let rename_ai = assignments_for_src
7941                    .iter()
7942                    .max_by_key(|(ai, _score)| added[*ai].path().to_string())
7943                    .map(|(ai, _)| *ai);
7944
7945                for &(ai, score) in assignments_for_src {
7946                    let (ref src_path, _, _) = sources[si];
7947                    let add = &added[ai];
7948                    let src_mode = source_tree_entries
7949                        .iter()
7950                        .find(|(p, _, _)| p == src_path)
7951                        .map_or(add.old_mode, |(_, m, _)| *m);
7952
7953                    let is_rename = Some(ai) == rename_ai;
7954                    result_entries.push(DiffEntry {
7955                        status: if is_rename {
7956                            DiffStatus::Renamed
7957                        } else {
7958                            DiffStatus::Copied
7959                        },
7960                        old_path: Some(src_path.clone()),
7961                        new_path: add.new_path.clone(),
7962                        old_mode: src_mode,
7963                        new_mode: add.new_mode,
7964                        old_oid: sources[si].1,
7965                        new_oid: add.new_oid,
7966                        score: Some(score),
7967                    });
7968                    used_added2[ai] = true;
7969                }
7970                renamed_deleted.insert(si);
7971            } else {
7972                for &(ai, score) in assignments_for_src {
7973                    let (ref src_path, _, _) = sources[si];
7974                    let add = &added[ai];
7975                    let src_mode = source_tree_entries
7976                        .iter()
7977                        .find(|(p, _, _)| p == src_path)
7978                        .map_or(add.old_mode, |(_, m, _)| *m);
7979
7980                    result_entries.push(DiffEntry {
7981                        status: DiffStatus::Copied,
7982                        old_path: Some(src_path.clone()),
7983                        new_path: add.new_path.clone(),
7984                        old_mode: src_mode,
7985                        new_mode: add.new_mode,
7986                        old_oid: sources[si].1,
7987                        new_oid: add.new_oid,
7988                        score: Some(score),
7989                    });
7990                    used_added2[ai] = true;
7991                }
7992            }
7993        }
7994    }
7995
7996    fn added_entry_oid(
7997        odb: &Odb,
7998        add: &DiffEntry,
7999        added_contents: &[Option<Vec<u8>>],
8000        ai: usize,
8001    ) -> Result<ObjectId> {
8002        if add.new_oid != zero_oid() {
8003            Ok(add.new_oid)
8004        } else if let Some(ref data) = added_contents[ai] {
8005            odb.hash(ObjectKind::Blob, data)
8006        } else {
8007            Ok(zero_oid())
8008        }
8009    }
8010
8011    let mut result_entries: Vec<DiffEntry> = Vec::new();
8012    let mut renamed_deleted: HashSet<usize> = HashSet::new();
8013    let mut used_added2 = vec![false; added.len()];
8014    let mut source_contents = vec![None; sources.len()];
8015
8016    if !added.is_empty() {
8017        let added_contents = parallel_load_optional_blobs(odb, added.len(), par_opts, 0, |ai| {
8018            read_added_entry_bytes(odb, &added[ai], work_root)
8019        });
8020
8021        // Git always scores exact OID copy/rename pairs before applying renameLimit.
8022        let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
8023        for (si, (src_path, src_oid, _)) in sources.iter().enumerate() {
8024            for (ai, add) in added.iter().enumerate() {
8025                if add.new_path.as_deref() == Some(src_path.as_str()) {
8026                    continue;
8027                }
8028                let add_oid = added_entry_oid(odb, add, &added_contents, ai)?;
8029                if *src_oid == add_oid && add_oid != zero_oid() {
8030                    exact_scores.push((100, si, ai));
8031                }
8032            }
8033        }
8034        exact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
8035        apply_copy_score_assignments(
8036            &exact_scores,
8037            &sources,
8038            &added,
8039            &source_modes,
8040            &mut used_added2,
8041            &mut renamed_deleted,
8042            &mut result_entries,
8043        );
8044
8045        let remaining_added: Vec<usize> = used_added2
8046            .iter()
8047            .enumerate()
8048            .filter_map(|(i, used)| (!*used).then_some(i))
8049            .collect();
8050        let remaining_source_count = sources
8051            .iter()
8052            .enumerate()
8053            .filter(|(si, (_, _, is_deleted))| !*is_deleted || !renamed_deleted.contains(si))
8054            .count();
8055
8056        let skip_inexact = remaining_added.is_empty()
8057            || inexact_matrix_exceeds_limit(
8058                remaining_added.len(),
8059                remaining_source_count,
8060                rename_limit,
8061            );
8062
8063        if !skip_inexact {
8064            source_contents = parallel_load_optional_blobs(odb, sources.len(), par_opts, 0, |si| {
8065                odb.read(&sources[si].1).ok().map(|obj| obj.data)
8066            });
8067
8068            let est_bytes: usize = source_contents
8069                .iter()
8070                .chain(added_contents.iter())
8071                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
8072                .sum();
8073
8074            let source_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
8075                odb,
8076                sources.len(),
8077                par_opts,
8078                est_bytes,
8079                |si| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
8080                    Ok(source_contents[si]
8081                        .as_ref()
8082                        .map(|d| RenameLineTable::from_similarity_blob(d)))
8083                },
8084            )
8085            .expect("copy source line tables");
8086            let added_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
8087                odb,
8088                added.len(),
8089                par_opts,
8090                est_bytes,
8091                |ai| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
8092                    Ok(added_contents[ai]
8093                        .as_ref()
8094                        .map(|d| RenameLineTable::from_similarity_blob(d)))
8095                },
8096            )
8097            .expect("copy added line tables");
8098
8099            let score_rows: Vec<Vec<(u32, usize, usize)>> = parallel_map_indices(
8100                odb,
8101                sources.len(),
8102                par_opts,
8103                est_bytes,
8104                |si| -> std::result::Result<Vec<(u32, usize, usize)>, std::convert::Infallible> {
8105                    if renamed_deleted.contains(&si) {
8106                        return Ok(Vec::new());
8107                    }
8108                    let (src_path, _, _) = &sources[si];
8109                    let mut row: Vec<(u32, usize, usize)> = Vec::new();
8110                    for &ai in &remaining_added {
8111                        let add = &added[ai];
8112                        if add.new_path.as_deref() == Some(src_path.as_str()) {
8113                            continue;
8114                        }
8115                        let score = match (&source_tables[si], &added_tables[ai]) {
8116                            (Some(old_table), Some(new_table)) => {
8117                                RenameLineTable::similarity_score(old_table, new_table)
8118                            }
8119                            _ => 0,
8120                        };
8121                        if score >= threshold {
8122                            row.push((score, si, ai));
8123                        }
8124                    }
8125                    Ok(row)
8126                },
8127            )
8128            .expect("copy score rows");
8129            let mut inexact_scores: Vec<(u32, usize, usize)> =
8130                score_rows.into_iter().flatten().collect();
8131            inexact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
8132            apply_copy_score_assignments(
8133                &inexact_scores,
8134                &sources,
8135                &added,
8136                &source_modes,
8137                &mut used_added2,
8138                &mut renamed_deleted,
8139                &mut result_entries,
8140            );
8141        }
8142    }
8143
8144    // Keep deleted entries that weren't consumed by a rename.
8145    for entry in deleted.into_iter() {
8146        if let Some(ref path) = entry.old_path {
8147            if let Some(&si) = deleted_source_idx.get(path) {
8148                if renamed_deleted.contains(&si) {
8149                    // This deletion was consumed by a rename; skip it.
8150                    continue;
8151                }
8152            }
8153        }
8154        result_entries.push(entry);
8155    }
8156
8157    let mut result = others;
8158    result.extend(result_entries);
8159    // Keep unmatched added entries.
8160    for (i, entry) in added.into_iter().enumerate() {
8161        if !used_added2[i] {
8162            result.push(entry);
8163        }
8164    }
8165
8166    let mut final_result = Vec::with_capacity(result.len());
8167    for e in result {
8168        if let Some(c) = modified_as_copy_from_sources(
8169            odb,
8170            work_root,
8171            &e,
8172            threshold,
8173            &sources,
8174            &source_contents,
8175            &source_modes,
8176        )? {
8177            final_result.push(c);
8178        } else {
8179            final_result.push(e);
8180        }
8181    }
8182
8183    sort_entries_by_path(&mut final_result);
8184    Ok(final_result)
8185}
8186
8187/// Options for Git-style rename, copy, and break-rewrite post-processing on tree diffs.
8188#[derive(Debug, Clone, Copy)]
8189pub struct DiffPostprocessOptions {
8190    /// When set, `--break-rewrites` threshold on Git's internal 0–[`GIT_DIFF_MAX_SCORE`] scale
8191    /// (`None` = off).
8192    pub break_score: Option<u64>,
8193    /// `-M` similarity threshold (0–100); `100` disables rename detection.
8194    pub rename_threshold: u32,
8195    /// `-C` copy threshold (0–100); `None` skips copy detection.
8196    pub copy_threshold: Option<u32>,
8197    /// `-C --find-copies-harder`: treat unmodified source-tree paths as copy sources.
8198    pub find_copies_harder: bool,
8199    /// When break-rewrite runs on tree↔worktree diffs, fill `new_oid` from worktree bytes for
8200    /// `--raw` output (matches `git diff <tree> -B`; leave false for index↔worktree / diff-files).
8201    pub materialize_break_worktree_new_oid: bool,
8202}
8203
8204impl Default for DiffPostprocessOptions {
8205    fn default() -> Self {
8206        Self {
8207            break_score: None,
8208            rename_threshold: 100,
8209            copy_threshold: None,
8210            find_copies_harder: false,
8211            materialize_break_worktree_new_oid: false,
8212        }
8213    }
8214}
8215
8216/// Index and git directory used to read canonical (clean) worktree bytes during break-rewrite
8217/// post-processing on tree↔worktree diffs.
8218#[derive(Debug, Clone, Copy)]
8219pub struct DiffPostprocessWorktree<'a> {
8220    /// Git directory for config and attributes (linked worktrees / gitfiles).
8221    pub repository_git_dir: &'a Path,
8222    /// Current index; stage-0 entries supply autocrlf index-guard blobs when needed.
8223    pub index: &'a Index,
8224}
8225
8226fn blob_bytes(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
8227    Ok(odb.read(oid)?.data)
8228}
8229
8230/// Bytes on the "new" side of a diff entry (ODB blob, or worktree file when `new_oid` is null).
8231///
8232/// When `worktree` is set, worktree paths are normalized with the same attribute/filter rules as
8233/// [`diff_tree_to_worktree`]; otherwise raw filesystem bytes are read.
8234fn read_new_side_bytes(
8235    odb: &Odb,
8236    entry: &DiffEntry,
8237    work_root: Option<&Path>,
8238    worktree: Option<&DiffPostprocessWorktree<'_>>,
8239    config: &ConfigSet,
8240) -> Result<Vec<u8>> {
8241    if entry.new_oid != zero_oid() {
8242        return blob_bytes(odb, &entry.new_oid);
8243    }
8244    let path = entry.new_path.as_deref().ok_or_else(|| {
8245        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
8246    })?;
8247    let root = work_root.ok_or_else(|| {
8248        Error::ObjectNotFound(format!(
8249            "worktree bytes required for {} but no work_root was provided",
8250            path
8251        ))
8252    })?;
8253    if let Some(wctx) = worktree {
8254        let mode = entry.new_mode.bits();
8255        let index_entry = wctx.index.get(path.as_bytes(), 0);
8256        return read_worktree_side_bytes_for_diff(odb, root, path, mode, config, index_entry, None);
8257    }
8258    fs::read(root.join(path)).map_err(Error::Io)
8259}
8260
8261/// True when clean-smudged worktree bytes differ from the raw on-disk file (e.g. `eol=lf` + CRLF).
8262fn worktree_side_differs_after_normalize(
8263    odb: &Odb,
8264    entry: &DiffEntry,
8265    work_root: &Path,
8266    worktree: &DiffPostprocessWorktree<'_>,
8267    config: &ConfigSet,
8268) -> Result<bool> {
8269    let path = entry.new_path.as_deref().ok_or_else(|| {
8270        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
8271    })?;
8272    let raw = fs::read(work_root.join(path)).map_err(Error::Io)?;
8273    let canonical = read_new_side_bytes(odb, entry, Some(work_root), Some(worktree), config)?;
8274    Ok(raw != canonical)
8275}
8276
8277fn split_broken_modifications(
8278    odb: &Odb,
8279    work_root: Option<&Path>,
8280    entries: Vec<DiffEntry>,
8281    break_score: u64,
8282    worktree: Option<&DiffPostprocessWorktree<'_>>,
8283    config: &ConfigSet,
8284) -> Result<Vec<DiffEntry>> {
8285    let mut out = Vec::with_capacity(entries.len());
8286    for e in entries {
8287        if e.status != DiffStatus::Modified {
8288            out.push(e);
8289            continue;
8290        }
8291        let old = blob_bytes(odb, &e.old_oid)?;
8292        let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
8293        if should_break_rewrite_pair(&old, &new, break_score) {
8294            let path = e.path().to_owned();
8295            out.push(DiffEntry {
8296                status: DiffStatus::Deleted,
8297                old_path: Some(path.clone()),
8298                new_path: None,
8299                old_mode: e.old_mode,
8300                new_mode: FileMode::ABSENT,
8301                old_oid: e.old_oid,
8302                new_oid: zero_oid(),
8303                score: None,
8304            });
8305            out.push(DiffEntry {
8306                status: DiffStatus::Added,
8307                old_path: None,
8308                new_path: Some(path),
8309                old_mode: FileMode::ABSENT,
8310                new_mode: e.new_mode,
8311                old_oid: zero_oid(),
8312                new_oid: e.new_oid,
8313                score: None,
8314            });
8315        } else {
8316            out.push(e);
8317        }
8318    }
8319    Ok(out)
8320}
8321
8322fn annotate_broken_modifications(
8323    odb: &Odb,
8324    work_root: Option<&Path>,
8325    entries: Vec<DiffEntry>,
8326    break_score: u64,
8327    worktree: Option<&DiffPostprocessWorktree<'_>>,
8328    config: &ConfigSet,
8329    materialize_worktree_oid: bool,
8330) -> Result<Vec<DiffEntry>> {
8331    let mut out = Vec::with_capacity(entries.len());
8332    for mut e in entries {
8333        if e.status == DiffStatus::Modified {
8334            let old = blob_bytes(odb, &e.old_oid)?;
8335            let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
8336            if should_break_rewrite_pair(&old, &new, break_score) {
8337                // `git diff-files -B` keeps plain `M` when the worktree side is still null.
8338                // `git diff <tree> -B` materializes the clean OID; `M###` appears only when
8339                // attributes/filters changed the bytes used for break scoring (e.g. `eol=lf`).
8340                let show_score = if e.new_oid != zero_oid() {
8341                    true
8342                } else if materialize_worktree_oid {
8343                    match (work_root, worktree) {
8344                        (Some(root), Some(wctx)) => {
8345                            worktree_side_differs_after_normalize(odb, &e, root, wctx, config)?
8346                        }
8347                        _ => false,
8348                    }
8349                } else {
8350                    false
8351                };
8352                if show_score {
8353                    e.score = rewrite_dissimilarity_index_percent(&old, &new);
8354                }
8355            }
8356        }
8357        out.push(e);
8358    }
8359    Ok(out)
8360}
8361
8362fn materialize_break_worktree_new_oids(
8363    odb: &Odb,
8364    work_root: Option<&Path>,
8365    entries: &mut [DiffEntry],
8366    worktree: Option<&DiffPostprocessWorktree<'_>>,
8367    config: &ConfigSet,
8368) -> Result<()> {
8369    for e in entries {
8370        if e.new_oid != zero_oid() {
8371            continue;
8372        }
8373        if !matches!(e.status, DiffStatus::Modified | DiffStatus::Added) {
8374            continue;
8375        }
8376        let bytes = read_new_side_bytes(odb, e, work_root, worktree, config)?;
8377        e.new_oid = odb.hash(ObjectKind::Blob, &bytes)?;
8378    }
8379    Ok(())
8380}
8381
8382/// After break-rewrite splits a modified path into delete+add, rename detection may pair the add
8383/// with another deletion (`g` → `f`). Git drops the unused delete at the rename target path.
8384fn prune_break_deletions_obviated_by_renames(entries: Vec<DiffEntry>) -> Vec<DiffEntry> {
8385    use std::collections::HashSet;
8386    let rename_targets: HashSet<String> = entries
8387        .iter()
8388        .filter(|e| e.status == DiffStatus::Renamed)
8389        .filter_map(|e| e.new_path.clone())
8390        .collect();
8391    if rename_targets.is_empty() {
8392        return entries;
8393    }
8394    entries
8395        .into_iter()
8396        .filter(|e| {
8397            if e.status != DiffStatus::Deleted {
8398                return true;
8399            }
8400            e.old_path
8401                .as_ref()
8402                .is_none_or(|p| !rename_targets.contains(p))
8403        })
8404        .collect()
8405}
8406
8407/// Apply break-rewrite, rename, and copy detection to a flat tree or index diff.
8408///
8409/// `source_tree_oid` supplies `--find-copies-harder` candidates; `work_root` is used when added
8410/// entries carry a zero OID (index↔worktree). Pass `worktree` when break-rewrite runs on a
8411/// tree↔worktree diff so worktree-side bytes honor attributes and filters.
8412///
8413/// # Errors
8414///
8415/// Propagates object read failures while evaluating break scores.
8416#[allow(clippy::too_many_arguments)]
8417pub fn postprocess_diff_entries(
8418    odb: &Odb,
8419    entries: Vec<DiffEntry>,
8420    source_tree_oid: Option<&ObjectId>,
8421    work_root: Option<&Path>,
8422    worktree: Option<&DiffPostprocessWorktree<'_>>,
8423    config: &ConfigSet,
8424    thread_policy: &ThreadPolicy,
8425    opts: &DiffPostprocessOptions,
8426) -> Result<Vec<DiffEntry>> {
8427    let mut entries = entries;
8428    if let Some(break_score) = opts.break_score {
8429        let rename_or_copy = opts.rename_threshold < 100 || opts.copy_threshold.is_some();
8430        entries = if rename_or_copy {
8431            split_broken_modifications(odb, work_root, entries, break_score, worktree, config)?
8432        } else {
8433            annotate_broken_modifications(
8434                odb,
8435                work_root,
8436                entries,
8437                break_score,
8438                worktree,
8439                config,
8440                opts.materialize_break_worktree_new_oid,
8441            )?
8442        };
8443        if opts.materialize_break_worktree_new_oid {
8444            materialize_break_worktree_new_oids(odb, work_root, &mut entries, worktree, config)?;
8445        }
8446    }
8447
8448    let source_tree_entries = source_tree_oid
8449        .map(|t| flatten_tree_entries(odb, t))
8450        .transpose()?
8451        .unwrap_or_default();
8452
8453    if opts.copy_threshold.is_some() || opts.find_copies_harder {
8454        let threshold = opts.copy_threshold.unwrap_or(opts.rename_threshold.min(99));
8455        detect_copies_with_policy(
8456            odb,
8457            work_root,
8458            entries,
8459            threshold,
8460            opts.find_copies_harder,
8461            &source_tree_entries,
8462            config,
8463            thread_policy,
8464        )
8465    } else if opts.rename_threshold < 100 {
8466        detect_renames_with_policy(
8467            odb,
8468            work_root,
8469            entries,
8470            opts.rename_threshold,
8471            config,
8472            thread_policy,
8473            None,
8474        )
8475    } else {
8476        Ok(entries)
8477    }
8478}
8479
8480/// Depth-first list of `(path, mode, oid)` blob entries in a tree (for copy detection).
8481pub fn flatten_tree_entries(
8482    odb: &Odb,
8483    tree_oid: &ObjectId,
8484) -> Result<Vec<(String, FileMode, ObjectId)>> {
8485    fn walk(
8486        odb: &Odb,
8487        tree_oid: &ObjectId,
8488        prefix: &str,
8489        out: &mut Vec<(String, FileMode, ObjectId)>,
8490    ) -> Result<()> {
8491        let obj = odb.read(tree_oid)?;
8492        let oid_len = odb.hash_algo().len();
8493        for e in TreeRefIter::with_oid_len(&obj.data, oid_len) {
8494            let e = e?;
8495            let name = String::from_utf8_lossy(e.name);
8496            let path = if prefix.is_empty() {
8497                name.to_string()
8498            } else {
8499                format!("{prefix}/{name}")
8500            };
8501            if e.mode == 0o040000 {
8502                walk(odb, &e.oid, &path, out)?;
8503            } else {
8504                out.push((path, FileMode::new(e.mode), e.oid));
8505            }
8506        }
8507        Ok(())
8508    }
8509    let mut out = Vec::new();
8510    walk(odb, tree_oid, "", &mut out)?;
8511    Ok(out)
8512}
8513
8514/// Apply Git-style rename and optional copy detection for index↔worktree diffs.
8515///
8516/// When `copies` is true (Git `diff.renames` / `status.renames` set to `copy`/`copies`),
8517/// runs copy detection, which also pairs deleted sources with one rename and any additional
8518/// destinations as copies.
8519///
8520/// # Errors
8521///
8522/// Propagates errors from reading the `head_tree` object from `odb`.
8523#[allow(clippy::too_many_arguments)]
8524pub fn status_apply_rename_copy_detection(
8525    odb: &Odb,
8526    unstaged_raw: Vec<DiffEntry>,
8527    threshold: u32,
8528    copies: bool,
8529    head_tree: Option<&ObjectId>,
8530    config: &ConfigSet,
8531    thread_policy: &ThreadPolicy,
8532    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
8533) -> Result<Vec<DiffEntry>> {
8534    if !copies {
8535        return detect_renames_with_policy(
8536            odb,
8537            None,
8538            unstaged_raw,
8539            threshold,
8540            config,
8541            thread_policy,
8542            cancel_watch,
8543        );
8544    }
8545    let source_tree_entries: Vec<(String, FileMode, ObjectId)> = match head_tree {
8546        Some(oid) => flatten_tree(odb, oid, "")?
8547            .into_iter()
8548            .map(|e| (e.path, FileMode::new(e.mode), e.oid))
8549            .collect(),
8550        None => Vec::new(),
8551    };
8552    detect_copies_with_policy(
8553        odb,
8554        None,
8555        unstaged_raw,
8556        threshold,
8557        false,
8558        &source_tree_entries,
8559        config,
8560        thread_policy,
8561    )
8562}
8563
8564/// Format a rename pair using Git's compact path format.
8565///
8566/// Examples:
8567/// - `a/b/c` → `c/b/a` → `a/b/c => c/b/a`
8568/// - `c/b/a` → `c/d/e` → `c/{b/a => d/e}`
8569/// - `c/d/e` → `d/e` → `{c/d => d}/e`
8570/// - `d/e` → `d/f/e` → `d/{ => f}/e`
8571pub fn format_rename_path(old: &str, new: &str) -> String {
8572    let ob = old.as_bytes();
8573    let nb = new.as_bytes();
8574
8575    // Find common prefix length, snapped to '/' boundary.
8576    let pfx = {
8577        let mut last_sep = 0usize;
8578        let min_len = ob.len().min(nb.len());
8579        for i in 0..min_len {
8580            if ob[i] != nb[i] {
8581                break;
8582            }
8583            if ob[i] == b'/' {
8584                last_sep = i + 1;
8585            }
8586        }
8587        last_sep
8588    };
8589
8590    // Find common suffix length, snapped to '/' boundary.
8591    let mut sfx = {
8592        let mut last_sep = 0usize;
8593        let min_len = ob.len().min(nb.len());
8594        for i in 0..min_len {
8595            let oi = ob.len() - 1 - i;
8596            let ni = nb.len() - 1 - i;
8597            if ob[oi] != nb[ni] {
8598                break;
8599            }
8600            if ob[oi] == b'/' {
8601                last_sep = i + 1;
8602            }
8603        }
8604        last_sep
8605    };
8606
8607    // Suffix starts at this position in each string.
8608    let mut sfx_at_old = ob.len() - sfx;
8609    let mut sfx_at_new = nb.len() - sfx;
8610
8611    // If prefix and suffix overlap in both strings (both middles empty),
8612    // reduce the suffix so that at least the longer string has a non-empty middle.
8613    while pfx > sfx_at_old && pfx > sfx_at_new && sfx > 0 {
8614        // Reduce suffix by snapping to the next smaller '/' boundary.
8615        let suffix_bytes = &ob[sfx_at_old..];
8616        let mut new_sfx = 0;
8617        // Find the next '/' after sfx_at_old (i.e., reduce suffix).
8618        for (i, &b) in suffix_bytes.iter().enumerate().skip(1) {
8619            if b == b'/' {
8620                new_sfx = sfx - i;
8621                break;
8622            }
8623        }
8624        if new_sfx == 0 || new_sfx >= sfx {
8625            sfx_at_old = ob.len();
8626            sfx_at_new = nb.len();
8627            break;
8628        }
8629        sfx = new_sfx;
8630        sfx_at_old = ob.len() - sfx;
8631        sfx_at_new = nb.len() - sfx;
8632    }
8633
8634    // When prefix and suffix overlap in the shorter string, they share
8635    // the '/' boundary character. In the output format, the shared '/'
8636    // appears in both positions (e.g. "d/{ => f}/e" for d/e → d/f/e).
8637    // Compute the middle parts. When prefix and suffix overlap in a
8638    // string, the middle for that string is empty. The shared '/' shows
8639    // in both prefix (trailing) and suffix (leading) positions.
8640    let prefix = &old[..pfx];
8641    let suffix = &old[sfx_at_old..];
8642    let old_mid = if pfx <= sfx_at_old {
8643        &old[pfx..sfx_at_old]
8644    } else {
8645        ""
8646    };
8647    let new_mid = if pfx <= sfx_at_new {
8648        &new[pfx..sfx_at_new]
8649    } else {
8650        ""
8651    };
8652
8653    if prefix.is_empty() && suffix.is_empty() {
8654        return format!("{old} => {new}");
8655    }
8656
8657    format!("{prefix}{{{old_mid} => {new_mid}}}{suffix}")
8658}
8659
8660/// Check if two entries share the same filename (basename).
8661fn same_basename(del: &DiffEntry, add: &DiffEntry) -> bool {
8662    let old = del.old_path.as_deref().unwrap_or("");
8663    let new = add.new_path.as_deref().unwrap_or("");
8664    let old_base = old.rsplit('/').next().unwrap_or(old);
8665    let new_base = new.rsplit('/').next().unwrap_or(new);
8666    old_base == new_base && !old_base.is_empty()
8667}
8668
8669const RENAME_SCORE_MAX: u32 = 100;
8670
8671/// Normalize blob bytes for rename similarity (skip allocation when already LF-only).
8672fn normalize_bytes_for_rename(data: Vec<u8>) -> Vec<u8> {
8673    if data.contains(&b'\r') {
8674        crate::crlf::crlf_to_lf(&data)
8675    } else {
8676        data
8677    }
8678}
8679
8680/// Uncompressed blob size without inflating object bodies (for rename pre-filtering).
8681fn blob_payload_size(odb: &Odb, oid: &ObjectId) -> Option<usize> {
8682    if oid.is_zero() {
8683        return None;
8684    }
8685    odb.read_info(oid).ok().map(|info| info.size as usize)
8686}
8687
8688/// Payload size for an added diff side (ODB blob or worktree bytes when not stored yet).
8689fn added_entry_payload_size(odb: &Odb, add: &DiffEntry, work_root: Option<&Path>) -> Option<usize> {
8690    if !add.new_oid.is_zero() {
8691        if let Some(size) = blob_payload_size(odb, &add.new_oid) {
8692            return Some(size);
8693        }
8694    }
8695    read_added_entry_bytes(odb, add, work_root).map(|data| data.len())
8696}
8697
8698/// Git `diffcore-delta` size gate: skip pairs that cannot reach `minimum_score`.
8699fn rename_pair_size_reachable(src_size: usize, dst_size: usize, minimum_score: u32) -> bool {
8700    let max_size = src_size.max(dst_size);
8701    let min_size = src_size.min(dst_size);
8702    if max_size == 0 {
8703        return minimum_score <= RENAME_SCORE_MAX;
8704    }
8705    (max_size - min_size).saturating_mul(RENAME_SCORE_MAX as usize)
8706        <= max_size.saturating_mul((RENAME_SCORE_MAX - minimum_score) as usize)
8707}
8708
8709/// Compute a similarity percentage (0–100) between two byte slices.
8710///
8711/// Matches Git rename detection: line-hash intersection over CRLF-normalized bodies,
8712/// `score = copied_bytes * 100 / max(src_size, dst_size)`.
8713fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
8714    RenameLineTable::similarity_score(
8715        &RenameLineTable::from_similarity_blob(old),
8716        &RenameLineTable::from_similarity_blob(new),
8717    )
8718}
8719
8720/// Compute rename/copy similarity percentage (0–100) between two byte slices.
8721///
8722/// This uses the same scoring logic as internal rename detection.
8723#[must_use]
8724pub fn rename_similarity_score(old: &[u8], new: &[u8]) -> u32 {
8725    compute_similarity(old, new)
8726}
8727
8728// ── Output formatting ───────────────────────────────────────────────
8729
8730/// Format a diff entry in Git's raw diff format.
8731///
8732/// Example: `:100644 100644 abc1234... def5678... M\tfile.txt`
8733pub fn format_raw(entry: &DiffEntry) -> String {
8734    let path = match entry.status {
8735        DiffStatus::Renamed | DiffStatus::Copied => {
8736            format!(
8737                "{}\t{}",
8738                entry.old_path.as_deref().unwrap_or(""),
8739                entry.new_path.as_deref().unwrap_or("")
8740            )
8741        }
8742        _ => entry.path().to_owned(),
8743    };
8744
8745    let status_str = match (entry.status, entry.score) {
8746        (DiffStatus::Renamed, Some(s)) => format!("R{:03}", s),
8747        (DiffStatus::Copied, Some(s)) => format!("C{:03}", s),
8748        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
8749        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
8750        _ => entry.status.letter().to_string(),
8751    };
8752
8753    let (old_hex, new_hex) = raw_oid_hex_pair(&entry.old_oid, &entry.new_oid);
8754    format!(
8755        ":{} {} {} {} {}\t{}",
8756        entry.old_mode, entry.new_mode, old_hex, new_hex, status_str, path
8757    )
8758}
8759
8760/// Render a diff entry's `(old, new)` OIDs as full hex for `--raw` output,
8761/// widening any null OID to the repository's hash width.
8762///
8763/// Diff entries store null OIDs at SHA-1 width regardless of the repository
8764/// algorithm, but `git diff --raw` prints them at the real hash width (64
8765/// zeros in a SHA-256 repo). A diff entry never has both sides null, so the
8766/// width is taken from whichever side carries a real object.
8767fn raw_oid_hex_pair(old: &ObjectId, new: &ObjectId) -> (String, String) {
8768    let width = if !old.is_zero() {
8769        old.algo().hex_len()
8770    } else if !new.is_zero() {
8771        new.algo().hex_len()
8772    } else {
8773        old.algo().hex_len()
8774    };
8775    let render = |oid: &ObjectId| {
8776        if oid.is_zero() {
8777            "0".repeat(width)
8778        } else {
8779            oid.to_hex()
8780        }
8781    };
8782    (render(old), render(new))
8783}
8784
8785/// Format a diff entry with abbreviated OIDs.
8786pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
8787    let ellipsis = if crate::environment::Environment::empty()
8788        .var("GIT_PRINT_SHA1_ELLIPSIS")
8789        .as_deref()
8790        == Some("yes")
8791    {
8792        "..."
8793    } else {
8794        ""
8795    };
8796    let old_hex = format!("{}", entry.old_oid);
8797    let new_hex = format!("{}", entry.new_oid);
8798    let old_abbrev = &old_hex[..abbrev_len.min(old_hex.len())];
8799    let new_abbrev = &new_hex[..abbrev_len.min(new_hex.len())];
8800
8801    // Renames/copies carry a similarity score and a `<old>\t<new>` path pair.
8802    let path = match entry.status {
8803        DiffStatus::Renamed | DiffStatus::Copied => format!(
8804            "{}\t{}",
8805            entry.old_path.as_deref().unwrap_or(""),
8806            entry.new_path.as_deref().unwrap_or("")
8807        ),
8808        _ => entry.path().to_owned(),
8809    };
8810    let status_str = match (entry.status, entry.score) {
8811        (DiffStatus::Renamed, Some(s)) => format!("R{s:03}"),
8812        (DiffStatus::Copied, Some(s)) => format!("C{s:03}"),
8813        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
8814        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
8815        _ => entry.status.letter().to_string(),
8816    };
8817
8818    format!(
8819        ":{} {} {}{} {}{} {}\t{}",
8820        entry.old_mode,
8821        entry.new_mode,
8822        old_abbrev,
8823        ellipsis,
8824        new_abbrev,
8825        ellipsis,
8826        status_str,
8827        path
8828    )
8829}
8830
8831/// Generate a unified diff patch for two blobs.
8832///
8833/// # Parameters
8834///
8835/// - `old_content` — the old file content (empty for added files).
8836/// - `new_content` — the new file content (empty for deleted files).
8837/// - `old_path` — display path for the old side.
8838/// - `new_path` — display path for the new side.
8839/// - `context_lines` — number of context lines around changes (default: 3).
8840/// - Inter-hunk context defaults to `0` (see [`unified_diff_with_prefix`]).
8841///
8842/// # Returns
8843///
8844/// The unified diff as a string.
8845pub fn unified_diff(
8846    old_content: &str,
8847    new_content: &str,
8848    old_path: &str,
8849    new_path: &str,
8850    context_lines: usize,
8851    indent_heuristic: bool,
8852    quote_path_fully: bool,
8853) -> String {
8854    unified_diff_with_prefix(
8855        old_content,
8856        new_content,
8857        old_path,
8858        new_path,
8859        context_lines,
8860        0,
8861        "a/",
8862        "b/",
8863        indent_heuristic,
8864        quote_path_fully,
8865    )
8866}
8867
8868/// Same as `unified_diff` but with configurable source/destination prefixes.
8869///
8870/// `inter_hunk_context` is Git's `--inter-hunk-context`: adjacent hunks merge when
8871/// the unchanged gap between them is at most `2 * context_lines + inter_hunk_context` lines.
8872#[allow(clippy::too_many_arguments)] // Mirrors Git-style unified diff parameters.
8873pub fn unified_diff_with_prefix(
8874    old_content: &str,
8875    new_content: &str,
8876    old_path: &str,
8877    new_path: &str,
8878    context_lines: usize,
8879    inter_hunk_context: usize,
8880    src_prefix: &str,
8881    dst_prefix: &str,
8882    indent_heuristic: bool,
8883    quote_path_fully: bool,
8884) -> String {
8885    unified_diff_with_prefix_and_funcname(
8886        old_content,
8887        new_content,
8888        old_path,
8889        new_path,
8890        context_lines,
8891        inter_hunk_context,
8892        src_prefix,
8893        dst_prefix,
8894        None,
8895        indent_heuristic,
8896        quote_path_fully,
8897    )
8898}
8899
8900/// Same as [`unified_diff_with_prefix`] with optional custom hunk-header
8901/// function-name matching.
8902#[allow(clippy::too_many_arguments)]
8903pub fn unified_diff_with_prefix_and_funcname(
8904    old_content: &str,
8905    new_content: &str,
8906    old_path: &str,
8907    new_path: &str,
8908    context_lines: usize,
8909    inter_hunk_context: usize,
8910    src_prefix: &str,
8911    dst_prefix: &str,
8912    funcname_matcher: Option<&FuncnameMatcher>,
8913    indent_heuristic: bool,
8914    quote_path_fully: bool,
8915) -> String {
8916    unified_diff_with_prefix_and_funcname_and_algorithm(
8917        old_content,
8918        new_content,
8919        old_path,
8920        new_path,
8921        context_lines,
8922        inter_hunk_context,
8923        src_prefix,
8924        dst_prefix,
8925        funcname_matcher,
8926        similar::Algorithm::Myers,
8927        false,
8928        false,
8929        indent_heuristic,
8930        quote_path_fully,
8931    )
8932}
8933
8934/// Same as [`unified_diff_with_prefix_and_funcname_and_algorithm`], streaming to `out`.
8935///
8936/// # Errors
8937///
8938/// Returns I/O errors from `out`.
8939#[allow(clippy::too_many_arguments)]
8940pub fn write_unified_diff_with_prefix_and_funcname_and_algorithm<W: std::io::Write>(
8941    out: &mut W,
8942    old_content: &str,
8943    new_content: &str,
8944    old_path: &str,
8945    new_path: &str,
8946    context_lines: usize,
8947    inter_hunk_context: usize,
8948    src_prefix: &str,
8949    dst_prefix: &str,
8950    funcname_matcher: Option<&FuncnameMatcher>,
8951    algorithm: similar::Algorithm,
8952    function_context: bool,
8953    use_git_histogram: bool,
8954    indent_heuristic: bool,
8955    quote_path_fully: bool,
8956) -> Result<()> {
8957    if function_context {
8958        let patch = unified_diff_with_function_context(
8959            old_content,
8960            new_content,
8961            old_path,
8962            new_path,
8963            context_lines,
8964            inter_hunk_context,
8965            src_prefix,
8966            dst_prefix,
8967            funcname_matcher,
8968            algorithm,
8969            indent_heuristic,
8970            quote_path_fully,
8971        );
8972        out.write_all(patch.as_bytes())?;
8973        return Ok(());
8974    }
8975
8976    if use_git_histogram {
8977        let patch = unified_diff_histogram_with_prefix_and_funcname_indent(
8978            old_content,
8979            new_content,
8980            old_path,
8981            new_path,
8982            context_lines,
8983            inter_hunk_context,
8984            src_prefix,
8985            dst_prefix,
8986            funcname_matcher,
8987            indent_heuristic,
8988            quote_path_fully,
8989        );
8990        out.write_all(patch.as_bytes())?;
8991        return Ok(());
8992    }
8993
8994    if algorithm == similar::Algorithm::Myers && !function_context {
8995        let patch = unified_diff_git_myers_with_prefix_and_funcname(
8996            old_content,
8997            new_content,
8998            old_path,
8999            new_path,
9000            context_lines,
9001            inter_hunk_context,
9002            src_prefix,
9003            dst_prefix,
9004            funcname_matcher,
9005            indent_heuristic,
9006            quote_path_fully,
9007        );
9008        out.write_all(patch.as_bytes())?;
9009        return Ok(());
9010    }
9011
9012    if algorithm == similar::Algorithm::Lcs && !function_context {
9013        let patch = unified_diff_git_minimal_with_prefix_and_funcname(
9014            old_content,
9015            new_content,
9016            old_path,
9017            new_path,
9018            context_lines,
9019            inter_hunk_context,
9020            src_prefix,
9021            dst_prefix,
9022            funcname_matcher,
9023            indent_heuristic,
9024            quote_path_fully,
9025        );
9026        out.write_all(patch.as_bytes())?;
9027        return Ok(());
9028    }
9029
9030    if algorithm == similar::Algorithm::Patience && !function_context {
9031        let patch = unified_diff_git_patience_with_prefix_and_funcname(
9032            old_content,
9033            new_content,
9034            old_path,
9035            new_path,
9036            context_lines,
9037            inter_hunk_context,
9038            src_prefix,
9039            dst_prefix,
9040            funcname_matcher,
9041            indent_heuristic,
9042            quote_path_fully,
9043        );
9044        out.write_all(patch.as_bytes())?;
9045        return Ok(());
9046    }
9047
9048    use crate::quote_path::format_diff_path_with_prefix;
9049    use similar::{udiff::UnifiedDiffHunk, TextDiff};
9050
9051    let diff = TextDiff::configure()
9052        .algorithm(algorithm)
9053        .diff_lines(old_content, new_content);
9054    let compacted_ops = diff_indent_heuristic::diff_lines_ops_compacted(
9055        old_content,
9056        new_content,
9057        algorithm,
9058        indent_heuristic,
9059    );
9060
9061    if old_path == "/dev/null" {
9062        out.write_all(b"--- /dev/null\n")?;
9063    } else if src_prefix.is_empty() {
9064        writeln!(out, "--- {old_path}")?;
9065    } else {
9066        out.write_all(b"--- ")?;
9067        out.write_all(
9068            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
9069        )?;
9070        out.write_all(b"\n")?;
9071    }
9072    if new_path == "/dev/null" {
9073        out.write_all(b"+++ /dev/null\n")?;
9074    } else if dst_prefix.is_empty() {
9075        writeln!(out, "+++ {new_path}")?;
9076    } else {
9077        out.write_all(b"+++ ")?;
9078        out.write_all(
9079            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
9080        )?;
9081        out.write_all(b"\n")?;
9082    }
9083
9084    let old_lines: Vec<&str> = old_content.lines().collect();
9085    let max_common_gap = context_lines
9086        .saturating_mul(2)
9087        .saturating_add(inter_hunk_context);
9088    let op_groups = group_diff_ops_gap(compacted_ops, context_lines, max_common_gap);
9089
9090    let mut hunk_buf = String::new();
9091    let mut eof_annot = UnifiedDiffNoNewlineAnnotator::new(old_content, new_content);
9092    for ops in op_groups {
9093        if ops.is_empty() {
9094            continue;
9095        }
9096        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
9097        hunk_buf.clear();
9098        use std::fmt::Write as FmtWrite;
9099        let _ = FmtWrite::write_fmt(&mut hunk_buf, format_args!("{hunk}"));
9100        let mut fragment = hunk_buf.clone();
9101        if let Some(first_newline) = hunk_buf.find('\n') {
9102            let header_line = &hunk_buf[..first_newline];
9103            let rest = &hunk_buf[first_newline..];
9104            if let Some(func_ctx) =
9105                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
9106            {
9107                fragment.clear();
9108                fragment.push_str(header_line);
9109                fragment.push(' ');
9110                fragment.push_str(&func_ctx);
9111                fragment.push_str(rest);
9112            }
9113        }
9114        eof_annot.write_hunk(out, &fragment)?;
9115    }
9116    Ok(())
9117}
9118
9119/// Same as [`unified_diff_with_prefix_and_funcname`] but allows callers to
9120/// choose the line diff algorithm used for hunk generation.
9121///
9122/// When `function_context` is true (`git diff -W`), hunks are expanded to
9123/// whole logical functions using the same rules as Git's `XDL_EMIT_FUNCCONTEXT`.
9124#[allow(clippy::too_many_arguments)]
9125pub fn unified_diff_with_prefix_and_funcname_and_algorithm(
9126    old_content: &str,
9127    new_content: &str,
9128    old_path: &str,
9129    new_path: &str,
9130    context_lines: usize,
9131    inter_hunk_context: usize,
9132    src_prefix: &str,
9133    dst_prefix: &str,
9134    funcname_matcher: Option<&FuncnameMatcher>,
9135    algorithm: similar::Algorithm,
9136    function_context: bool,
9137    use_git_histogram: bool,
9138    indent_heuristic: bool,
9139    quote_path_fully: bool,
9140) -> String {
9141    let mut output = Vec::new();
9142    if write_unified_diff_with_prefix_and_funcname_and_algorithm(
9143        &mut output,
9144        old_content,
9145        new_content,
9146        old_path,
9147        new_path,
9148        context_lines,
9149        inter_hunk_context,
9150        src_prefix,
9151        dst_prefix,
9152        funcname_matcher,
9153        algorithm,
9154        function_context,
9155        use_git_histogram,
9156        indent_heuristic,
9157        quote_path_fully,
9158    )
9159    .is_err()
9160    {
9161        output.clear();
9162    }
9163    String::from_utf8_lossy(&output).into_owned()
9164}
9165
9166/// Group diff ops into hunks like Git's `xdl_get_hunk`: two changes merge into one hunk while
9167/// the run of unchanged lines between them is at most `max_common_gap`
9168/// (`2 * context + inter_hunk_context`), and each hunk keeps at most `context` unchanged lines
9169/// at its edges. Unlike `similar::group_diff_ops`, the split threshold is decoupled from the
9170/// edge context so odd gap limits group exactly like Git.
9171pub(crate) fn group_diff_ops_gap(
9172    mut ops: Vec<similar::DiffOp>,
9173    context: usize,
9174    max_common_gap: usize,
9175) -> Vec<Vec<similar::DiffOp>> {
9176    use similar::DiffOp;
9177    if ops.is_empty() {
9178        return vec![];
9179    }
9180
9181    let mut pending_group = Vec::new();
9182    let mut rv = Vec::new();
9183
9184    if let Some(DiffOp::Equal {
9185        old_index,
9186        new_index,
9187        len,
9188    }) = ops.first_mut()
9189    {
9190        let offset = (*len).saturating_sub(context);
9191        *old_index += offset;
9192        *new_index += offset;
9193        *len -= offset;
9194    }
9195
9196    if let Some(DiffOp::Equal { len, .. }) = ops.last_mut() {
9197        *len -= (*len).saturating_sub(context);
9198    }
9199
9200    for op in ops.into_iter() {
9201        if let DiffOp::Equal {
9202            old_index,
9203            new_index,
9204            len,
9205        } = op
9206        {
9207            // End the current group and start a new one whenever the unchanged
9208            // run is too long to fuse the surrounding changes.
9209            if len > max_common_gap {
9210                pending_group.push(DiffOp::Equal {
9211                    old_index,
9212                    new_index,
9213                    len: context,
9214                });
9215                rv.push(pending_group);
9216                let offset = len.saturating_sub(context);
9217                pending_group = vec![DiffOp::Equal {
9218                    old_index: old_index + offset,
9219                    new_index: new_index + offset,
9220                    len: len - offset,
9221                }];
9222                continue;
9223            }
9224        }
9225        pending_group.push(op);
9226    }
9227
9228    match &pending_group[..] {
9229        &[] | &[similar::DiffOp::Equal { .. }] => {}
9230        _ => rv.push(pending_group),
9231    }
9232
9233    rv
9234}
9235#[expect(clippy::too_many_arguments)]
9236/// `git diff -W`: expand each hunk to include full function bodies (see Git `xemit.c`).
9237fn unified_diff_with_function_context(
9238    old_content: &str,
9239    new_content: &str,
9240    old_path: &str,
9241    new_path: &str,
9242    context_lines: usize,
9243    inter_hunk_context: usize,
9244    src_prefix: &str,
9245    dst_prefix: &str,
9246    funcname_matcher: Option<&FuncnameMatcher>,
9247    algorithm: similar::Algorithm,
9248    indent_heuristic: bool,
9249    quote_path_fully: bool,
9250) -> String {
9251    use crate::quote_path::format_diff_path_with_prefix;
9252    use similar::{udiff::UnifiedDiffHunk, TextDiff};
9253
9254    let diff = TextDiff::configure()
9255        .algorithm(algorithm)
9256        .diff_lines(old_content, new_content);
9257
9258    let old_lines: Vec<&str> = old_content.lines().collect();
9259    let new_lines: Vec<&str> = new_content.lines().collect();
9260    let n_old = old_lines.len();
9261    let n_new = new_lines.len();
9262
9263    // Group changes the way Git's xdl_get_hunk does: merge while the unchanged
9264    // gap is at most `2*context + inter_hunk_context`. `similar::group_diff_ops`
9265    // splits only at gap > `2*radius`, which over-merges changes in *different*
9266    // functions (e.g. a leading insertion and a body change) into one hunk and
9267    // then over-expands the function context (t4015 #136). Use the gap-correct
9268    // grouping (same as the non-function-context path).
9269    let max_common_gap = context_lines
9270        .saturating_mul(2)
9271        .saturating_add(inter_hunk_context);
9272    let all_ops = diff.ops().to_vec();
9273    let op_groups = group_diff_ops_gap(all_ops.clone(), context_lines, max_common_gap);
9274
9275    let mut ranges: Vec<(usize, usize, usize, usize)> = Vec::new();
9276
9277    for ops in op_groups {
9278        if ops.is_empty() {
9279            continue;
9280        }
9281        let i1_anchor = func_context_old_anchor(&ops, n_old);
9282        let i1_end = hunk_old_change_end_exclusive(&ops);
9283        let skip_preimage_pull =
9284            append_with_whole_function_added(&ops, n_old, n_new, &new_lines, funcname_matcher);
9285        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
9286        let hunk_str = format!("{hunk}");
9287        let header_line = hunk_str
9288            .lines()
9289            .next()
9290            .unwrap_or("")
9291            .trim_end_matches(['\r', '\n']);
9292        let Some((base_s1, _base_e1, _base_s2, _base_e2)) =
9293            parse_unified_hunk_header_ranges(header_line)
9294        else {
9295            continue;
9296        };
9297
9298        let ctx = context_lines;
9299        let (s1, e1, s2, e2) = if skip_preimage_pull {
9300            let s = n_old.saturating_sub(ctx);
9301            let s2 = map_old_line_to_new(&all_ops, s, n_new).min(n_new);
9302            (s, n_old, s2, n_new)
9303        } else {
9304            let mut s1 = base_s1.saturating_sub(ctx);
9305            let mut s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
9306
9307            let base_pre_s1 = i1_anchor.saturating_sub(ctx);
9308            if base_pre_s1 < s1 {
9309                s1 = base_pre_s1;
9310                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
9311            }
9312
9313            let fs1 = expand_func_pre_start(s1, i1_anchor, n_old, &old_lines, funcname_matcher);
9314            if fs1 < s1 {
9315                s1 = fs1;
9316                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
9317            }
9318
9319            // `i1_end` is the exclusive end of the changed region; its post-image
9320            // context is `ctx` lines (Git's `xche->i1 + xche->chg1 + lctx`). The
9321            // hunk's `base_e1` already includes the group's trailing context, so
9322            // use the change end + ctx directly to avoid double-counting context
9323            // (which over-extended the hunk and merged separate functions — t4015 #136).
9324            let mut e1 = (i1_end + ctx).min(n_old);
9325            let mut e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
9326            let fe1 = expand_func_post_end(e1, i1_end, n_old, &old_lines, funcname_matcher);
9327            if fe1 > e1 {
9328                e1 = fe1;
9329                e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
9330            }
9331            (s1, e1, s2, e2)
9332        };
9333
9334        ranges.push((s1, e1, s2, e2));
9335    }
9336
9337    // Merge ranges whose function-context expansion made them overlap on the old
9338    // side (Git emits a single hunk in that case).
9339    ranges.sort_by_key(|r| (r.0, r.2));
9340    let mut merged: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(ranges.len());
9341    for (s1, e1, s2, e2) in ranges {
9342        if let Some(last) = merged.last_mut() {
9343            if s1 < last.1 {
9344                last.1 = last.1.max(e1);
9345                last.3 = last.3.max(e2);
9346                continue;
9347            }
9348        }
9349        merged.push((s1, e1, s2, e2));
9350    }
9351    let ranges = merged;
9352
9353    let mut output = String::new();
9354    if old_path == "/dev/null" {
9355        output.push_str("--- /dev/null\n");
9356    } else if src_prefix.is_empty() {
9357        output.push_str(&format!("--- {old_path}\n"));
9358    } else {
9359        output.push_str("--- ");
9360        output.push_str(&format_diff_path_with_prefix(
9361            src_prefix,
9362            old_path,
9363            quote_path_fully,
9364        ));
9365        output.push('\n');
9366    }
9367    if new_path == "/dev/null" {
9368        output.push_str("+++ /dev/null\n");
9369    } else if dst_prefix.is_empty() {
9370        output.push_str(&format!("+++ {new_path}\n"));
9371    } else {
9372        output.push_str("+++ ");
9373        output.push_str(&format_diff_path_with_prefix(
9374            dst_prefix,
9375            new_path,
9376            quote_path_fully,
9377        ));
9378        output.push('\n');
9379    }
9380
9381    for (s1, e1, s2, e2) in ranges {
9382        if s1 >= e1 && s2 >= e2 {
9383            continue;
9384        }
9385        let old_seg =
9386            line_slice_for_diff_with_eof_nl(&old_lines, s1, e1, old_content.ends_with('\n'));
9387        let new_seg =
9388            line_slice_for_diff_with_eof_nl(&new_lines, s2, e2, new_content.ends_with('\n'));
9389        let inner_ctx = old_seg.lines().count().max(new_seg.lines().count()).max(1);
9390        let piece = unified_diff_with_prefix_and_funcname_and_algorithm(
9391            &old_seg,
9392            &new_seg,
9393            old_path,
9394            new_path,
9395            inner_ctx,
9396            0,
9397            src_prefix,
9398            dst_prefix,
9399            funcname_matcher,
9400            algorithm,
9401            false,
9402            false,
9403            indent_heuristic,
9404            quote_path_fully,
9405        );
9406        let shifted = shift_unified_hunk_headers_to_full_file(&piece, s1, s2);
9407        let with_func =
9408            enrich_unified_hunk_headers_funcname(&shifted, &old_lines, funcname_matcher);
9409        for line in with_func.lines() {
9410            if line.starts_with("--- ") || line.starts_with("+++ ") {
9411                continue;
9412            }
9413            output.push_str(line);
9414            output.push('\n');
9415        }
9416    }
9417
9418    output
9419}
9420
9421/// `piece` is a unified diff for a slice of the file; hunk headers use 1-based
9422/// coordinates relative to that slice. Shift them by `delta_old` / `delta_new`
9423/// (0-based offsets of the slice in the full file) so the combined patch applies
9424/// to the whole file.
9425fn shift_unified_hunk_headers_to_full_file(
9426    patch: &str,
9427    delta_old: usize,
9428    delta_new: usize,
9429) -> String {
9430    if delta_old == 0 && delta_new == 0 {
9431        return patch.to_owned();
9432    }
9433    let mut out = String::with_capacity(patch.len());
9434    for line in patch.lines() {
9435        if let Some(shifted) = shift_one_unified_hunk_header(line, delta_old, delta_new) {
9436            out.push_str(&shifted);
9437        } else {
9438            out.push_str(line);
9439        }
9440        out.push('\n');
9441    }
9442    out
9443}
9444
9445fn shift_one_unified_hunk_header(line: &str, delta_old: usize, delta_new: usize) -> Option<String> {
9446    let rest = line.strip_prefix("@@ ")?;
9447    let (old_chunk, after_plus) = rest.split_once(" +")?;
9448    let old_spec = old_chunk.strip_prefix('-')?;
9449    let (new_spec, suffix) = after_plus.split_once(" @@")?;
9450    let shifted_old = shift_unified_range_spec(old_spec, delta_old)?;
9451    let shifted_new = shift_unified_range_spec(new_spec, delta_new)?;
9452    Some(format!("@@ -{shifted_old} +{shifted_new} @@{suffix}"))
9453}
9454
9455fn shift_unified_range_spec(spec: &str, delta: usize) -> Option<String> {
9456    let spec = spec.trim();
9457    if let Some((start_s, count_s)) = spec.split_once(',') {
9458        let start: usize = start_s.parse().ok()?;
9459        let count: usize = count_s.parse().ok()?;
9460        Some(format!("{},{}", start.saturating_add(delta), count))
9461    } else {
9462        let start: usize = spec.parse().ok()?;
9463        Some(format!("{}", start.saturating_add(delta)))
9464    }
9465}
9466
9467/// Re-attach `@@ ... @@ <funcname>` using full-file line indices (inner diffs use slices).
9468fn enrich_unified_hunk_headers_funcname(
9469    patch: &str,
9470    full_old_lines: &[&str],
9471    funcname_matcher: Option<&FuncnameMatcher>,
9472) -> String {
9473    let mut out = String::with_capacity(patch.len());
9474    for line in patch.lines() {
9475        if let Some(fixed) = enrich_one_hunk_header_funcname(line, full_old_lines, funcname_matcher)
9476        {
9477            out.push_str(&fixed);
9478        } else {
9479            out.push_str(line);
9480        }
9481        out.push('\n');
9482    }
9483    out
9484}
9485
9486fn enrich_one_hunk_header_funcname(
9487    line: &str,
9488    full_old_lines: &[&str],
9489    funcname_matcher: Option<&FuncnameMatcher>,
9490) -> Option<String> {
9491    let after_at = line.strip_prefix("@@ ")?;
9492    let idx = after_at.find(" @@")?;
9493    let mid = after_at[..idx].trim();
9494    let tail = after_at[idx + 3..].trim_start();
9495    let header_for_parse = format!("@@ {mid} @@");
9496    let func = extract_function_context(&header_for_parse, full_old_lines, funcname_matcher, None);
9497    Some(if let Some(f) = func {
9498        format!("@@ {mid} @@ {f}")
9499    } else if !tail.is_empty() {
9500        format!("@@ {mid} @@ {tail}")
9501    } else {
9502        format!("@@ {mid} @@")
9503    })
9504}
9505
9506fn line_slice_for_diff_with_eof_nl(
9507    lines: &[&str],
9508    start: usize,
9509    end: usize,
9510    full_file_ends_with_newline: bool,
9511) -> String {
9512    if start >= end {
9513        return String::new();
9514    }
9515    let mut s = lines[start..end].join("\n");
9516    let slice_is_suffix_of_file = end == lines.len();
9517    let need_trailing_nl = if slice_is_suffix_of_file {
9518        full_file_ends_with_newline
9519    } else {
9520        true
9521    };
9522    if need_trailing_nl && !s.ends_with('\n') {
9523        s.push('\n');
9524    }
9525    s
9526}
9527
9528/// Map a 0-based old line index to the corresponding 0-based new line index using the full-file
9529/// diff ops (Git aligns context across deletions/insertions).
9530fn map_old_line_to_new(ops: &[similar::DiffOp], old_line: usize, n_new: usize) -> usize {
9531    use similar::DiffOp;
9532    let mut n = 0usize;
9533    for op in ops {
9534        match *op {
9535            DiffOp::Equal {
9536                old_index,
9537                new_index,
9538                len,
9539            } => {
9540                if old_index + len <= old_line {
9541                    n = new_index + len;
9542                    continue;
9543                }
9544                if old_index < old_line {
9545                    let take = old_line - old_index;
9546                    return (new_index + take).min(n_new);
9547                }
9548                return new_index.min(n_new);
9549            }
9550            DiffOp::Delete {
9551                old_index,
9552                old_len,
9553                new_index,
9554            } => {
9555                if old_index + old_len <= old_line {
9556                    n = new_index;
9557                    continue;
9558                }
9559                if old_index < old_line {
9560                    return new_index.min(n_new);
9561                }
9562            }
9563            DiffOp::Insert {
9564                old_index,
9565                new_index,
9566                new_len,
9567            } => {
9568                if old_index < old_line {
9569                    n = new_index + new_len;
9570                    continue;
9571                }
9572                if old_index == old_line {
9573                    // `old_line` is an exclusive end or insertion point aligned with this insert
9574                    // (e.g. EOF append maps to after the inserted block).
9575                    return (new_index + new_len).min(n_new);
9576                }
9577                return new_index.min(n_new);
9578            }
9579            DiffOp::Replace {
9580                old_index,
9581                old_len,
9582                new_index,
9583                new_len,
9584            } => {
9585                if old_index + old_len <= old_line {
9586                    n = new_index + new_len;
9587                    continue;
9588                }
9589                if old_index < old_line {
9590                    let into_old = old_line - old_index;
9591                    let mapped = new_index + into_old.min(new_len);
9592                    return mapped.min(n_new);
9593                }
9594                return new_index.min(n_new);
9595            }
9596        }
9597    }
9598    n.min(n_new)
9599}
9600
9601/// Parse `@@ -old +new @@` into 0-based half-open ranges in each file.
9602fn parse_unified_hunk_header_ranges(header: &str) -> Option<(usize, usize, usize, usize)> {
9603    let rest = header.strip_prefix("@@ ")?;
9604    let (old_tok, rest2) = rest.split_once(" +")?;
9605    let old_tok = old_tok.strip_prefix('-')?;
9606    let new_tok = rest2.split_once(" @@").map(|(a, _)| a)?;
9607
9608    fn parse_side(spec: &str) -> Option<(usize, usize)> {
9609        let spec = spec.trim();
9610        let (start_one_based, count) = if let Some((a, b)) = spec.split_once(',') {
9611            (a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)
9612        } else {
9613            let s = spec.parse::<usize>().ok()?;
9614            (s, 1usize)
9615        };
9616        let s0 = start_one_based.saturating_sub(1);
9617        let e0 = s0.saturating_add(count);
9618        Some((s0, e0))
9619    }
9620
9621    let (os, oe) = parse_side(old_tok)?;
9622    let (ns, ne) = parse_side(new_tok)?;
9623    Some((os, oe, ns, ne))
9624}
9625
9626/// Git `xemit.c`: when a hunk only inserts at EOF (first inserted line is `new_index == n_old`)
9627/// and the added text already contains a funcname line, do not pull extra context from the preimage.
9628fn append_with_whole_function_added(
9629    ops: &[similar::DiffOp],
9630    n_old: usize,
9631    n_new: usize,
9632    new_lines: &[&str],
9633    matcher: Option<&FuncnameMatcher>,
9634) -> bool {
9635    use similar::DiffOp;
9636    if n_old == 0 {
9637        return false;
9638    }
9639    let mut only_ins_or_eq = true;
9640    let mut min_new_ins = usize::MAX;
9641    for op in ops {
9642        match *op {
9643            DiffOp::Equal { .. } => {}
9644            DiffOp::Insert {
9645                new_index, new_len, ..
9646            } => {
9647                min_new_ins = min_new_ins.min(new_index);
9648                if new_len == 0 {
9649                    only_ins_or_eq = false;
9650                }
9651            }
9652            DiffOp::Delete { .. } | DiffOp::Replace { .. } => {
9653                only_ins_or_eq = false;
9654            }
9655        }
9656    }
9657    let mut insert_at_eof = false;
9658    for op in ops {
9659        if let DiffOp::Insert { old_index, .. } = *op {
9660            if old_index == n_old {
9661                insert_at_eof = true;
9662                break;
9663            }
9664        }
9665    }
9666    let append_at_eof = min_new_ins == n_old || insert_at_eof;
9667    if !only_ins_or_eq || !append_at_eof || min_new_ins == usize::MAX {
9668        return false;
9669    }
9670    // Git only skips preimage pull when the inserted block is clearly a new logical
9671    // function (see `xemit.c` walking `xdf2` for `is_func_rec`). A loose "any line
9672    // looks like a function" check would match `return` / `printf` and break `-W`
9673    // hunks that still need preimage context (t4051 `extended`).
9674    let mut j = min_new_ins;
9675    while j < n_new {
9676        let line = new_lines[j];
9677        if line.trim().is_empty() {
9678            j += 1;
9679            continue;
9680        }
9681        if let Some(m) = matcher {
9682            if m.match_line(line).is_some() {
9683                return true;
9684            }
9685        } else if inserted_block_starts_with_c_like_function_definition(line) {
9686            return true;
9687        }
9688        j += 1;
9689    }
9690    false
9691}
9692
9693fn inserted_block_starts_with_c_like_function_definition(line: &str) -> bool {
9694    let t = line.trim_start();
9695    let Some(open_paren) = t.find('(') else {
9696        return false;
9697    };
9698    let head = &t[..open_paren];
9699    let tokens: Vec<&str> = head.split_whitespace().collect();
9700    if tokens.len() < 2 {
9701        // `printf(...)`, `return (`, etc. — not `return_type name(`.
9702        return false;
9703    }
9704    let nameish = tokens.last().copied().unwrap_or("");
9705    let name = nameish.trim_end_matches(['*', '&']);
9706    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
9707        return false;
9708    }
9709    let type_or_modifier = |tok: &str| {
9710        matches!(
9711            tok,
9712            "static"
9713                | "extern"
9714                | "inline"
9715                | "void"
9716                | "int"
9717                | "char"
9718                | "short"
9719                | "long"
9720                | "float"
9721                | "double"
9722                | "unsigned"
9723                | "signed"
9724                | "struct"
9725                | "enum"
9726                | "union"
9727                | "const"
9728                | "volatile"
9729                | "typedef"
9730        )
9731    };
9732    tokens[..tokens.len() - 1]
9733        .iter()
9734        .any(|tok| type_or_modifier(tok))
9735}
9736
9737fn hunk_old_change_end_exclusive(ops: &[similar::DiffOp]) -> usize {
9738    use similar::DiffOp;
9739    let mut max_o = 0usize;
9740    for op in ops {
9741        match *op {
9742            DiffOp::Delete {
9743                old_index, old_len, ..
9744            } => {
9745                max_o = max_o.max(old_index + old_len);
9746            }
9747            DiffOp::Replace {
9748                old_index, old_len, ..
9749            } => {
9750                max_o = max_o.max(old_index + old_len);
9751            }
9752            DiffOp::Insert { old_index, .. } => {
9753                // Pure insertions do not consume old lines; Git's post-context anchor is the
9754                // insertion point (`old_index`), not 0 (t4051 `extended`).
9755                max_o = max_o.max(old_index);
9756            }
9757            DiffOp::Equal { .. } => {}
9758        }
9759    }
9760    max_o
9761}
9762
9763fn func_context_old_anchor(ops: &[similar::DiffOp], n_old: usize) -> usize {
9764    use similar::DiffOp;
9765    let mut has_delete_or_replace = false;
9766    let mut min_del = usize::MAX;
9767    let mut min_ins_old = usize::MAX;
9768
9769    for op in ops {
9770        match *op {
9771            DiffOp::Delete {
9772                old_index, old_len, ..
9773            } => {
9774                has_delete_or_replace = true;
9775                min_del = min_del.min(old_index);
9776                min_del = min_del.min(old_index + old_len.saturating_sub(1));
9777            }
9778            DiffOp::Replace {
9779                old_index, old_len, ..
9780            } => {
9781                has_delete_or_replace = true;
9782                min_del = min_del.min(old_index);
9783                min_del = min_del.min(old_index + old_len.saturating_sub(1));
9784            }
9785            DiffOp::Insert { old_index, .. } => {
9786                min_ins_old = min_ins_old.min(old_index);
9787            }
9788            DiffOp::Equal { .. } => {}
9789        }
9790    }
9791
9792    let mut i1 = if has_delete_or_replace {
9793        min_del
9794    } else if min_ins_old != usize::MAX {
9795        min_ins_old
9796    } else {
9797        0
9798    };
9799
9800    let pure_insert = ops
9801        .iter()
9802        .all(|op| matches!(op, DiffOp::Insert { .. } | DiffOp::Equal { .. }))
9803        && ops.iter().any(|op| matches!(op, DiffOp::Insert { .. }));
9804
9805    if pure_insert && i1 >= n_old && n_old > 0 {
9806        i1 = n_old - 1;
9807    }
9808
9809    i1.min(n_old.saturating_sub(1))
9810}
9811
9812fn expand_func_pre_start(
9813    s1: usize,
9814    i1: usize,
9815    n_old: usize,
9816    old_lines: &[&str],
9817    matcher: Option<&FuncnameMatcher>,
9818) -> usize {
9819    if n_old == 0 {
9820        return s1;
9821    }
9822    let i1 = i1.min(n_old.saturating_sub(1));
9823    let mut fs1 = get_func_line_backward(old_lines, i1, matcher).unwrap_or(i1);
9824    while fs1 > 0
9825        && !is_line_empty_for_func_context(old_lines[fs1 - 1])
9826        && !is_func_line(old_lines[fs1 - 1], matcher)
9827    {
9828        fs1 -= 1;
9829    }
9830    s1.min(fs1)
9831}
9832
9833fn expand_func_post_end(
9834    e1: usize,
9835    i1_end: usize,
9836    n_old: usize,
9837    old_lines: &[&str],
9838    matcher: Option<&FuncnameMatcher>,
9839) -> usize {
9840    let from = i1_end.min(n_old);
9841    let fe1 = get_func_line_forward(old_lines, from, matcher).unwrap_or(n_old);
9842    let mut fe1_adj = fe1;
9843    while fe1_adj > 0 && is_line_empty_for_func_context(old_lines[fe1_adj - 1]) {
9844        fe1_adj -= 1;
9845    }
9846    e1.max(fe1_adj).min(n_old)
9847}
9848
9849fn is_line_empty_for_func_context(line: &str) -> bool {
9850    line.chars().all(|c| c.is_whitespace())
9851}
9852
9853pub(crate) fn is_func_line(line: &str, matcher: Option<&FuncnameMatcher>) -> bool {
9854    if let Some(m) = matcher {
9855        return m.match_line(line).is_some();
9856    }
9857    let t = line.trim_end_matches(['\n', '\r']);
9858    if t.is_empty() {
9859        return false;
9860    }
9861    let b = t.as_bytes()[0];
9862    if !(b.is_ascii_alphabetic() || b == b'_' || b == b'$') {
9863        return false;
9864    }
9865    true
9866}
9867
9868fn get_func_line_backward(
9869    old_lines: &[&str],
9870    start: usize,
9871    matcher: Option<&FuncnameMatcher>,
9872) -> Option<usize> {
9873    let mut l = start.min(old_lines.len().saturating_sub(1));
9874    if old_lines.is_empty() {
9875        return None;
9876    }
9877    loop {
9878        if is_func_line(old_lines[l], matcher) {
9879            return Some(l);
9880        }
9881        if l == 0 {
9882            break;
9883        }
9884        l -= 1;
9885    }
9886    None
9887}
9888
9889fn get_func_line_forward(
9890    old_lines: &[&str],
9891    start: usize,
9892    matcher: Option<&FuncnameMatcher>,
9893) -> Option<usize> {
9894    let mut l = start;
9895    while l < old_lines.len() {
9896        if is_func_line(old_lines[l], matcher) {
9897            return Some(l);
9898        }
9899        l += 1;
9900    }
9901    None
9902}
9903#[expect(clippy::too_many_arguments)]
9904/// Compute a unified diff with anchored lines.
9905///
9906/// Anchored lines that appear exactly once in both old and new content are
9907/// forced to match, splitting the diff into segments around those anchor points.
9908/// This produces diffs where the anchored text stays as context and surrounding
9909/// lines are shown as additions/removals.
9910///
9911/// Segment diffs use `algorithm`. When `use_git_histogram` is true, histogram uses imara-diff
9912/// (Git-compatible); otherwise `algorithm` is passed to `similar`.
9913pub fn anchored_unified_diff(
9914    old_content: &str,
9915    new_content: &str,
9916    old_path: &str,
9917    new_path: &str,
9918    context_lines: usize,
9919    anchors: &[String],
9920    algorithm: similar::Algorithm,
9921    use_git_histogram: bool,
9922    indent_heuristic: bool,
9923    quote_path_fully: bool,
9924) -> String {
9925    use crate::quote_path::format_diff_path_with_prefix;
9926    use similar::TextDiff;
9927
9928    let old_lines: Vec<&str> = old_content.lines().collect();
9929    let new_lines: Vec<&str> = new_content.lines().collect();
9930
9931    // Find anchored lines that appear exactly once in both old and new
9932    let mut anchor_pairs: Vec<(usize, usize)> = Vec::new(); // (old_idx, new_idx)
9933
9934    for anchor in anchors {
9935        let anchor_str = anchor.as_str();
9936
9937        // Count occurrences in old
9938        let old_positions: Vec<usize> = old_lines
9939            .iter()
9940            .enumerate()
9941            .filter(|(_, l)| l.trim_end() == anchor_str)
9942            .map(|(i, _)| i)
9943            .collect();
9944
9945        // Count occurrences in new
9946        let new_positions: Vec<usize> = new_lines
9947            .iter()
9948            .enumerate()
9949            .filter(|(_, l)| l.trim_end() == anchor_str)
9950            .map(|(i, _)| i)
9951            .collect();
9952
9953        // Only anchor if unique in both
9954        if old_positions.len() == 1 && new_positions.len() == 1 {
9955            anchor_pairs.push((old_positions[0], new_positions[0]));
9956        }
9957    }
9958
9959    // If no valid anchors, fall back to normal diff
9960    if anchor_pairs.is_empty() {
9961        return unified_diff_with_prefix_and_funcname_and_algorithm(
9962            old_content,
9963            new_content,
9964            old_path,
9965            new_path,
9966            context_lines,
9967            0,
9968            "a/",
9969            "b/",
9970            None,
9971            algorithm,
9972            false,
9973            use_git_histogram,
9974            indent_heuristic,
9975            quote_path_fully,
9976        );
9977    }
9978
9979    // Sort anchor pairs by their position in the old file
9980    anchor_pairs.sort_by_key(|&(old_idx, _)| old_idx);
9981
9982    // Filter to only keep pairs where new positions are also increasing
9983    // (longest increasing subsequence of new positions)
9984    let mut filtered: Vec<(usize, usize)> = Vec::new();
9985    for &pair in &anchor_pairs {
9986        if filtered.is_empty() || filtered.last().is_some_and(|last| pair.1 > last.1) {
9987            filtered.push(pair);
9988        }
9989    }
9990    let anchor_pairs = filtered;
9991
9992    // Build a modified version of old/new where we diff segments between anchors.
9993    // We'll construct the diff by processing segments:
9994    // - Before first anchor
9995    // - Between consecutive anchors
9996    // - After last anchor
9997    // Each anchor line itself is a fixed context match.
9998
9999    // Collect all diff operations
10000    struct LineDiffOp {
10001        tag: char, // ' ', '+', '-'
10002        line: String,
10003    }
10004
10005    let append_segment_diff =
10006        |ops: &mut Vec<LineDiffOp>, old_seg_input: &str, new_seg_input: &str| {
10007            use similar::ChangeTag;
10008            let old_ls: Vec<&str> = old_seg_input.lines().collect();
10009            let new_ls: Vec<&str> = new_seg_input.lines().collect();
10010            if old_ls.is_empty() && new_ls.is_empty() {
10011                return;
10012            }
10013            let seg_diff = TextDiff::configure()
10014                .algorithm(algorithm)
10015                .diff_slices(&old_ls, &new_ls);
10016            let raw = seg_diff.ops().to_vec();
10017            let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
10018                &raw,
10019                &old_ls,
10020                &new_ls,
10021                indent_heuristic,
10022            );
10023            for op in &compacted {
10024                for ch in op.iter_changes(&old_ls, &new_ls) {
10025                    let t = match ch.tag() {
10026                        ChangeTag::Equal => ' ',
10027                        ChangeTag::Delete => '-',
10028                        ChangeTag::Insert => '+',
10029                    };
10030                    ops.push(LineDiffOp {
10031                        tag: t,
10032                        line: ch.value().to_string(),
10033                    });
10034                }
10035            }
10036        };
10037
10038    let mut ops: Vec<LineDiffOp> = Vec::new();
10039    let mut old_pos = 0usize;
10040    let mut new_pos = 0usize;
10041
10042    for &(old_anchor, new_anchor) in &anchor_pairs {
10043        // Diff the segment before this anchor
10044        let old_segment: Vec<&str> = old_lines[old_pos..old_anchor].to_vec();
10045        let new_segment: Vec<&str> = new_lines[new_pos..new_anchor].to_vec();
10046
10047        let old_seg_text = old_segment.join("\n");
10048        let new_seg_text = new_segment.join("\n");
10049
10050        if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
10051            let old_seg_input = if old_seg_text.is_empty() {
10052                String::new()
10053            } else {
10054                format!("{}\n", old_seg_text)
10055            };
10056            let new_seg_input = if new_seg_text.is_empty() {
10057                String::new()
10058            } else {
10059                format!("{}\n", new_seg_text)
10060            };
10061            append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
10062        }
10063
10064        // The anchor line itself is always context
10065        ops.push(LineDiffOp {
10066            tag: ' ',
10067            line: old_lines[old_anchor].to_string(),
10068        });
10069
10070        old_pos = old_anchor + 1;
10071        new_pos = new_anchor + 1;
10072    }
10073
10074    // Diff the remaining segment after the last anchor
10075    let old_segment: Vec<&str> = old_lines[old_pos..].to_vec();
10076    let new_segment: Vec<&str> = new_lines[new_pos..].to_vec();
10077    let old_seg_text = old_segment.join("\n");
10078    let new_seg_text = new_segment.join("\n");
10079
10080    if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
10081        let old_seg_input = if old_seg_text.is_empty() {
10082            String::new()
10083        } else {
10084            format!("{}\n", old_seg_text)
10085        };
10086        let new_seg_input = if new_seg_text.is_empty() {
10087            String::new()
10088        } else {
10089            format!("{}\n", new_seg_text)
10090        };
10091        append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
10092    }
10093
10094    // Now format as unified diff with hunks
10095    let mut output = String::new();
10096    if old_path == "/dev/null" {
10097        output.push_str("--- /dev/null\n");
10098    } else {
10099        output.push_str("--- ");
10100        output.push_str(&format_diff_path_with_prefix(
10101            "a/",
10102            old_path,
10103            quote_path_fully,
10104        ));
10105        output.push('\n');
10106    }
10107    if new_path == "/dev/null" {
10108        output.push_str("+++ /dev/null\n");
10109    } else {
10110        output.push_str("+++ ");
10111        output.push_str(&format_diff_path_with_prefix(
10112            "b/",
10113            new_path,
10114            quote_path_fully,
10115        ));
10116        output.push('\n');
10117    }
10118
10119    // Group ops into hunks with context
10120    let total_ops = ops.len();
10121    if total_ops == 0 {
10122        return output;
10123    }
10124
10125    // Find ranges of changes
10126    let mut hunks: Vec<(usize, usize)> = Vec::new(); // (start, end) indices into ops
10127    let mut i = 0;
10128    while i < total_ops {
10129        if ops[i].tag != ' ' {
10130            let start = i.saturating_sub(context_lines);
10131            let mut end = i;
10132            // Extend to include consecutive changes and their context
10133            while end < total_ops {
10134                if ops[end].tag != ' ' {
10135                    end += 1;
10136                    continue;
10137                }
10138                // Check if there's another change within context_lines
10139                let mut next_change = end;
10140                while next_change < total_ops && ops[next_change].tag == ' ' {
10141                    next_change += 1;
10142                }
10143                if next_change < total_ops && next_change - end <= context_lines * 2 {
10144                    end = next_change + 1;
10145                } else {
10146                    end = (end + context_lines).min(total_ops);
10147                    break;
10148                }
10149            }
10150            // Merge with previous hunk if overlapping
10151            if let Some(last) = hunks.last_mut() {
10152                if start <= last.1 {
10153                    last.1 = end;
10154                } else {
10155                    hunks.push((start, end));
10156                }
10157            } else {
10158                hunks.push((start, end));
10159            }
10160            i = end;
10161        } else {
10162            i += 1;
10163        }
10164    }
10165
10166    // Output each hunk
10167    for (start, end) in hunks {
10168        // Count old/new lines in this hunk
10169        let mut old_start = 1usize;
10170        let mut new_start = 1usize;
10171        // Calculate line numbers by counting ops before this hunk
10172        for op in &ops[..start] {
10173            match op.tag {
10174                ' ' => {
10175                    old_start += 1;
10176                    new_start += 1;
10177                }
10178                '-' => {
10179                    old_start += 1;
10180                }
10181                '+' => {
10182                    new_start += 1;
10183                }
10184                _ => {}
10185            }
10186        }
10187        let mut old_count = 0usize;
10188        let mut new_count = 0usize;
10189        for op in &ops[start..end] {
10190            match op.tag {
10191                ' ' => {
10192                    old_count += 1;
10193                    new_count += 1;
10194                }
10195                '-' => {
10196                    old_count += 1;
10197                }
10198                '+' => {
10199                    new_count += 1;
10200                }
10201                _ => {}
10202            }
10203        }
10204
10205        output.push_str(&format!(
10206            "@@ -{},{} +{},{} @@\n",
10207            old_start, old_count, new_start, new_count
10208        ));
10209        for op in &ops[start..end] {
10210            output.push(op.tag);
10211            output.push_str(&op.line);
10212            output.push('\n');
10213        }
10214    }
10215
10216    output
10217}
10218
10219/// Extract function context for a hunk header.
10220///
10221/// Given a hunk header like `@@ -8,7 +8,7 @@`, find the last line
10222/// before line 8 in the old content that looks like a function header
10223/// (starts with a non-whitespace character, like Git's default).
10224fn func_context_line_matches(line: &str, funcname_matcher: Option<&FuncnameMatcher>) -> bool {
10225    if let Some(m) = funcname_matcher {
10226        return m.match_line(line).is_some();
10227    }
10228    is_func_line(line, None)
10229}
10230
10231fn extract_function_context(
10232    header: &str,
10233    old_lines: &[&str],
10234    funcname_matcher: Option<&FuncnameMatcher>,
10235    _source_path: Option<&str>,
10236) -> Option<String> {
10237    // Parse the old start line number from "@@ -<start>,<count> ..."
10238    let at_pos = header.find("-")?;
10239    let rest = &header[at_pos + 1..];
10240    let comma_or_space = rest.find([',', ' '])?;
10241    let start_str = &rest[..comma_or_space];
10242    let start_line: usize = start_str.parse().ok()?;
10243
10244    // Parse the old line count; "@@ -<start>,<count> ..." (no comma means count 1).
10245    // Only look for the comma inside the old-range token itself — searching the
10246    // whole remainder would pick up the comma from the new side (e.g. "+0,0").
10247    let old_token_end = rest.find([' ', '\t']).unwrap_or(rest.len());
10248    let old_token = &rest[..old_token_end];
10249    let old_count: usize = if let Some(comma) = old_token.find(',') {
10250        old_token[comma + 1..].parse().unwrap_or(1)
10251    } else {
10252        1
10253    };
10254
10255    if start_line == 0 {
10256        return None;
10257    }
10258
10259    // Look backwards for a line that matches the funcname pattern. start_line is
10260    // 1-indexed. For a normal hunk the first changed pre-image line is
10261    // old_lines[start_line-1], so we search lines strictly before it
10262    // (old_lines[0..start_line-1]). For a pure insertion (old count 0) the
10263    // content is inserted *after* old line start_line, so Git's function search
10264    // begins at that line itself: search old_lines[0..start_line].
10265    if start_line == 0 {
10266        return None;
10267    }
10268    let search_end = if old_count == 0 {
10269        start_line.min(old_lines.len())
10270    } else {
10271        if start_line <= 1 {
10272            return None;
10273        }
10274        (start_line - 1).min(old_lines.len())
10275    };
10276    let truncate = |line: &str| {
10277        let mut end = line.len().min(80);
10278        while end > 0 && !line.is_char_boundary(end) {
10279            end -= 1;
10280        }
10281        let mut out = line[..end].to_owned();
10282        let tail = line[end..].trim_start();
10283        // Git drops trailing whitespace on truncated funcname lines when the
10284        // remainder of the source line continues with normal text (t2021-style
10285        // shell tests), but keeps it when the next token is a string delimiter
10286        // (gettext `.po` msgstr lines).
10287        if tail.is_empty()
10288            || (!tail.starts_with('"')
10289                && !tail.starts_with('\'')
10290                && !line.starts_with("msgstr")
10291                && !line.starts_with("msgid"))
10292        {
10293            out = out.trim_end().to_owned();
10294        }
10295        out
10296    };
10297
10298    for i in (0..search_end).rev() {
10299        let line = old_lines[i];
10300        if line.is_empty() {
10301            continue;
10302        }
10303        if func_context_line_matches(line, funcname_matcher) {
10304            if let Some(matcher) = funcname_matcher {
10305                if let Some(matched) = matcher.match_line(line) {
10306                    return Some(truncate(&matched));
10307                }
10308                continue;
10309            }
10310            return Some(truncate(line));
10311        }
10312    }
10313    None
10314}
10315
10316/// Generate diff stat output (file name + insertions/deletions).
10317///
10318/// Returns a single line like: ` file.txt | 5 ++---`
10319pub fn format_stat_line(
10320    path: &str,
10321    insertions: usize,
10322    deletions: usize,
10323    max_path_len: usize,
10324) -> String {
10325    format_stat_line_width(path, insertions, deletions, max_path_len, 0)
10326}
10327
10328/// Format one `--stat` line with explicit path and count column widths.
10329pub fn format_stat_line_width(
10330    path: &str,
10331    insertions: usize,
10332    deletions: usize,
10333    max_path_len: usize,
10334    count_width: usize,
10335) -> String {
10336    let mut line =
10337        crate::diffstat::format_single_stat_line(path, insertions, deletions, max_path_len)
10338            .unwrap_or_else(|_| String::new());
10339    if count_width > 0 && line.contains(" | ") {
10340        let total = insertions + deletions;
10341        let cw = count_width;
10342        if let Some((left, rest)) = line.split_once(" | ") {
10343            if let Some((_, bar)) = rest.split_once(' ') {
10344                line = format!(" {left} | {total:>cw$} {bar}", cw = cw);
10345            } else {
10346                line = format!(" {left} | {total:>cw$}", cw = cw);
10347            }
10348        }
10349    }
10350    line
10351}
10352
10353fn last_line_index_without_trailing_newline(text: &str) -> Option<usize> {
10354    LineSide::from_text(text).incomplete_last_line_index()
10355}
10356
10357fn parse_unified_hunk_starts(header: &str) -> Option<(usize, usize)> {
10358    let rest = header.strip_prefix("@@ ")?;
10359    let (old_part, new_part) = rest.split_once(" +")?;
10360    let old_start = parse_unified_range_start(old_part.strip_prefix('-')?)?;
10361    let new_start = parse_unified_range_start(new_part.split(" @@").next()?)?;
10362    Some((old_start, new_start))
10363}
10364
10365fn parse_unified_range_start(spec: &str) -> Option<usize> {
10366    let spec = spec.trim();
10367    if let Some((start, _)) = spec.split_once(',') {
10368        start.parse().ok()
10369    } else {
10370        spec.parse().ok()
10371    }
10372}
10373
10374/// Tracks hunk line numbers while streaming `\ No newline at end of file` markers.
10375struct UnifiedDiffNoNewlineAnnotator {
10376    old_last: Option<usize>,
10377    new_last: Option<usize>,
10378    old_line: usize,
10379    new_line: usize,
10380    passthrough: bool,
10381}
10382
10383impl UnifiedDiffNoNewlineAnnotator {
10384    fn new(old: &str, new: &str) -> Self {
10385        let old_last = last_line_index_without_trailing_newline(old);
10386        let new_last = last_line_index_without_trailing_newline(new);
10387        Self {
10388            old_last,
10389            new_last,
10390            old_line: 1,
10391            new_line: 1,
10392            passthrough: old_last.is_none() && new_last.is_none(),
10393        }
10394    }
10395
10396    fn write_hunk<W: std::io::Write>(&mut self, out: &mut W, patch: &str) -> Result<()> {
10397        if self.passthrough {
10398            out.write_all(patch.as_bytes())?;
10399            return Ok(());
10400        }
10401        let mut lines = patch.lines().peekable();
10402        while let Some(line) = lines.next() {
10403            self.write_annotated_line(out, line, &mut lines)?;
10404        }
10405        Ok(())
10406    }
10407
10408    fn write_annotated_line<W: std::io::Write>(
10409        &mut self,
10410        out: &mut W,
10411        line: &str,
10412        lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
10413    ) -> Result<()> {
10414        if let Some((o, n)) = line
10415            .strip_prefix("@@ ")
10416            .and_then(|_| parse_unified_hunk_starts(line))
10417        {
10418            self.old_line = o;
10419            self.new_line = n;
10420            writeln!(out, "{line}")?;
10421            return Ok(());
10422        }
10423        if line.starts_with("--- ")
10424            || line.starts_with("+++ ")
10425            || line.starts_with("diff --git ")
10426            || line.starts_with("diff --cc ")
10427            || line.starts_with("diff --combined ")
10428        {
10429            writeln!(out, "{line}")?;
10430            return Ok(());
10431        }
10432        if line.starts_with('-') {
10433            writeln!(out, "{line}")?;
10434            let already_marked = lines
10435                .peek()
10436                .is_some_and(|next| *next == "\\ No newline at end of file");
10437            if !already_marked && self.old_last == Some(self.old_line.saturating_sub(1)) {
10438                writeln!(out, "\\ No newline at end of file")?;
10439            }
10440            self.old_line += 1;
10441            return Ok(());
10442        }
10443        if line.starts_with('+') {
10444            writeln!(out, "{line}")?;
10445            let already_marked = lines
10446                .peek()
10447                .is_some_and(|next| *next == "\\ No newline at end of file");
10448            if !already_marked && self.new_last == Some(self.new_line.saturating_sub(1)) {
10449                writeln!(out, "\\ No newline at end of file")?;
10450            }
10451            self.new_line += 1;
10452            return Ok(());
10453        }
10454        if line.starts_with(' ') {
10455            writeln!(out, "{line}")?;
10456            let already_marked = lines
10457                .peek()
10458                .is_some_and(|next| *next == "\\ No newline at end of file");
10459            if !already_marked {
10460                let old_idx = self.old_line.saturating_sub(1);
10461                let new_idx = self.new_line.saturating_sub(1);
10462                if self.old_last == Some(old_idx) || self.new_last == Some(new_idx) {
10463                    writeln!(out, "\\ No newline at end of file")?;
10464                }
10465            }
10466            self.old_line += 1;
10467            self.new_line += 1;
10468            return Ok(());
10469        }
10470        writeln!(out, "{line}")?;
10471        Ok(())
10472    }
10473}
10474
10475/// Insert Git `\ No newline at end of file` after `-`/`+` hunk lines when that side of the
10476/// file lacks a trailing newline on the last line.
10477#[must_use]
10478pub fn annotate_unified_diff_no_newline_at_eof(patch: &str, old: &str, new: &str) -> String {
10479    let mut annotator = UnifiedDiffNoNewlineAnnotator::new(old, new);
10480    if annotator.passthrough {
10481        return patch.to_string();
10482    }
10483    let mut out = Vec::with_capacity(patch.len() + 64);
10484    let mut lines = patch.lines().peekable();
10485    while let Some(line) = lines.next() {
10486        if annotator
10487            .write_annotated_line(&mut out, line, &mut lines)
10488            .is_err()
10489        {
10490            return patch.to_string();
10491        }
10492    }
10493    String::from_utf8(out).unwrap_or_else(|_| patch.to_string())
10494}
10495
10496/// Normalise one line like Git's `-b` / `--ignore-space-change`.
10497#[must_use]
10498pub fn normalize_ignore_space_change_line(line: &str) -> String {
10499    let mut result = String::with_capacity(line.len());
10500    let mut in_space = false;
10501    for c in line.chars() {
10502        if c.is_whitespace() {
10503            if !in_space {
10504                result.push(' ');
10505                in_space = true;
10506            }
10507        } else {
10508            result.push(c);
10509            in_space = false;
10510        }
10511    }
10512    while result.ends_with(' ') {
10513        result.pop();
10514    }
10515    result
10516}
10517
10518/// Normalise text like Git's `-b` / `--ignore-space-change`: on each line, collapse runs of
10519/// whitespace to a single ASCII space and trim trailing spaces.
10520///
10521/// Line breaks are preserved by splitting on [`str::lines`] and rejoining with `\n` (same approach
10522/// as the porcelain `diff` whitespace handling in `grit`).
10523#[must_use]
10524pub fn normalize_ignore_space_change(content: &str) -> String {
10525    content
10526        .lines()
10527        .map(normalize_ignore_space_change_line)
10528        .collect::<Vec<_>>()
10529        .join("\n")
10530}
10531
10532/// Count insertions and deletions between two strings.
10533///
10534/// Returns `(insertions, deletions)`.
10535pub fn count_changes(old_content: &str, new_content: &str) -> (usize, usize) {
10536    count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, false)
10537}
10538
10539/// Count insertions and deletions using the given line-diff algorithm.
10540///
10541/// Git's `--stat` / `--numstat` follow the configured diff algorithm; this mirrors that by
10542/// running [`similar::TextDiff`] with an explicit [`similar::Algorithm`].
10543#[must_use]
10544pub fn count_changes_with_algorithm(
10545    old_content: &str,
10546    new_content: &str,
10547    algorithm: similar::Algorithm,
10548    use_git_histogram: bool,
10549) -> (usize, usize) {
10550    if use_git_histogram {
10551        use imara_diff::{Algorithm, Diff, InternedInput};
10552        let input = InternedInput::new(old_content, new_content);
10553        let mut d = Diff::compute(Algorithm::Histogram, &input);
10554        d.postprocess_lines(&input);
10555        return (d.count_additions() as usize, d.count_removals() as usize);
10556    }
10557
10558    use similar::{ChangeTag, TextDiff};
10559
10560    let diff = TextDiff::configure()
10561        .algorithm(algorithm)
10562        .diff_lines(old_content, new_content);
10563    let mut ins = 0;
10564    let mut del = 0;
10565
10566    for change in diff.iter_all_changes() {
10567        match change.tag() {
10568            ChangeTag::Insert => ins += 1,
10569            ChangeTag::Delete => del += 1,
10570            ChangeTag::Equal => {}
10571        }
10572    }
10573
10574    (ins, del)
10575}
10576
10577/// Count insertions and deletions using Git's histogram line-diff engine.
10578#[must_use]
10579pub fn count_changes_git_histogram(old_content: &str, new_content: &str) -> (usize, usize) {
10580    count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, true)
10581}
10582
10583/// Line count for diffstat/`--numstat`, matching Git's `count_lines()` in `diff.c`.
10584///
10585/// Counts newline-terminated lines; a final line without trailing newline still counts as one line.
10586/// An empty buffer yields `0`.
10587#[must_use]
10588pub fn count_git_lines(data: &[u8]) -> usize {
10589    if data.is_empty() {
10590        return 0;
10591    }
10592    let mut count = 0usize;
10593    let mut nl_just_seen = false;
10594    for &ch in data {
10595        if ch == b'\n' {
10596            count += 1;
10597            nl_just_seen = true;
10598        } else {
10599            nl_just_seen = false;
10600        }
10601    }
10602    if !nl_just_seen {
10603        count += 1;
10604    }
10605    count
10606}
10607
10608/// Internal maximum diff score used by Git rename/break heuristics (`MAX_SCORE` in `diffcore.h`).
10609pub const GIT_DIFF_MAX_SCORE: u64 = 60_000;
10610const DIFF_MAX_SCORE: u64 = GIT_DIFF_MAX_SCORE;
10611const DIFF_MINIMUM_BREAK_SIZE: usize = 400;
10612const DIFF_DEFAULT_BREAK_SCORE: u64 = 30_000;
10613/// Default break threshold (`DEFAULT_BREAK_SCORE` in `diffcore.h`), internal 0–[`GIT_DIFF_MAX_SCORE`] scale.
10614pub const GIT_DIFF_DEFAULT_BREAK_SCORE: u64 = DIFF_DEFAULT_BREAK_SCORE;
10615/// Default merge threshold after a break (`DEFAULT_MERGE_SCORE` in `diffcore.h`): pairs broken for
10616/// rename/copy but not consumed are merged back when deletion-weight is below this (60% by default).
10617pub const GIT_DIFF_DEFAULT_MERGE_SCORE_AFTER_BREAK: u64 = 36_000;
10618const DIFF_HASHBASE: u32 = 107_927;
10619
10620#[derive(Clone, Copy, Default)]
10621struct SpanSlot {
10622    hashval: u32,
10623    cnt: u32,
10624}
10625
10626struct SpanHashTop {
10627    alloc_log2: u8,
10628    free_slots: i32,
10629    data: Vec<SpanSlot>,
10630}
10631
10632impl SpanHashTop {
10633    fn new(initial_log2: u8) -> Self {
10634        let cap = 1usize << initial_log2;
10635        Self {
10636            alloc_log2: initial_log2,
10637            free_slots: initial_free(initial_log2),
10638            data: vec![SpanSlot::default(); cap],
10639        }
10640    }
10641
10642    fn len(&self) -> usize {
10643        1usize << self.alloc_log2
10644    }
10645
10646    fn add_span(&mut self, hashval: u32, cnt: u32) {
10647        loop {
10648            let lim = self.len();
10649            let mut bucket = (hashval as usize) & (lim - 1);
10650            loop {
10651                let h = &mut self.data[bucket];
10652                if h.cnt == 0 {
10653                    h.hashval = hashval;
10654                    h.cnt = cnt;
10655                    self.free_slots -= 1;
10656                    if self.free_slots < 0 {
10657                        self.rehash();
10658                        break;
10659                    }
10660                    return;
10661                }
10662                if h.hashval == hashval {
10663                    h.cnt = h.cnt.saturating_add(cnt);
10664                    return;
10665                }
10666                bucket += 1;
10667                if bucket >= lim {
10668                    bucket = 0;
10669                }
10670            }
10671        }
10672    }
10673
10674    fn rehash(&mut self) {
10675        let old = std::mem::take(&mut self.data);
10676        let old_log = self.alloc_log2;
10677        self.alloc_log2 = old_log.saturating_add(1);
10678        let new_len = 1usize << self.alloc_log2;
10679        self.free_slots = initial_free(self.alloc_log2);
10680        self.data = vec![SpanSlot::default(); new_len];
10681        let old_sz = 1usize << old_log;
10682        for o in old.iter().take(old_sz) {
10683            let o = *o;
10684            if o.cnt == 0 {
10685                continue;
10686            }
10687            self.add_span_after_rehash(o.hashval, o.cnt);
10688        }
10689    }
10690
10691    fn add_span_after_rehash(&mut self, hashval: u32, cnt: u32) {
10692        loop {
10693            let lim = self.len();
10694            let mut bucket = (hashval as usize) & (lim - 1);
10695            loop {
10696                let h = &mut self.data[bucket];
10697                if h.cnt == 0 {
10698                    h.hashval = hashval;
10699                    h.cnt = cnt;
10700                    self.free_slots -= 1;
10701                    if self.free_slots < 0 {
10702                        self.rehash();
10703                        break;
10704                    }
10705                    return;
10706                }
10707                if h.hashval == hashval {
10708                    h.cnt = h.cnt.saturating_add(cnt);
10709                    return;
10710                }
10711                bucket += 1;
10712                if bucket >= lim {
10713                    bucket = 0;
10714                }
10715            }
10716        }
10717    }
10718
10719    fn sort_by_hashval(&mut self) {
10720        let sz = self.len();
10721        self.data[..sz].sort_by(|a, b| {
10722            if a.cnt == 0 {
10723                return std::cmp::Ordering::Greater;
10724            }
10725            if b.cnt == 0 {
10726                return std::cmp::Ordering::Less;
10727            }
10728            a.hashval.cmp(&b.hashval)
10729        });
10730    }
10731}
10732
10733fn initial_free(sz_log2: u8) -> i32 {
10734    let sz = sz_log2 as i32;
10735    ((1i32 << sz_log2) * (sz - 3) / sz).max(0)
10736}
10737
10738fn hash_blob_spans(buf: &[u8], is_text: bool) -> SpanHashTop {
10739    let mut hash = SpanHashTop::new(9);
10740    let mut n = 0u32;
10741    let mut accum1: u32 = 0;
10742    let mut accum2: u32 = 0;
10743    let mut i = 0usize;
10744    while i < buf.len() {
10745        let c = buf[i] as u32;
10746        let old_1 = accum1;
10747        i += 1;
10748
10749        if is_text && c == b'\r' as u32 && i < buf.len() && buf[i] == b'\n' {
10750            continue;
10751        }
10752
10753        accum1 = accum1.wrapping_shl(7) ^ accum2.wrapping_shr(25);
10754        accum2 = accum2.wrapping_shl(7) ^ old_1.wrapping_shr(25);
10755        accum1 = accum1.wrapping_add(c);
10756        n += 1;
10757        if n < 64 && c != b'\n' as u32 {
10758            continue;
10759        }
10760        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
10761        hash.add_span(hashval, n);
10762        n = 0;
10763        accum1 = 0;
10764        accum2 = 0;
10765    }
10766    if n > 0 {
10767        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
10768        hash.add_span(hashval, n);
10769    }
10770    hash.sort_by_hashval();
10771    hash
10772}
10773
10774/// Approximate copied vs added material between two blobs (Git `diffcore_count_changes`).
10775///
10776/// Returns `(copied_bytes_from_src, literal_added_bytes_in_dst)` matching Git's
10777/// `diffcore_count_changes` semantics (used for `--dirstat=changes` damage).
10778#[must_use]
10779pub fn diffcore_count_changes(old: &[u8], new: &[u8]) -> (u64, u64) {
10780    crate::trace::trace_span!("grit.diff.blob");
10781    let src_is_text = !crate::merge_file::is_binary(old);
10782    let dst_is_text = !crate::merge_file::is_binary(new);
10783    let src_count = hash_blob_spans(old, src_is_text);
10784    let dst_count = hash_blob_spans(new, dst_is_text);
10785    let mut sc: u64 = 0;
10786    let mut la: u64 = 0;
10787    let mut si = 0usize;
10788    let mut di = 0usize;
10789    let src_len = src_count.len();
10790    let dst_len = dst_count.len();
10791    loop {
10792        if si >= src_len || src_count.data[si].cnt == 0 {
10793            break;
10794        }
10795        let s_hash = src_count.data[si].hashval;
10796        let s_cnt = u64::from(src_count.data[si].cnt);
10797        while di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval < s_hash {
10798            la += u64::from(dst_count.data[di].cnt);
10799            di += 1;
10800        }
10801        let mut dst_cnt = 0u64;
10802        if di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval == s_hash {
10803            dst_cnt = u64::from(dst_count.data[di].cnt);
10804            di += 1;
10805        }
10806        if s_cnt < dst_cnt {
10807            la += dst_cnt - s_cnt;
10808            sc += s_cnt;
10809        } else {
10810            sc += dst_cnt;
10811        }
10812        si += 1;
10813    }
10814    while di < dst_len && dst_count.data[di].cnt != 0 {
10815        la += u64::from(dst_count.data[di].cnt);
10816        di += 1;
10817    }
10818    (sc, la)
10819}
10820
10821/// Whether this modified blob pair should use Git's "complete rewrite" diffstat path when
10822/// `--break-rewrites` is in effect (`should_break` in `diffcore-break.c`).
10823#[must_use]
10824pub fn should_break_rewrite_for_stat(old: &[u8], new: &[u8]) -> bool {
10825    should_break_rewrite_inner(old, new, DIFF_DEFAULT_BREAK_SCORE)
10826}
10827
10828/// Whether an in-place blob edit should be split into delete+create for rename/copy (`should_break`
10829/// in `diffcore-break.c`). `break_score` is on the internal 0–[`GIT_DIFF_MAX_SCORE`] scale (default
10830/// `DIFF_DEFAULT_BREAK_SCORE`).
10831#[must_use]
10832pub fn should_break_rewrite_pair(old: &[u8], new: &[u8], break_score: u64) -> bool {
10833    should_break_rewrite_inner(old, new, break_score)
10834}
10835
10836/// Drop entries that do not match `filter` (Git `--diff-filter`, after rename detection).
10837#[must_use]
10838pub fn apply_diff_filter(
10839    entries: Vec<DiffEntry>,
10840    filter: Option<super::filter::DiffFilter>,
10841) -> Vec<DiffEntry> {
10842    match filter {
10843        Some(f) => f.filter_entries(entries),
10844        None => entries,
10845    }
10846}
10847
10848/// Parse `-M` / `--find-renames` threshold into Git's 0–100 similarity percent.
10849#[must_use]
10850pub fn parse_diff_rename_threshold_percent(arg: &str) -> Option<u8> {
10851    let token = if arg.is_empty() { "50" } else { arg };
10852    let internal = parse_diff_rename_score_token(token)?;
10853    Some((internal.saturating_mul(100) / GIT_DIFF_MAX_SCORE).min(100) as u8)
10854}
10855
10856/// Parse a single Git `parse_rename_score` token (`50`, `50%`, decimal forms) into internal
10857/// 0–[`GIT_DIFF_MAX_SCORE`] units.
10858pub fn parse_diff_rename_score_token(arg: &str) -> Option<u64> {
10859    let mut num: u64 = 0;
10860    let mut scale: u64 = 1;
10861    let mut dot = false;
10862    let mut saw_digit = false;
10863    for ch in arg.chars() {
10864        if !dot && ch == '.' {
10865            scale = 1;
10866            dot = true;
10867            continue;
10868        }
10869        if ch == '%' {
10870            scale = if dot { scale.saturating_mul(100) } else { 100 };
10871            break;
10872        }
10873        if ch.is_ascii_digit() {
10874            saw_digit = true;
10875            if scale < 100_000 {
10876                scale = scale.saturating_mul(10);
10877                num = num.saturating_mul(10) + u64::from(ch as u8 - b'0');
10878            }
10879        } else {
10880            break;
10881        }
10882    }
10883    if !saw_digit {
10884        return None;
10885    }
10886    Some(if num >= scale {
10887        GIT_DIFF_MAX_SCORE
10888    } else {
10889        GIT_DIFF_MAX_SCORE * num / scale
10890    })
10891}
10892
10893/// Git `merge_score` from `diffcore-break.c` when a pair is considered broken: how much of the
10894/// source blob was removed (0–`DIFF_MAX_SCORE` scale). Used for `dissimilarity index` metadata.
10895#[must_use]
10896pub fn rewrite_merge_score(old: &[u8], new: &[u8]) -> Option<u64> {
10897    if old.is_empty() {
10898        return None;
10899    }
10900    let max_size = old.len().max(new.len());
10901    if max_size < DIFF_MINIMUM_BREAK_SIZE {
10902        return None;
10903    }
10904    let (src_copied, _) = diffcore_count_changes(old, new);
10905    let src_copied = src_copied.min(old.len() as u64);
10906    let src_removed = (old.len() as u64).saturating_sub(src_copied);
10907    Some(src_removed * DIFF_MAX_SCORE / old.len() as u64)
10908}
10909
10910/// Percentage shown in `dissimilarity index N%` for a rewrite (`similarity_index` in Git's diff.c).
10911#[must_use]
10912pub fn rewrite_dissimilarity_index_percent(old: &[u8], new: &[u8]) -> Option<u32> {
10913    let score = rewrite_merge_score(old, new)?;
10914    Some((score * 100 / DIFF_MAX_SCORE).min(100) as u32)
10915}
10916
10917fn should_break_rewrite_inner(src: &[u8], dst: &[u8], break_score: u64) -> bool {
10918    if src.is_empty() {
10919        return false;
10920    }
10921    let max_size = src.len().max(dst.len());
10922    if max_size < DIFF_MINIMUM_BREAK_SIZE {
10923        return false;
10924    }
10925    let (src_copied, literal_added) = diffcore_count_changes(src, dst);
10926    let src_copied = src_copied.min(src.len() as u64);
10927    let mut literal_added = literal_added;
10928    let dst_len = dst.len() as u64;
10929    if src_copied < dst_len && literal_added + src_copied > dst_len {
10930        literal_added = dst_len.saturating_sub(src_copied);
10931    }
10932    let src_removed = (src.len() as u64).saturating_sub(src_copied);
10933    let merge_score = src_removed * DIFF_MAX_SCORE / src.len() as u64;
10934    if merge_score > break_score {
10935        return true;
10936    }
10937    let delta_size = src_removed.saturating_add(literal_added);
10938    if delta_size * DIFF_MAX_SCORE / (max_size as u64) < break_score {
10939        return false;
10940    }
10941    let s = src.len() as u64;
10942    if (s * break_score < src_removed * DIFF_MAX_SCORE)
10943        && (literal_added * 20 < src_removed)
10944        && (literal_added * 20 < src_copied)
10945    {
10946        return false;
10947    }
10948    true
10949}
10950
10951// ── Helpers ─────────────────────────────────────────────────────────
10952
10953/// Flatten a tree object recursively into a sorted list of (path, mode, oid).
10954struct FlatEntry {
10955    path: String,
10956    mode: u32,
10957    oid: ObjectId,
10958}
10959
10960fn flatten_tree(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Result<Vec<FlatEntry>> {
10961    let entries = read_tree(odb, tree_oid)?;
10962    let mut result = Vec::new();
10963
10964    for entry in entries {
10965        let name_str = String::from_utf8_lossy(&entry.name);
10966        let path = format_path(prefix, &name_str);
10967        if is_tree_mode(entry.mode) {
10968            let nested = flatten_tree(odb, &entry.oid, &path)?;
10969            result.extend(nested);
10970        } else {
10971            result.push(FlatEntry {
10972                path,
10973                mode: entry.mode,
10974                oid: entry.oid,
10975            });
10976        }
10977    }
10978
10979    Ok(result)
10980}
10981
10982/// Paths present in `HEAD`'s tree with mode and blob/commit OID (for status porcelain v2).
10983pub fn head_path_states(
10984    odb: &Odb,
10985    head_tree: Option<&ObjectId>,
10986) -> Result<std::collections::BTreeMap<String, (u32, ObjectId)>> {
10987    let mut m = std::collections::BTreeMap::new();
10988    let Some(t) = head_tree else {
10989        return Ok(m);
10990    };
10991    for fe in flatten_tree(odb, t, "")? {
10992        m.insert(fe.path, (fe.mode, fe.oid));
10993    }
10994    Ok(m)
10995}
10996
10997/// Whether a mode represents a tree (directory).
10998fn is_tree_mode(mode: u32) -> bool {
10999    mode == 0o040000
11000}
11001
11002/// Git orders diff lists by raw path bytes, which differs from the stored form of
11003/// non-UTF-8 paths (see [`crate::repo_relpath::storage_bytes`]).
11004fn path_order(a: &DiffEntry, b: &DiffEntry) -> std::cmp::Ordering {
11005    use crate::repo_relpath::storage_bytes;
11006    storage_bytes(a.path()).cmp(&storage_bytes(b.path()))
11007}
11008
11009fn entries_sorted_by_path(entries: &[DiffEntry]) -> bool {
11010    entries.is_sorted_by(|a, b| path_order(a, b).is_le())
11011}
11012
11013fn entries_strictly_sorted_by_path(entries: &[DiffEntry]) -> bool {
11014    entries.is_sorted_by(|a, b| path_order(a, b).is_lt())
11015}
11016
11017/// Stable-sort diff entries by [`DiffEntry::path`] in raw byte order, Git's output order
11018/// for diff lists (non-UTF-8 paths compare by their decoded bytes).
11019///
11020/// Tree diffs and rename detection already return entries in this order, so the common
11021/// case is a single comparison pass with no allocation (a stable sort would otherwise
11022/// allocate scratch space proportional to the list).
11023///
11024/// # Parameters
11025///
11026/// - `entries`: the list to order in place; entries with equal paths keep their order.
11027pub fn sort_entries_by_path(entries: &mut [DiffEntry]) {
11028    if entries_sorted_by_path(entries) {
11029        return;
11030    }
11031    entries.sort_by(path_order);
11032}
11033
11034/// Build a path with an optional prefix.
11035fn format_path(prefix: &str, name: &str) -> String {
11036    if prefix.is_empty() {
11037        name.to_owned()
11038    } else {
11039        let mut path = String::with_capacity(prefix.len() + 1 + name.len());
11040        path.push_str(prefix);
11041        path.push('/');
11042        path.push_str(name);
11043        path
11044    }
11045}
11046
11047/// Format a numeric mode as a zero-padded octal string.
11048pub fn format_mode(mode: u32) -> String {
11049    format!("{mode:06o}")
11050}
11051
11052/// Read the HEAD commit OID from a submodule checkout directory.
11053///
11054/// Returns `None` if the path is missing, not a submodule checkout, or has no resolvable HEAD.
11055#[must_use]
11056pub fn read_submodule_head_for_checkout(sub_dir: &Path) -> Option<ObjectId> {
11057    read_submodule_head(sub_dir)
11058}
11059
11060/// First line of a commit's message for `git diff --submodule=log` output.
11061///
11062/// Honors `encoding` in the commit object (Latin-1 vs UTF-8) using the same
11063/// rules as Git's submodule summary.
11064#[must_use]
11065pub fn submodule_commit_subject_line(c: &CommitData) -> String {
11066    let enc = c.encoding.as_deref().unwrap_or("UTF-8");
11067    let is_latin1 = enc.eq_ignore_ascii_case("ISO8859-1")
11068        || enc.eq_ignore_ascii_case("ISO-8859-1")
11069        || enc.eq_ignore_ascii_case("LATIN1")
11070        || enc.eq_ignore_ascii_case("ISO-8859-15");
11071    if let Some(raw) = c.raw_message.as_deref() {
11072        let line = raw.split(|b| *b == b'\n').next().unwrap_or(raw);
11073        if is_latin1 {
11074            return line
11075                .iter()
11076                .map(|&b| b as char)
11077                .collect::<String>()
11078                .trim()
11079                .to_owned();
11080        }
11081        return String::from_utf8_lossy(line).trim().to_string();
11082    }
11083    c.message.lines().next().unwrap_or("").trim().to_owned()
11084}
11085
11086/// True when `sub_dir` is an empty directory (or missing), i.e. the placeholder left by
11087/// `git apply --index` before `git submodule update`.
11088fn submodule_worktree_is_unpopulated_placeholder(sub_dir: &Path) -> bool {
11089    match fs::read_dir(sub_dir) {
11090        Ok(mut it) => it.next().is_none(),
11091        Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
11092        Err(_) => false,
11093    }
11094}
11095
11096fn read_submodule_head(sub_dir: &Path) -> Option<ObjectId> {
11097    read_submodule_head_oid(sub_dir)
11098}
11099
11100/// Resolve the embedded git directory for a submodule work tree (`sub_dir/.git`).
11101#[must_use]
11102pub fn submodule_embedded_git_dir(sub_dir: &Path) -> Option<PathBuf> {
11103    let gitfile = sub_dir.join(".git");
11104    if gitfile.is_file() {
11105        let content = fs::read_to_string(&gitfile).ok()?;
11106        let gitdir = content
11107            .lines()
11108            .find_map(|l| l.strip_prefix("gitdir: "))?
11109            .trim();
11110        Some(if Path::new(gitdir).is_absolute() {
11111            PathBuf::from(gitdir)
11112        } else {
11113            sub_dir.join(gitdir)
11114        })
11115    } else if gitfile.is_dir() {
11116        Some(gitfile)
11117    } else {
11118        None
11119    }
11120}
11121
11122/// Whether `abs` is a nested Git checkout (embedded `.git`) not already indexed as a gitlink.
11123///
11124/// Matches Git's `is_nonbare_repository_dir` / `is_git_directory`: the embedded git dir must look
11125/// like a real repository (valid `HEAD` plus `objects/`, or `objects/` and `refs/`), not a bare
11126/// `.git` directory placeholder.
11127#[must_use]
11128pub(crate) fn is_unregistered_embedded_git_repo(index: &Index, rel: &str, abs: &Path) -> bool {
11129    if rel.is_empty() {
11130        return false;
11131    }
11132    if index
11133        .get(rel.as_bytes(), 0)
11134        .is_some_and(|e| e.mode == crate::index::MODE_GITLINK)
11135    {
11136        return false;
11137    }
11138    let Some(git_dir) = submodule_embedded_git_dir(abs) else {
11139        return false;
11140    };
11141    crate::submodule_gitdir::is_valid_git_dir(&git_dir)
11142}
11143
11144/// Walk upward from `sub_dir` to find the nearest containing Git work tree.
11145fn find_superproject_git(sub_dir: &Path) -> Option<(PathBuf, PathBuf)> {
11146    let mut cur = sub_dir.parent()?;
11147    loop {
11148        let git_path = cur.join(".git");
11149        if git_path.exists() {
11150            let gd = if git_path.is_file() {
11151                let content = fs::read_to_string(&git_path).ok()?;
11152                let line = content
11153                    .lines()
11154                    .find_map(|l| l.strip_prefix("gitdir: "))?
11155                    .trim();
11156                if Path::new(line).is_absolute() {
11157                    PathBuf::from(line)
11158                } else {
11159                    cur.join(line)
11160                }
11161            } else {
11162                git_path
11163            };
11164            return Some((cur.to_path_buf(), gd));
11165        }
11166        cur = cur.parent()?;
11167    }
11168}
11169
11170/// Read the HEAD commit OID from a submodule working tree directory.
11171///
11172/// Handles both embedded `.git` directories and `gitdir:` gitfiles pointing at
11173/// `.git/modules/...` (or other locations). Returns `None` if the path is not
11174/// a checkout or has no resolvable HEAD.
11175pub fn read_submodule_head_oid(sub_dir: &Path) -> Option<ObjectId> {
11176    // Submodule `.git` may be a gitfile pointing at `.git/modules/<name>` in another superproject
11177    // after `cp -R`. Prefer the current superproject's module dir when present.
11178    let mut git_dir = submodule_embedded_git_dir(sub_dir)?;
11179    if let Some((super_wt, super_git_dir)) = find_superproject_git(sub_dir) {
11180        let rel = sub_dir.strip_prefix(&super_wt).ok()?;
11181        let rel_str = rel.to_string_lossy().replace('\\', "/");
11182        let local_mod = super_git_dir
11183            .join("modules")
11184            .join(rel_str.trim_start_matches('/'));
11185        if local_mod.join("HEAD").exists() {
11186            let sg = super_git_dir
11187                .canonicalize_simplified()
11188                .unwrap_or(super_git_dir);
11189            let cur = git_dir
11190                .canonicalize_simplified()
11191                .unwrap_or_else(|_| git_dir.clone());
11192            if !cur.starts_with(&sg) {
11193                git_dir = local_mod;
11194            }
11195        }
11196    }
11197    let head_content = fs::read_to_string(git_dir.join("HEAD")).ok()?;
11198    let head_trimmed = head_content.trim();
11199    if head_trimmed.starts_with("ref: ") {
11200        // Use the full ref resolver so packed-refs and worktrees match Git. If `HEAD` is a stale
11201        // symref (e.g. still `refs/heads/master` while only `main` exists), fall back like
11202        // `resolve_gitlink_ref` / `git add` on embedded repos (`t6437-submodule-merge`).
11203        match crate::refs::resolve_ref(&git_dir, "HEAD") {
11204            Ok(oid) => Some(oid),
11205            Err(_) => {
11206                let mut found = None;
11207                for branch in ["main", "master"] {
11208                    let p = git_dir.join("refs/heads").join(branch);
11209                    if let Ok(s) = fs::read_to_string(&p) {
11210                        if let Ok(o) = ObjectId::from_hex(s.trim()) {
11211                            found = Some(o);
11212                            break;
11213                        }
11214                    }
11215                }
11216                found
11217            }
11218        }
11219    } else {
11220        ObjectId::from_hex(head_trimmed).ok()
11221    }
11222}
11223
11224/// True when a populated submodule checkout is *broken*: its `HEAD` resolves to a commit OID, but
11225/// that commit object cannot be read from the submodule's own object database.
11226///
11227/// This mirrors Git's `is_submodule_modified`, which shells out to `git status --porcelain=2`
11228/// inside the submodule; when the submodule's object store is corrupt (e.g. `rm -r .git/objects`),
11229/// that inner status fails and Git aborts the surrounding `status`/`diff`/`fetch`. We detect the
11230/// same condition in-process so the superproject operation can return a fatal error rather than
11231/// silently treating the submodule as clean (t5526 "fetching submodule into a broken repository").
11232///
11233/// Returns `false` when the submodule is not checked out, has no embedded git dir, or has an
11234/// unresolvable HEAD (those are handled separately by the unpopulated/placeholder logic).
11235#[must_use]
11236pub fn submodule_head_object_broken(sub_dir: &Path) -> bool {
11237    let Some(sub_git_dir) = submodule_embedded_git_dir(sub_dir) else {
11238        return false;
11239    };
11240    let Some(head_oid) = read_submodule_head_oid(sub_dir) else {
11241        return false;
11242    };
11243    let odb = Odb::new(&sub_git_dir.join("objects"));
11244    match odb.read(&head_oid) {
11245        // HEAD object present but not a commit would be a different kind of corruption; only the
11246        // missing-object case is the broken-repo scenario Git guards against here.
11247        Ok(obj) => parse_commit(&obj.data).is_err(),
11248        Err(_) => true,
11249    }
11250}
11251
11252/// Submodule dirty bits aligned with Git's `DIRTY_SUBMODULE_*` / porcelain v2 `S???` token.
11253#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
11254pub struct SubmodulePorcelainFlags {
11255    /// Submodule checkout HEAD differs from the gitlink OID recorded in the parent index.
11256    pub new_commits: bool,
11257    /// The submodule has its own staged or unstaged changes (`DIRTY_SUBMODULE_MODIFIED`).
11258    pub modified: bool,
11259    /// The submodule work tree contains paths not in its index (`DIRTY_SUBMODULE_UNTRACKED`).
11260    pub untracked: bool,
11261}
11262
11263/// Inspect a checked-out submodule at `rel_path` (relative to `super_worktree`) and return
11264/// flags used for `git status --porcelain=v2` submodule tokens.
11265///
11266/// `recorded_oid` is the gitlink OID stored in the **parent** index (stage 0). When the
11267/// submodule is not checked out or cannot be opened, returns [`Default::default()`].
11268pub fn submodule_porcelain_flags(
11269    super_worktree: &Path,
11270    rel_path: &str,
11271    recorded_oid: ObjectId,
11272) -> SubmodulePorcelainFlags {
11273    let sub_dir = super_worktree.join(rel_path);
11274    let Some(sub_git_dir) = submodule_embedded_git_dir(&sub_dir) else {
11275        return SubmodulePorcelainFlags::default();
11276    };
11277    let Some(sub_head) = read_submodule_head_oid(&sub_dir) else {
11278        return SubmodulePorcelainFlags::default();
11279    };
11280
11281    let new_commits = sub_head != recorded_oid;
11282
11283    let index_path = sub_git_dir.join("index");
11284    let mut sub_index = match crate::index::Index::load(&index_path) {
11285        Ok(ix) => ix,
11286        Err(_) => {
11287            return SubmodulePorcelainFlags {
11288                new_commits,
11289                ..Default::default()
11290            }
11291        }
11292    };
11293
11294    let tracked: std::collections::BTreeSet<String> = sub_index
11295        .entries
11296        .iter()
11297        .filter(|e| e.stage() == 0)
11298        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
11299        .collect();
11300    let untracked = submodule_dir_has_untracked_inner(&sub_dir, &sub_dir, &tracked, &sub_index);
11301
11302    let objects_dir = sub_git_dir.join("objects");
11303    let odb = Odb::new(&objects_dir);
11304
11305    let sub_head_tree = (|| -> Option<ObjectId> {
11306        let h = fs::read_to_string(sub_git_dir.join("HEAD")).ok()?;
11307        let h_str = h.trim();
11308        let commit_oid = if let Some(r) = h_str.strip_prefix("ref: ") {
11309            let oid_hex = fs::read_to_string(sub_git_dir.join(r)).ok()?;
11310            ObjectId::from_hex(oid_hex.trim()).ok()?
11311        } else {
11312            ObjectId::from_hex(h_str).ok()?
11313        };
11314        let obj = odb.read(&commit_oid).ok()?;
11315        let commit = parse_commit(&obj.data).ok()?;
11316        Some(commit.tree)
11317    })();
11318
11319    let staged_dirty = sub_head_tree
11320        .as_ref()
11321        .map(|t| diff_index_to_tree(&odb, &sub_index, Some(t), false).map(|v| !v.is_empty()))
11322        .unwrap_or(Ok(false));
11323    let staged_dirty = staged_dirty.unwrap_or(false);
11324
11325    let unstaged_dirty = diff_index_to_worktree(&odb, &mut sub_index, &sub_dir, false, true)
11326        .map(|v| !v.is_empty())
11327        .unwrap_or(false);
11328
11329    let mut modified = staged_dirty || unstaged_dirty;
11330
11331    // Nested submodule has its own index: OR `modified` from immediate gitlink children so a
11332    // dirty nested checkout (e.g. staged `file` under `sub1/sub2`) marks the parent gitlink as
11333    // modified in the superproject (t7506). Do **not** OR `untracked` — untracked-only inside a
11334    // nested submodule must stay `S..U` on the parent, not `S.U` / `S.M.`.
11335    for e in &sub_index.entries {
11336        if e.stage() != 0 || e.mode != 0o160000 {
11337            continue;
11338        }
11339        let child = String::from_utf8_lossy(&e.path).into_owned();
11340        let full_rel = if rel_path.is_empty() {
11341            child
11342        } else {
11343            format!("{rel_path}/{child}")
11344        };
11345        let nested = submodule_porcelain_flags(super_worktree, &full_rel, e.oid);
11346        modified |= nested.modified;
11347    }
11348
11349    SubmodulePorcelainFlags {
11350        new_commits,
11351        modified,
11352        untracked,
11353    }
11354}
11355
11356fn submodule_dir_has_untracked_inner(
11357    dir: &Path,
11358    root: &Path,
11359    tracked: &std::collections::BTreeSet<String>,
11360    owning_index: &Index,
11361) -> bool {
11362    let entries = match fs::read_dir(dir) {
11363        Ok(e) => e,
11364        Err(_) => return false,
11365    };
11366    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
11367    sorted.sort_by_key(|e| e.file_name());
11368
11369    for entry in sorted {
11370        let name = entry.file_name().to_string_lossy().to_string();
11371        if name == ".git" {
11372            continue;
11373        }
11374        let path = entry.path();
11375        let rel = path
11376            .strip_prefix(root)
11377            .map(|p| p.to_string_lossy().to_string())
11378            .unwrap_or_else(|_| name.clone());
11379
11380        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
11381        if is_dir {
11382            let is_gitlink = owning_index
11383                .get(rel.as_bytes(), 0)
11384                .is_some_and(|e| e.mode == 0o160000);
11385            if is_gitlink {
11386                let Some(nested_git) = submodule_embedded_git_dir(&path) else {
11387                    continue;
11388                };
11389                let nested_index_path = nested_git.join("index");
11390                let Ok(nested_ix) = crate::index::Index::load(&nested_index_path) else {
11391                    continue;
11392                };
11393                let nested_tracked: std::collections::BTreeSet<String> = nested_ix
11394                    .entries
11395                    .iter()
11396                    .filter(|e| e.stage() == 0)
11397                    .map(|e| String::from_utf8_lossy(&e.path).into_owned())
11398                    .collect();
11399                if submodule_dir_has_untracked_inner(&path, &path, &nested_tracked, &nested_ix) {
11400                    return true;
11401                }
11402            } else if submodule_dir_has_untracked_inner(&path, root, tracked, owning_index) {
11403                return true;
11404            }
11405        } else if !tracked.contains(&rel) {
11406            return true;
11407        }
11408    }
11409    false
11410}
11411
11412/// Reorder diff entries by a `-O<orderfile>` (`diff.orderFile`): entries are sorted by the index of
11413/// the first orderfile glob pattern that matches their path; unmatched entries sort last (stable).
11414pub fn apply_orderfile_entries(
11415    entries: Vec<DiffEntry>,
11416    order_path: &str,
11417    cwd: &Path,
11418) -> Result<Vec<DiffEntry>> {
11419    apply_orderfile(entries, order_path, cwd)
11420}
11421
11422fn apply_orderfile(
11423    mut entries: Vec<DiffEntry>,
11424    order_path: &str,
11425    cwd: &Path,
11426) -> Result<Vec<DiffEntry>> {
11427    let patterns = read_orderfile_patterns(order_path, cwd)?;
11428    let sort_key = |entry: &DiffEntry| -> usize {
11429        let path = entry
11430            .new_path
11431            .as_ref()
11432            .or(entry.old_path.as_ref())
11433            .cloned()
11434            .unwrap_or_default();
11435        for (i, pat) in patterns.iter().enumerate() {
11436            if orderfile_pattern_matches(pat, &path) {
11437                return i;
11438            }
11439        }
11440        patterns.len()
11441    };
11442    entries.sort_by_key(|e| sort_key(e));
11443    Ok(entries)
11444}
11445
11446/// Read non-empty, non-comment glob patterns (one per line) from a `-O<orderfile>` file.
11447pub fn read_orderfile_patterns(order_path: &str, cwd: &Path) -> Result<Vec<String>> {
11448    let path = Path::new(order_path);
11449    let resolved = if path.is_absolute() {
11450        path.to_path_buf()
11451    } else {
11452        cwd.join(path)
11453    };
11454    let _meta = std::fs::metadata(&resolved)
11455        .map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
11456    let mut f = std::fs::File::open(&resolved)
11457        .map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
11458    let mut content = String::new();
11459    std::io::Read::read_to_string(&mut f, &mut content)
11460        .map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
11461    Ok(content
11462        .lines()
11463        .map(|l| l.trim().to_string())
11464        .filter(|l| !l.is_empty() && !l.starts_with('#'))
11465        .collect())
11466}
11467
11468/// Reorder diff entries for `git diff` `--rotate-to` / `--skip-to` (changed paths only).
11469pub fn apply_rotate_skip_entries(
11470    mut entries: Vec<DiffEntry>,
11471    rotate_to: Option<&str>,
11472    skip_to: Option<&str>,
11473) -> Result<Vec<DiffEntry>> {
11474    let Some(needle) = rotate_to.or(skip_to) else {
11475        return Ok(entries);
11476    };
11477    let needle = needle.trim();
11478    if needle.is_empty() {
11479        return Ok(entries);
11480    }
11481    let idx = entries
11482        .iter()
11483        .position(|e| e.path() == needle)
11484        .ok_or_else(|| {
11485            Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11486                path: needle.to_owned(),
11487            })
11488        })?;
11489    if rotate_to.is_some() {
11490        entries.rotate_left(idx);
11491    }
11492    if let Some(skip) = skip_to.filter(|s| !s.trim().is_empty()) {
11493        let pos = entries
11494            .iter()
11495            .position(|e| e.path() == skip)
11496            .ok_or_else(|| {
11497                Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11498                    path: skip.to_owned(),
11499                })
11500            })?;
11501        entries.drain(..pos);
11502    }
11503    Ok(entries)
11504}
11505
11506/// `git log` rotate/skip: reorder using the **commit tree** path order (all blobs), then keep only
11507/// paths present in `entries` — matches Git's `diff --rotate-to` with history walks.
11508pub fn apply_rotate_skip_log_entries(
11509    odb: &Odb,
11510    commit_tree: &ObjectId,
11511    entries: Vec<DiffEntry>,
11512    rotate_to: Option<&str>,
11513    skip_to: Option<&str>,
11514) -> Result<Vec<DiffEntry>> {
11515    // Without --rotate-to/--skip-to this is a no-op (the ordered-paths helper
11516    // returns the entries untouched), so skip the full-tree walk that
11517    // `all_blob_paths_in_tree_order` performs — per displayed commit it read
11518    // every subtree of the commit's tree just to discard the result.
11519    fn trimmed(s: Option<&str>) -> Option<&str> {
11520        s.map(str::trim).filter(|t| !t.is_empty())
11521    }
11522    if trimmed(rotate_to).is_none() && trimmed(skip_to).is_none() {
11523        return Ok(entries);
11524    }
11525    let tree_paths = crate::merge_diff::all_blob_paths_in_tree_order(odb, commit_tree);
11526    apply_rotate_skip_ordered_paths(&tree_paths, entries, rotate_to, skip_to)
11527}
11528
11529fn apply_rotate_skip_ordered_paths(
11530    tree_paths: &[String],
11531    entries: Vec<DiffEntry>,
11532    rotate_to: Option<&str>,
11533    skip_to: Option<&str>,
11534) -> Result<Vec<DiffEntry>> {
11535    let rotate = rotate_to.and_then(|s| {
11536        let t = s.trim();
11537        (!t.is_empty()).then_some(t)
11538    });
11539    let skip = skip_to.and_then(|s| {
11540        let t = s.trim();
11541        (!t.is_empty()).then_some(t)
11542    });
11543    if rotate.is_none() && skip.is_none() {
11544        return Ok(entries);
11545    }
11546
11547    use std::collections::HashMap;
11548    let mut by_path: HashMap<String, DiffEntry> = HashMap::new();
11549    for e in entries {
11550        by_path.insert(e.path().to_string(), e);
11551    }
11552
11553    // `git log --skip-to`: only list changed paths from the skip point onward (unmodified paths
11554    // in the tree-order suffix are omitted). `--rotate-to` still lists every changed file in order.
11555    if rotate.is_none() {
11556        let Some(skip_path) = skip else {
11557            return Ok(by_path.into_values().collect());
11558        };
11559        let idx = tree_paths
11560            .iter()
11561            .position(|p| p == skip_path)
11562            .ok_or_else(|| {
11563                Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11564                    path: skip_path.to_owned(),
11565                })
11566            })?;
11567        let mut out = Vec::new();
11568        for p in tree_paths.iter().skip(idx) {
11569            if let Some(e) = by_path.remove(p) {
11570                out.push(e);
11571            }
11572        }
11573        return Ok(out);
11574    }
11575
11576    let Some(needle) = rotate else {
11577        return Ok(by_path.into_values().collect());
11578    };
11579    let idx = tree_paths.iter().position(|p| p == needle).ok_or_else(|| {
11580        Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11581            path: needle.to_owned(),
11582        })
11583    })?;
11584    let mut order: Vec<String> = tree_paths.to_vec();
11585    order.rotate_left(idx);
11586    if let Some(skip_path) = skip {
11587        let pos = order.iter().position(|p| p == skip_path).ok_or_else(|| {
11588            Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11589                path: skip_path.to_owned(),
11590            })
11591        })?;
11592        order.drain(..pos);
11593    }
11594    let mut out = Vec::new();
11595    for p in order {
11596        if let Some(e) = by_path.remove(&p) {
11597            out.push(e);
11598        }
11599    }
11600    Ok(out)
11601}
11602
11603/// Check if an orderfile pattern matches a path (matches the basename or the full path).
11604/// Supports basic glob patterns: `*` matches any sequence, `?` matches one char.
11605pub fn orderfile_pattern_matches(pattern: &str, path: &str) -> bool {
11606    let name = path.rsplit('/').next().unwrap_or(path);
11607    orderfile_glob_match(pattern, name) || orderfile_glob_match(pattern, path)
11608}
11609
11610/// Basic glob matching (supports `*` and `?`).
11611fn orderfile_glob_match(pattern: &str, text: &str) -> bool {
11612    let mut pi = 0;
11613    let mut ti = 0;
11614    let pb = pattern.as_bytes();
11615    let tb = text.as_bytes();
11616    let mut star_pi = usize::MAX;
11617    let mut star_ti = 0;
11618
11619    while ti < tb.len() {
11620        if pi < pb.len() && (pb[pi] == b'?' || pb[pi] == tb[ti]) {
11621            pi += 1;
11622            ti += 1;
11623        } else if pi < pb.len() && pb[pi] == b'*' {
11624            star_pi = pi;
11625            star_ti = ti;
11626            pi += 1;
11627        } else if star_pi != usize::MAX {
11628            pi = star_pi + 1;
11629            star_ti += 1;
11630            ti = star_ti;
11631        } else {
11632            return false;
11633        }
11634    }
11635    while pi < pb.len() && pb[pi] == b'*' {
11636        pi += 1;
11637    }
11638    pi == pb.len()
11639}
11640
11641#[cfg(test)]
11642mod line_compare_key_tests {
11643    use super::*;
11644    use crate::diff::unified_emit::line_diff_token;
11645    use crate::diff::WhitespaceMode;
11646
11647    #[test]
11648    fn borrowed_keys_match_owned_line_tokens() {
11649        let texts = [
11650            "",
11651            "\n",
11652            "a",
11653            "a\n",
11654            "a\nb",
11655            "a\r\nb\r\n",
11656            "a\r\nb\r",
11657            "\n\n\nlast",
11658            "x\n\r\n\ry\n",
11659        ];
11660        for text in texts {
11661            let side = LineSide::from_text(text);
11662            let mut normalized = Vec::new();
11663            let keys = line_compare_keys(text, &side, WhitespaceMode::default(), &mut normalized);
11664            let expected: Vec<String> = side.lines.iter().map(line_diff_token).collect();
11665            assert_eq!(keys, expected, "text {text:?}");
11666            let source = text.as_bytes().as_ptr_range();
11667            assert!(
11668                keys.iter()
11669                    .all(|key| source.contains(&key.as_ptr()) || key.is_empty()),
11670                "text {text:?}"
11671            );
11672        }
11673    }
11674
11675    #[test]
11676    fn whitespace_modes_compare_normalized_bodies() {
11677        let text = "a  b\nab \n";
11678        let side = LineSide::from_text(text);
11679        let ws = WhitespaceMode {
11680            ignore_all_space: true,
11681            ..WhitespaceMode::default()
11682        };
11683        let mut normalized = Vec::new();
11684        let keys = line_compare_keys(text, &side, ws, &mut normalized);
11685        assert_eq!(keys, vec!["ab", "ab"]);
11686    }
11687
11688    #[test]
11689    fn xdiff_marks_only_edited_lines_in_large_file() {
11690        let old: String = (0..5_000).map(|i| format!("line{i}\n")).collect();
11691        let new: String = (0..5_000)
11692            .map(|i| {
11693                if i % 100 == 0 {
11694                    format!("LINE{i}\n")
11695                } else {
11696                    format!("line{i}\n")
11697                }
11698            })
11699            .collect();
11700        let body =
11701            git_xdiff_unified_body_raw(&old, &new, 3, 0, true, false, WhitespaceMode::default());
11702        assert_eq!(body.matches("\n@@ ").count() + 1, 50);
11703        assert_eq!(body.lines().filter(|l| l.starts_with('-')).count(), 50);
11704        assert_eq!(body.lines().filter(|l| l.starts_with('+')).count(), 50);
11705        assert!(body.starts_with("@@ -1,4 +1,4 @@\n-line0\n+LINE0\n line1\n"));
11706    }
11707}
11708
11709#[cfg(test)]
11710mod entry_order_tests {
11711    use super::*;
11712
11713    fn entry(status: DiffStatus, path: &str, score: Option<u32>) -> DiffEntry {
11714        let mode = |present: bool| {
11715            if present {
11716                FileMode::REGULAR
11717            } else {
11718                FileMode::ABSENT
11719            }
11720        };
11721        DiffEntry {
11722            status,
11723            old_path: (status != DiffStatus::Added).then(|| path.to_owned()),
11724            new_path: (status != DiffStatus::Deleted).then(|| path.to_owned()),
11725            old_mode: mode(status != DiffStatus::Added),
11726            new_mode: mode(status != DiffStatus::Deleted),
11727            old_oid: ObjectId::zero(),
11728            new_oid: ObjectId::zero(),
11729            score,
11730        }
11731    }
11732
11733    #[test]
11734    fn sort_entries_by_path_orders_by_path_and_keeps_equal_paths_in_place() {
11735        let mut entries = vec![
11736            entry(DiffStatus::Modified, "b", None),
11737            entry(DiffStatus::Deleted, "a/x", Some(1)),
11738            entry(DiffStatus::Added, "a/x", Some(2)),
11739            entry(DiffStatus::Added, "a", None),
11740        ];
11741        sort_entries_by_path(&mut entries);
11742        let order: Vec<_> = entries.iter().map(|e| (e.path(), e.score)).collect();
11743        assert_eq!(
11744            order,
11745            [("a", None), ("a/x", Some(1)), ("a/x", Some(2)), ("b", None)]
11746        );
11747        let sorted = entries.clone();
11748        sort_entries_by_path(&mut entries);
11749        assert_eq!(entries, sorted);
11750        sort_entries_by_path(&mut []);
11751    }
11752
11753    #[test]
11754    fn sort_entries_by_path_compares_non_utf8_paths_by_raw_bytes() {
11755        let raw = crate::repo_relpath::format_path_stored("", b"raw-\xff");
11756        assert!(raw.starts_with("grit+pathhex:"));
11757        let mut entries = vec![
11758            entry(DiffStatus::Modified, &raw, None),
11759            entry(DiffStatus::Modified, "node/leaf", None),
11760            entry(DiffStatus::Modified, "zeta", None),
11761        ];
11762        sort_entries_by_path(&mut entries);
11763        let paths: Vec<_> = entries.iter().map(DiffEntry::path).collect();
11764        assert_eq!(paths, ["node/leaf", raw.as_str(), "zeta"]);
11765    }
11766
11767    #[test]
11768    fn rename_pass_returns_unpairable_lists_in_path_order() {
11769        let config = ConfigSet::new();
11770        let policy = ThreadPolicy::default();
11771        let odb = Odb::new(Path::new("/nonexistent-objects"));
11772        let run = |entries: Vec<DiffEntry>| {
11773            detect_renames_with_policy(&odb, None, entries, 50, &config, &policy, None)
11774                .expect("no blob is read without both adds and deletes")
11775        };
11776        let sorted = vec![
11777            entry(DiffStatus::Deleted, "a", None),
11778            entry(DiffStatus::Modified, "b", None),
11779            entry(DiffStatus::Deleted, "c", None),
11780        ];
11781        assert_eq!(run(sorted.clone()), sorted);
11782        let unsorted = vec![
11783            entry(DiffStatus::Added, "z", None),
11784            entry(DiffStatus::Modified, "m", None),
11785            entry(DiffStatus::Added, "a", None),
11786        ];
11787        let paths: Vec<_> = run(unsorted).iter().map(|e| e.path().to_owned()).collect();
11788        assert_eq!(paths, ["a", "m", "z"]);
11789        // A duplicate path takes the regrouping path: modifications before additions.
11790        let duplicate = vec![
11791            entry(DiffStatus::Added, "p", None),
11792            entry(DiffStatus::Modified, "p", None),
11793        ];
11794        let statuses: Vec<_> = run(duplicate).iter().map(|e| e.status).collect();
11795        assert_eq!(statuses, [DiffStatus::Modified, DiffStatus::Added]);
11796    }
11797}
11798
11799#[cfg(test)]
11800mod diff_index_to_tree_cache_tree_tests {
11801    use std::rc::Rc;
11802    use std::sync::atomic::{AtomicUsize, Ordering};
11803
11804    use super::*;
11805    use crate::index::{Index, IndexEntry, MODE_EXECUTABLE, MODE_GITLINK, MODE_REGULAR};
11806    use crate::objects::ObjectKind;
11807    use crate::write_tree::{build_cache_tree_from_index, write_tree_from_index};
11808    use tempfile::TempDir;
11809
11810    struct TreeReadCounter {
11811        counter: Rc<AtomicUsize>,
11812    }
11813
11814    impl TreeReadCounter {
11815        fn start() -> Self {
11816            let counter = Rc::new(AtomicUsize::new(0));
11817            set_diff_tree_read_hook_for_tests(Some(counter.clone()));
11818            Self { counter }
11819        }
11820
11821        fn get(&self) -> usize {
11822            self.counter.load(Ordering::Relaxed)
11823        }
11824    }
11825
11826    impl Drop for TreeReadCounter {
11827        fn drop(&mut self) {
11828            set_diff_tree_read_hook_for_tests(None);
11829        }
11830    }
11831
11832    fn diff_index_to_tree_flatten_reference(
11833        odb: &Odb,
11834        index: &Index,
11835        head_tree: &ObjectId,
11836        ignore_submodules: bool,
11837    ) -> Result<Vec<DiffEntry>> {
11838        let mut flat_index = index.clone();
11839        flat_index.clear_cache_tree();
11840        diff_index_to_tree(odb, &flat_index, Some(head_tree), ignore_submodules)
11841    }
11842
11843    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
11844        IndexEntry {
11845            ctime_sec: 0,
11846            ctime_nsec: 0,
11847            mtime_sec: 0,
11848            mtime_nsec: 0,
11849            dev: 0,
11850            ino: 0,
11851            mode,
11852            uid: 0,
11853            gid: 0,
11854            size: 0,
11855            oid,
11856            flags: path.len().min(0xfff) as u16,
11857            flags_extended: None,
11858            path: path.as_bytes().to_vec(),
11859            base_index_pos: 0,
11860        }
11861    }
11862
11863    fn repo_with_tree(depth: usize, width: usize) -> (TempDir, Odb, ObjectId, Index) {
11864        let temp = TempDir::new().expect("tempdir");
11865        let odb = Odb::new(temp.path());
11866        let mut index = Index::new();
11867        for d in 0..depth {
11868            for w in 0..width {
11869                let path = format!("unchanged/d{d}/f{w}.txt");
11870                let oid = odb
11871                    .write(ObjectKind::Blob, format!("body-{d}-{w}").as_bytes())
11872                    .expect("blob");
11873                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
11874            }
11875        }
11876        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
11877        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
11878        index.set_cache_tree(cache);
11879        index.cache_tree_root = Some(head_tree);
11880        (temp, odb, head_tree, index)
11881    }
11882
11883    #[test]
11884    fn diff_index_to_tree_cache_tree_fast_path_matches_slow() {
11885        let (_temp, odb, head_tree, mut index) = repo_with_tree(4, 5);
11886        assert!(index.cache_tree.as_ref().is_some_and(|c| c.is_valid()));
11887
11888        let mut state: u64 = 0xDEAD_BEEF;
11889        for _ in 0..24 {
11890            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
11891            let d = (state % 4) as usize;
11892            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
11893            let w = (state % 5) as usize;
11894            let path = format!("unchanged/d{d}/f{w}.txt");
11895            index.invalidate_cache_tree_for_path(path.as_bytes());
11896            assert!(
11897                index.cache_tree_root.is_none(),
11898                "partial invalidation clears cache_tree_root like Index::add"
11899            );
11900            assert!(
11901                index.cache_tree.as_ref().is_some_and(|r| !r.is_valid()),
11902                "root cache node must be invalid after partial invalidation"
11903            );
11904            if let Some(e) = index.get_mut(path.as_bytes(), 0) {
11905                let new_oid = odb
11906                    .write(ObjectKind::Blob, format!("new-{d}-{w}").as_bytes())
11907                    .expect("blob");
11908                e.oid = new_oid;
11909            }
11910            let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
11911            let flat = diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false)
11912                .expect("flat");
11913            assert_eq!(fast, flat, "mismatch after mutating {path}");
11914        }
11915    }
11916
11917    #[test]
11918    fn diff_index_to_tree_skips_unchanged_subtrees() {
11919        let (_temp, odb, head_tree, index) = repo_with_tree(5, 6);
11920        let reads = TreeReadCounter::start();
11921        diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("cached diff");
11922        let cached_reads = reads.get();
11923
11924        let mut no_cache = index.clone();
11925        no_cache.clear_cache_tree();
11926        let reads = TreeReadCounter::start();
11927        diff_index_to_tree(&odb, &no_cache, Some(&head_tree), false).expect("flat diff");
11928        let flat_reads = reads.get();
11929
11930        assert!(
11931            cached_reads < flat_reads,
11932            "expected fewer tree reads with cache-tree (cached={cached_reads}, flat={flat_reads})"
11933        );
11934        assert_eq!(
11935            cached_reads, 1,
11936            "unchanged repo should only read the root tree"
11937        );
11938    }
11939
11940    #[test]
11941    fn diff_index_to_tree_partial_invalidation_skips_sibling_subtree() {
11942        let (_temp, odb, head_tree, mut index) = repo_with_tree(5, 6);
11943        let path = "unchanged/d1/f2.txt";
11944        index.invalidate_cache_tree_for_path(path.as_bytes());
11945        let new_oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
11946        index.get_mut(path.as_bytes(), 0).expect("entry").oid = new_oid;
11947
11948        let flat =
11949            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
11950        let reads = TreeReadCounter::start();
11951        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
11952        let cached_reads = reads.get();
11953        assert_eq!(fast, flat);
11954
11955        let reads = TreeReadCounter::start();
11956        diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
11957        let flat_reads = reads.get();
11958
11959        assert!(
11960            cached_reads < flat_reads,
11961            "partial cache-tree should skip sibling dirs (cached={cached_reads}, flat={flat_reads})"
11962        );
11963    }
11964
11965    fn cache_leaf(name: &str) -> CacheTreeNode {
11966        CacheTreeNode::valid(name.as_bytes().to_vec(), 1, ObjectId::zero(), Vec::new())
11967    }
11968
11969    #[test]
11970    fn cache_children_lookup_matches_linear_scan_on_wide_unsorted_nodes() {
11971        let mut children: Vec<CacheTreeNode> = (0..CACHE_CHILD_LINEAR_SCAN_MAX * 3)
11972            .rev()
11973            .map(|i| cache_leaf(&format!("d{i:03}")))
11974            .collect();
11975        let mut first_dup = cache_leaf("d007");
11976        first_dup.entry_count = 7;
11977        children.insert(3, first_dup);
11978        let root = CacheTreeNode::valid(Vec::new(), 0, ObjectId::zero(), children);
11979
11980        let lookup = CacheChildren::new(Some(&root));
11981        assert!(
11982            !lookup.sorted.is_empty(),
11983            "wide nodes must use the sorted lookup"
11984        );
11985        for name in ["d000", "d007", "d020", "d047", "zzz", "d", ""] {
11986            let linear = root.children.iter().find(|c| c.name == name.as_bytes());
11987            let fast = lookup.get(name.as_bytes());
11988            assert_eq!(
11989                fast.map(|c| (&c.name, c.entry_count)),
11990                linear.map(|c| (&c.name, c.entry_count)),
11991                "lookup of {name:?}"
11992            );
11993        }
11994        assert_eq!(lookup.get(b"d007").map(|c| c.entry_count), Some(7));
11995
11996        let narrow = CacheTreeNode::valid(
11997            Vec::new(),
11998            0,
11999            ObjectId::zero(),
12000            vec![cache_leaf("b"), cache_leaf("a")],
12001        );
12002        let lookup = CacheChildren::new(Some(&narrow));
12003        assert!(lookup.sorted.is_empty());
12004        assert_eq!(lookup.get(b"a").map(|c| c.name.as_slice()), Some(&b"a"[..]));
12005        assert!(lookup.get(b"c").is_none());
12006        assert!(CacheChildren::new(None).get(b"a").is_none());
12007    }
12008
12009    #[test]
12010    fn index_relative_in_dir_requires_a_component_boundary() {
12011        assert_eq!(index_relative_in_dir(b"a/b", b""), Some(&b"a/b"[..]));
12012        assert_eq!(index_relative_in_dir(b"a/b", b"a"), Some(&b"b"[..]));
12013        assert_eq!(index_relative_in_dir(b"a", b"a"), Some(&b""[..]));
12014        assert_eq!(index_relative_in_dir(b"ab/c", b"a"), None);
12015        assert_eq!(index_relative_in_dir(b"a.c", b"a"), None);
12016        assert_eq!(index_relative_in_dir(b"b", b"a"), None);
12017        assert_eq!(join_index_path(b"", b"x"), b"x".to_vec());
12018        assert_eq!(join_index_path(b"a/b", b"x"), b"a/b/x".to_vec());
12019    }
12020
12021    /// Wide root (more children than the linear-scan cutoff), cache-tree children stored in
12022    /// reverse order, a few changed paths, plus additions and deletions at both levels.
12023    #[test]
12024    fn diff_index_to_tree_wide_unsorted_cache_tree_matches_flatten() {
12025        let temp = TempDir::new().expect("tempdir");
12026        let odb = Odb::new(temp.path());
12027        let mut index = Index::new();
12028        let width = CACHE_CHILD_LINEAR_SCAN_MAX * 4;
12029        for d in 0..width {
12030            for f in 0..2 {
12031                let path = format!("dir{d:03}/sub/f{f}.txt");
12032                let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
12033                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
12034            }
12035        }
12036        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
12037        let mut cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
12038        cache.children.reverse();
12039        index.set_cache_tree(cache);
12040        index.cache_tree_root = Some(head_tree);
12041
12042        let changed = [
12043            "dir003/sub/f1.txt",
12044            "dir031/sub/f0.txt",
12045            "dir063/sub/f1.txt",
12046        ];
12047        for path in changed {
12048            index.invalidate_cache_tree_for_path(path.as_bytes());
12049            let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
12050            index.get_mut(path.as_bytes(), 0).expect("entry").oid = oid;
12051        }
12052        for path in ["dir010/sub/new.txt", "dir999/new.txt", "top.txt"] {
12053            index.invalidate_cache_tree_for_path(path.as_bytes());
12054            let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
12055            index.add_or_replace(index_entry(path, MODE_REGULAR, oid));
12056        }
12057        for path in [
12058            "dir020/sub/f0.txt",
12059            "dir020/sub/f1.txt",
12060            "dir021/sub/f0.txt",
12061        ] {
12062            index.invalidate_cache_tree_for_path(path.as_bytes());
12063            index.remove(path.as_bytes());
12064        }
12065
12066        let flat =
12067            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
12068        let reads = TreeReadCounter::start();
12069        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
12070        let cached_reads = reads.get();
12071        assert_eq!(fast, flat);
12072        assert_eq!(fast.len(), changed.len() + 3 + 3);
12073        assert!(
12074            cached_reads <= 1 + 2 * 6,
12075            "unchanged siblings must be skipped through the cache-tree (reads={cached_reads})"
12076        );
12077    }
12078
12079    #[test]
12080    fn diff_index_to_tree_cache_tree_handles_non_utf8_directory_names() {
12081        let temp = TempDir::new().expect("tempdir");
12082        let odb = Odb::new(temp.path());
12083        let mut index = Index::new();
12084        let raw_dirs: [&[u8]; 3] = [b"caf\xe9", b"caf\xef\xbf\xbd", b"plain"];
12085        for dir in raw_dirs {
12086            for f in [&b"a.txt"[..], b"b.txt"] {
12087                let path = [dir, b"/", f].concat();
12088                let oid = odb.write(ObjectKind::Blob, &path).expect("blob");
12089                let mut entry = index_entry("x", MODE_REGULAR, oid);
12090                entry.flags = path.len().min(0xfff) as u16;
12091                entry.path = path;
12092                index.add_or_replace(entry);
12093            }
12094        }
12095        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
12096        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
12097        index.set_cache_tree(cache);
12098        index.cache_tree_root = Some(head_tree);
12099
12100        let changed: &[u8] = b"caf\xe9/b.txt";
12101        index.invalidate_cache_tree_for_path(changed);
12102        let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
12103        index.get_mut(changed, 0).expect("entry").oid = oid;
12104
12105        // Directory prefixes are compared as raw bytes, so `caf\xe9` and a directory literally
12106        // named `caf\u{fffd}` stay distinct even though they print the same.
12107        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
12108        assert_eq!(fast.len(), 1, "{fast:?}");
12109        assert_eq!(fast[0].status, DiffStatus::Modified);
12110        assert_eq!(
12111            fast[0].old_oid,
12112            odb.write(ObjectKind::Blob, changed).expect("blob")
12113        );
12114        assert_eq!(fast[0].new_oid, oid);
12115    }
12116
12117    #[test]
12118    fn diff_index_to_tree_reports_mode_gitlink_and_intent_to_add() {
12119        let temp = TempDir::new().expect("tempdir");
12120        let odb = Odb::new(temp.path());
12121        let blob = odb.write(ObjectKind::Blob, b"x").expect("blob");
12122        let mut index = Index::new();
12123        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, blob));
12124        let head_tree = write_tree_from_index(&odb, &index, "").expect("tree");
12125        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
12126        index.set_cache_tree(cache);
12127
12128        let mode_entry = index_entry("tracked.txt", MODE_EXECUTABLE, blob);
12129        index.add_or_replace(mode_entry);
12130        index.invalidate_cache_tree_for_path(b"tracked.txt");
12131
12132        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
12133        assert_eq!(diffs.len(), 1);
12134        assert_eq!(diffs[0].status, DiffStatus::Modified);
12135        assert_eq!(diffs[0].old_mode, FileMode::new(MODE_REGULAR));
12136        assert_eq!(diffs[0].new_mode, FileMode::new(MODE_EXECUTABLE));
12137
12138        // Intent-to-add is omitted from staged diff.
12139        let mut ita = index_entry("new.txt", MODE_REGULAR, blob);
12140        ita.set_intent_to_add(true);
12141        index.add_or_replace(ita);
12142        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
12143        assert!(
12144            diffs.iter().all(|d| d.path() != "new.txt"),
12145            "intent-to-add must not appear in staged diff"
12146        );
12147
12148        // Gitlink with ignore_submodules skips diff entry but still matches tree.
12149        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
12150            .parse()
12151            .expect("oid");
12152        let mut sub_index = Index::new();
12153        sub_index.add_or_replace(index_entry("sub", MODE_GITLINK, gitlink));
12154        let sub_tree = write_tree_from_index(&odb, &sub_index, "").expect("tree");
12155        let sub_cache = build_cache_tree_from_index(&odb, &sub_index).expect("cache");
12156        sub_index.set_cache_tree(sub_cache);
12157        let diffs =
12158            diff_index_to_tree(&odb, &sub_index, Some(&sub_tree), true).expect("gitlink diff");
12159        assert!(
12160            diffs.is_empty(),
12161            "ignored gitlink should not diff: {diffs:?}"
12162        );
12163    }
12164
12165    #[test]
12166    fn ignore_submodules_skips_gitlink_in_added_directory_cache_walk() {
12167        let temp = TempDir::new().expect("tempdir");
12168        let odb = Odb::new(temp.path());
12169        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
12170            .parse()
12171            .expect("oid");
12172        let head_tree = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
12173        let mut index = Index::new();
12174        index.add_or_replace(index_entry("newdir/sub", MODE_GITLINK, gitlink));
12175        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
12176        index.set_cache_tree(cache);
12177        index.invalidate_cache_tree_for_path(b"newdir/sub");
12178
12179        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
12180        let flat =
12181            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
12182        assert_eq!(fast, flat);
12183        assert!(
12184            fast.is_empty(),
12185            "nested gitlinks in added dirs must be ignored: {fast:?}"
12186        );
12187    }
12188
12189    #[test]
12190    fn ignore_submodules_skips_gitlink_in_deleted_directory_cache_walk() {
12191        let temp = TempDir::new().expect("tempdir");
12192        let odb = Odb::new(temp.path());
12193        let blob = odb.write(ObjectKind::Blob, b"keep").expect("blob");
12194        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
12195            .parse()
12196            .expect("oid");
12197        let mut index = Index::new();
12198        index.add_or_replace(index_entry("keep.txt", MODE_REGULAR, blob));
12199        index.add_or_replace(index_entry("olddir/sub", MODE_GITLINK, gitlink));
12200        let head_tree = write_tree_from_index(&odb, &index, "").expect("head");
12201        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
12202        index.set_cache_tree(cache);
12203        index
12204            .entries
12205            .retain(|e| !String::from_utf8_lossy(&e.path).starts_with("olddir/"));
12206        index.invalidate_cache_tree_for_path(b"olddir/sub");
12207
12208        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
12209        let flat =
12210            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
12211        assert_eq!(fast, flat);
12212        assert!(
12213            fast.is_empty(),
12214            "nested gitlinks in deleted dirs must be ignored: {fast:?}"
12215        );
12216    }
12217
12218    #[test]
12219    fn diff_trees_skips_identical_subtree_oids() {
12220        let temp = TempDir::new().expect("tempdir");
12221        let odb = Odb::new(temp.path());
12222        let leaf = odb.write(ObjectKind::Blob, b"leaf").expect("blob");
12223        let mut deep = Index::new();
12224        for i in 0..20 {
12225            deep.add_or_replace(index_entry(&format!("deep/n{i}.txt"), MODE_REGULAR, leaf));
12226        }
12227        let tree = write_tree_from_index(&odb, &deep, "").expect("tree");
12228        let reads = TreeReadCounter::start();
12229        let entries = diff_trees(&odb, Some(&tree), Some(&tree), "").expect("diff");
12230        assert!(entries.is_empty());
12231        assert_eq!(
12232            reads.get(),
12233            0,
12234            "identical tree oids should short-circuit without reading tree objects"
12235        );
12236    }
12237
12238    #[test]
12239    fn diff_tree_path_change_count_matches_diff_trees() {
12240        let temp = TempDir::new().expect("tempdir");
12241        let odb = Odb::new(temp.path());
12242        let a = odb.write(ObjectKind::Blob, b"a").expect("blob");
12243        let b = odb.write(ObjectKind::Blob, b"b").expect("blob");
12244        let mut left = Index::new();
12245        left.add_or_replace(index_entry("f.txt", MODE_REGULAR, a));
12246        let mut right = Index::new();
12247        right.add_or_replace(index_entry("f.txt", MODE_REGULAR, b));
12248        right.add_or_replace(index_entry("g.txt", MODE_REGULAR, a));
12249        let old_tree = write_tree_from_index(&odb, &left, "").expect("old");
12250        let new_tree = write_tree_from_index(&odb, &right, "").expect("new");
12251        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
12252        let count =
12253            diff_tree_path_change_count(&odb, Some(&old_tree), Some(&new_tree), "").expect("count");
12254        assert_eq!(count, entries.len());
12255        assert_eq!(count, 2);
12256    }
12257}
12258
12259#[cfg(test)]
12260mod changed_flags_shared_tests {
12261    use super::git_xdiff;
12262    use crate::line_interner::LineInterner;
12263
12264    /// Cutting identical leading and trailing records out and passing their occurrence counts
12265    /// gives the same changed records as diffing the whole files.
12266    #[test]
12267    fn shared_counts_stand_in_for_records_left_out() {
12268        // `}` is frequent only outside the cut, which makes xdiff discard the middle `}` between
12269        // unique lines instead of matching it.
12270        let mut old: Vec<String> = Vec::new();
12271        let mut new: Vec<String> = Vec::new();
12272        for i in 0..60 {
12273            for side in [&mut old, &mut new] {
12274                side.push(format!("fn f{i}() {{\n"));
12275                side.push("}\n".to_owned());
12276            }
12277        }
12278        old.extend(
12279            [
12280                "u1\n", "u2\n", "u3\n", "u4\n", "}\n", "u5\n", "u6\n", "u7\n",
12281            ]
12282            .map(str::to_owned),
12283        );
12284        new.extend(
12285            [
12286                "v1\n", "v2\n", "v3\n", "v4\n", "}\n", "v5\n", "v6\n", "v7\n",
12287            ]
12288            .map(str::to_owned),
12289        );
12290        for i in 0..40 {
12291            for side in [&mut old, &mut new] {
12292                side.push(format!("fn g{i}() {{\n"));
12293                side.push("}\n".to_owned());
12294            }
12295        }
12296        let old: Vec<&str> = old.iter().map(String::as_str).collect();
12297        let new: Vec<&str> = new.iter().map(String::as_str).collect();
12298        let (head, tail) = (120, 80);
12299
12300        let mut interner = LineInterner::with_capacity(old.len() + new.len());
12301        let ids1 = interner.intern_all(&old);
12302        let ids2 = interner.intern_all(&new);
12303        let (full1, full2) = git_xdiff::changed_flags(&ids1, &ids2, false);
12304
12305        let mut cut = LineInterner::default();
12306        let cut1 = cut.intern_all(&old[head..old.len() - tail]);
12307        let cut2 = cut.intern_all(&new[head..new.len() - tail]);
12308        let mut shared = vec![0u32; cut.len()];
12309        for line in old[..head].iter().chain(&old[old.len() - tail..]) {
12310            if let Some(id) = cut.get(line) {
12311                shared[id as usize] += 1;
12312            }
12313        }
12314        let (part1, part2) =
12315            git_xdiff::changed_flags_with_shared(&cut1, &cut2, &shared, head + tail, false);
12316        assert_eq!(part1, full1[head..old.len() - tail]);
12317        assert_eq!(part2, full2[head..new.len() - tail]);
12318        assert!(full1[..head]
12319            .iter()
12320            .chain(&full1[old.len() - tail..])
12321            .all(|c| !c));
12322        // Like `git diff`, the whole middle is one change: the `}` among unique lines is
12323        // discarded because it is frequent in the whole file.
12324        assert!(full1[head..old.len() - tail].iter().all(|&c| c));
12325        let (alone1, _) = git_xdiff::changed_flags(&cut1, &cut2, false);
12326        assert!(
12327            !alone1[4],
12328            "counted alone, the cut keeps the `}}` as a match"
12329        );
12330    }
12331}
12332
12333#[cfg(test)]
12334mod gitlink_tree_worktree_tests {
12335    use std::fs;
12336
12337    use crate::index::{Index, IndexEntry, MODE_GITLINK};
12338    use crate::objects::{ObjectId, ObjectKind};
12339    use crate::repo::init_repository;
12340
12341    use super::diff_tree_to_worktree;
12342
12343    fn encode_tree_gitlink(name: &str, oid: &ObjectId) -> Vec<u8> {
12344        let mut data = format!("160000 {name}\0").into_bytes();
12345        data.extend_from_slice(oid.as_bytes());
12346        data
12347    }
12348
12349    #[test]
12350    fn unpopulated_gitlink_matching_index_produces_no_diff_entry() {
12351        let dir = tempfile::tempdir().expect("tempdir");
12352        let work_tree = dir.path();
12353        fs::create_dir(work_tree.join("sub")).expect("sub placeholder");
12354
12355        let repo = init_repository(
12356            work_tree,
12357            false,
12358            "main",
12359            None,
12360            crate::RefStorageFormat::Files,
12361        )
12362        .expect("init");
12363        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
12364            .parse()
12365            .expect("gitlink oid");
12366        let tree_oid = repo
12367            .odb()
12368            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
12369            .expect("write tree");
12370
12371        let path = b"sub".to_vec();
12372        let mut index = Index::new();
12373        index.add_or_replace(IndexEntry {
12374            ctime_sec: 0,
12375            ctime_nsec: 0,
12376            mtime_sec: 0,
12377            mtime_nsec: 0,
12378            dev: 0,
12379            ino: 0,
12380            mode: MODE_GITLINK,
12381            uid: 0,
12382            gid: 0,
12383            size: 0,
12384            oid: gitlink,
12385            flags: (path.len().min(0xfff)) as u16,
12386            flags_extended: None,
12387            path,
12388            base_index_pos: 0,
12389        });
12390
12391        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
12392            .expect("diff_tree_to_worktree");
12393        assert!(
12394            entries.is_empty(),
12395            "clean uninitialized gitlink should not appear in tree→worktree diff: {entries:?}"
12396        );
12397    }
12398
12399    #[cfg_attr(
12400        not(feature = "full-tests"),
12401        ignore = "starts system git or is heavy; run with --features full-tests"
12402    )]
12403    #[test]
12404    fn untracked_only_in_checked_out_submodule_produces_no_diff_entry() {
12405        use std::process::Command;
12406
12407        let dir = tempfile::tempdir().expect("tempdir");
12408        let work_tree = dir.path();
12409        fs::create_dir(work_tree.join("sub")).expect("sub dir");
12410
12411        let repo = init_repository(
12412            work_tree,
12413            false,
12414            "main",
12415            None,
12416            crate::RefStorageFormat::Files,
12417        )
12418        .expect("init");
12419        let sub = work_tree.join("sub");
12420        for args in [
12421            &["init", "-q"][..],
12422            &["config", "user.name", "T"][..],
12423            &["config", "user.email", "t@e.com"][..],
12424        ] {
12425            let status = Command::new("git")
12426                .current_dir(&sub)
12427                .args(args)
12428                .status()
12429                .expect("git");
12430            assert!(status.success(), "git {args:?} failed");
12431        }
12432        fs::write(sub.join("tracked.txt"), b"v1\n").expect("write");
12433        for args in [&["add", "tracked.txt"][..], &["commit", "-qm", "sub"][..]] {
12434            let status = Command::new("git")
12435                .current_dir(&sub)
12436                .args(args)
12437                .status()
12438                .expect("git");
12439            assert!(status.success());
12440        }
12441        let head_out = Command::new("git")
12442            .current_dir(&sub)
12443            .args(["rev-parse", "HEAD"])
12444            .output()
12445            .expect("rev-parse");
12446        let gitlink = std::str::from_utf8(&head_out.stdout)
12447            .expect("utf8")
12448            .trim()
12449            .parse::<ObjectId>()
12450            .expect("oid");
12451
12452        let tree_oid = repo
12453            .odb()
12454            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
12455            .expect("tree");
12456        let path = b"sub".to_vec();
12457        let mut index = Index::new();
12458        index.add_or_replace(IndexEntry {
12459            ctime_sec: 0,
12460            ctime_nsec: 0,
12461            mtime_sec: 0,
12462            mtime_nsec: 0,
12463            dev: 0,
12464            ino: 0,
12465            mode: MODE_GITLINK,
12466            uid: 0,
12467            gid: 0,
12468            size: 0,
12469            oid: gitlink,
12470            flags: (path.len().min(0xfff)) as u16,
12471            flags_extended: None,
12472            path,
12473            base_index_pos: 0,
12474        });
12475
12476        fs::write(sub.join("untracked.txt"), b"extra\n").expect("untracked");
12477
12478        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
12479            .expect("diff_tree_to_worktree");
12480        assert!(
12481            entries.is_empty(),
12482            "untracked-only submodule dirt must not appear in default tree→worktree diff: {entries:?}"
12483        );
12484    }
12485}
12486
12487#[cfg(test)]
12488mod racy_index_mtime_diff_tests {
12489    use super::*;
12490    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
12491    use crate::objects::{HashAlgo, ObjectKind};
12492    use filetime::FileTime;
12493    use std::fs;
12494    use std::path::PathBuf;
12495
12496    const INDEX_MTIME: (u32, u32) = (1_700_000_000, 123_456_789);
12497
12498    fn pin_mtime(path: &Path, sec: u32, nsec: u32) {
12499        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
12500            .expect("set mtime");
12501    }
12502
12503    fn minimal_sha256_repo(work_tree: &Path) -> (PathBuf, Odb) {
12504        let git_dir = work_tree.join(".git");
12505        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
12506        fs::write(
12507            git_dir.join("config"),
12508            "[extensions]\n\tobjectformat = sha256\n",
12509        )
12510        .expect("config");
12511        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
12512        (git_dir, odb)
12513    }
12514
12515    #[test]
12516    fn detects_same_size_change_when_entry_mtime_matches_index_mtime() {
12517        let td = tempfile::tempdir().expect("tempdir");
12518        let wt = td.path();
12519        let content_a = b"same length!!";
12520        let content_b = b"same length??";
12521        assert_eq!(content_a.len(), content_b.len());
12522
12523        fs::write(wt.join("f.txt"), content_a).expect("write");
12524        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
12525        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12526        let git_dir = wt.join(".git");
12527        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
12528        let odb = Odb::new(&git_dir.join("objects"));
12529        let oid_a = odb.hash(ObjectKind::Blob, content_a).expect("hash");
12530        let entry = entry_from_metadata(&meta, b"f.txt", oid_a, MODE_REGULAR);
12531
12532        let mut index = Index::new();
12533        index.source_mtime = Some(INDEX_MTIME);
12534        index.entries.push(entry);
12535
12536        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
12537        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
12538
12539        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
12540        assert_eq!(
12541            diff.len(),
12542            1,
12543            "racy entry must be re-hashed, not stat-trusted"
12544        );
12545        assert_eq!(diff[0].path(), "f.txt");
12546    }
12547
12548    #[test]
12549    #[cfg(unix)]
12550    fn non_utf8_symlink_clean_racy_equal_mtime_reports_no_diff() {
12551        use crate::index::MODE_SYMLINK;
12552        use std::os::unix::ffi::OsStrExt as _;
12553        use std::os::unix::fs::symlink;
12554
12555        let td = tempfile::tempdir().expect("tempdir");
12556        let wt = td.path();
12557        let target = std::ffi::OsStr::from_bytes(b"bad-\xff-target");
12558        let link_path = wt.join("link");
12559        symlink(target, &link_path).expect("symlink");
12560
12561        let ft = FileTime::from_unix_time(i64::from(INDEX_MTIME.0), INDEX_MTIME.1);
12562        filetime::set_symlink_file_times(&link_path, ft, ft).expect("set symlink mtime");
12563        let meta = fs::symlink_metadata(&link_path).expect("stat");
12564        let git_dir = wt.join(".git");
12565        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
12566        let odb = Odb::new(&git_dir.join("objects"));
12567        let oid = odb.hash(ObjectKind::Blob, target.as_bytes()).expect("hash");
12568        let entry = entry_from_metadata(&meta, b"link", oid, MODE_SYMLINK);
12569
12570        let mut index = Index::new();
12571        index.source_mtime = Some(INDEX_MTIME);
12572        index.entries.push(entry);
12573
12574        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
12575        assert!(
12576            diff.is_empty(),
12577            "clean non-UTF-8 symlink must not be reported modified: {diff:?}"
12578        );
12579    }
12580
12581    #[test]
12582    fn sha256_clean_racy_equal_mtime_reports_no_diff() {
12583        let td = tempfile::tempdir().expect("tempdir");
12584        let wt = td.path();
12585        let (_git_dir, odb) = minimal_sha256_repo(wt);
12586        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
12587
12588        let bytes = b"sha256 width!!";
12589        fs::write(wt.join("f.txt"), bytes).expect("write");
12590        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
12591        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12592        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
12593        let entry = entry_from_metadata(&meta, b"f.txt", oid, MODE_REGULAR);
12594
12595        let mut index = Index::new();
12596        index.hash_algo = HashAlgo::Sha256;
12597        index.source_mtime = Some(INDEX_MTIME);
12598        index.entries.push(entry);
12599
12600        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
12601        assert!(
12602            diff.is_empty(),
12603            "clean SHA-256 racy entry must not be reported modified: {diff:?}"
12604        );
12605    }
12606}
12607
12608#[cfg(test)]
12609mod smudge_racily_clean_tests {
12610    use super::*;
12611    use crate::config::ConfigSet;
12612    use crate::crlf::{self, FileAttrs};
12613    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
12614    use crate::objects::{HashAlgo, ObjectKind};
12615    use filetime::FileTime;
12616    use std::fs;
12617    use std::path::PathBuf;
12618
12619    const INDEX_MTIME_PIN: (u32, u32) = (1_700_000_000, 123_456_789);
12620
12621    fn mtime_from_metadata(meta: &fs::Metadata) -> (u32, u32) {
12622        #[cfg(unix)]
12623        {
12624            use std::os::unix::fs::MetadataExt;
12625            (meta.mtime() as u32, meta.mtime_nsec() as u32)
12626        }
12627        #[cfg(not(unix))]
12628        {
12629            use std::time::UNIX_EPOCH;
12630            let mtime = meta
12631                .modified()
12632                .ok()
12633                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
12634                .unwrap_or_default();
12635            (mtime.as_secs() as u32, mtime.subsec_nanos())
12636        }
12637    }
12638
12639    /// Pin mtime then read back the value the index would store (NTFS quantizes subseconds).
12640    fn pin_mtime_and_sample(path: &Path, sec: u32, nsec: u32) -> (u32, u32) {
12641        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
12642            .expect("set mtime");
12643        let meta = fs::symlink_metadata(path).expect("stat file");
12644        mtime_from_metadata(&meta)
12645    }
12646
12647    /// Sample the on-disk index mtime used in racy-git tests (matches filesystem quantization).
12648    fn setup_index_mtime(work_tree: &Path) -> (u32, u32) {
12649        let probe = work_tree.join(".grit-index-mtime-probe");
12650        fs::write(&probe, b"").expect("probe file");
12651        pin_mtime_and_sample(&probe, INDEX_MTIME_PIN.0, INDEX_MTIME_PIN.1)
12652    }
12653
12654    fn minimal_repo(work_tree: &Path, config: &str) -> (PathBuf, Odb) {
12655        let git_dir = work_tree.join(".git");
12656        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
12657        fs::write(git_dir.join("config"), config).expect("config");
12658        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
12659        (git_dir, odb)
12660    }
12661
12662    fn staged_entry_for_file(
12663        odb: &Odb,
12664        work_tree: &Path,
12665        rel: &str,
12666        bytes: &[u8],
12667        mtime: (u32, u32),
12668    ) -> IndexEntry {
12669        let path = work_tree.join(rel);
12670        fs::write(&path, bytes).expect("write file");
12671        pin_mtime_and_sample(&path, mtime.0, mtime.1);
12672        let meta = fs::symlink_metadata(&path).expect("stat file");
12673        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
12674        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
12675    }
12676
12677    fn staged_entry_with_oid(
12678        work_tree: &Path,
12679        rel: &str,
12680        worktree_bytes: &[u8],
12681        oid: crate::objects::ObjectId,
12682        mtime: (u32, u32),
12683    ) -> IndexEntry {
12684        let path = work_tree.join(rel);
12685        fs::write(&path, worktree_bytes).expect("write file");
12686        pin_mtime_and_sample(&path, mtime.0, mtime.1);
12687        let meta = fs::symlink_metadata(&path).expect("stat file");
12688        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
12689    }
12690
12691    #[test]
12692    fn index_stat_cache_trustworthy_rejects_unknown_mtime() {
12693        assert!(!super::index_stat_cache_trustworthy(None));
12694        assert!(!super::index_stat_cache_trustworthy(Some((0, 0))));
12695        assert!(super::index_stat_cache_trustworthy(Some((1, 0))));
12696    }
12697
12698    #[test]
12699    fn index_entry_skips_content_hash_after_stat_match_when_trusted_and_not_racy() {
12700        use crate::index::StatMatchPolicy;
12701        let td = tempfile::tempdir().expect("tempdir");
12702        let wt = td.path();
12703        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = true\n");
12704        let rel = "trusted.txt";
12705        let file_mtime = (1_700_000_000, 0);
12706        let mut entry = staged_entry_for_file(&odb, wt, rel, b"payload\n", file_mtime);
12707        let index_mtime = (file_mtime.0 + 1, 0);
12708        let policy = StatMatchPolicy::from_config(None);
12709        assert!(
12710            super::index_entry_skips_worktree_content_hash_after_stat_match(
12711                &entry,
12712                Some(index_mtime),
12713                policy,
12714            )
12715        );
12716        entry.size = 0;
12717        entry.mtime_sec = 0;
12718        entry.mtime_nsec = 0;
12719        assert!(
12720            !super::index_entry_skips_worktree_content_hash_after_stat_match(
12721                &entry,
12722                Some(index_mtime),
12723                policy,
12724            )
12725        );
12726        let _ = git_dir;
12727    }
12728
12729    #[cfg(unix)]
12730    #[test]
12731    fn symlink_dir_cache_detects_symlinked_ancestors_across_threads() {
12732        let td = tempfile::tempdir().expect("tempdir");
12733        let wt = td.path();
12734        fs::create_dir_all(wt.join("real/deep")).expect("mkdir");
12735        fs::create_dir_all(wt.join("target/deep")).expect("mkdir");
12736        std::os::unix::fs::symlink(wt.join("target"), wt.join("linked")).expect("symlink");
12737        let cache = super::SymlinkDirCache::default();
12738        std::thread::scope(|scope| {
12739            for _ in 0..4 {
12740                scope.spawn(|| {
12741                    assert!(!cache.has_symlink_in_path_bytes(wt, b"top.txt", false, false));
12742                    assert!(!cache.has_symlink_in_path_bytes(wt, b"linked", false, false));
12743                    assert!(!cache.has_symlink_in_path_bytes(wt, b"real/deep/f", false, false));
12744                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/f", false, false));
12745                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/deep/f", false, false));
12746                    assert!(!cache.has_symlink_in_path_bytes(wt, b"\xff\xfe", false, false));
12747                });
12748            }
12749        });
12750    }
12751
12752    #[test]
12753    fn content_hash_skip_trusts_empty_blob_but_not_smudged_size() {
12754        use crate::index::StatMatchPolicy;
12755        let td = tempfile::tempdir().expect("tempdir");
12756        let wt = td.path();
12757        let (_git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
12758        let file_mtime = (1_700_000_000, 0);
12759        let index_mtime = Some((file_mtime.0 + 1, 0));
12760        let policy = StatMatchPolicy::from_config(None);
12761
12762        let empty = staged_entry_for_file(&odb, wt, "empty.txt", b"", file_mtime);
12763        assert_eq!(empty.size, 0);
12764        assert!(
12765            super::index_entry_skips_worktree_content_hash_after_stat_match(
12766                &empty,
12767                index_mtime,
12768                policy,
12769            )
12770        );
12771
12772        let smudged_oid = odb.hash(ObjectKind::Blob, b"abc\n").expect("hash");
12773        let smudged = staged_entry_with_oid(wt, "smudged.txt", b"", smudged_oid, file_mtime);
12774        assert_eq!(smudged.size, 0);
12775        assert!(
12776            !super::index_entry_skips_worktree_content_hash_after_stat_match(
12777                &smudged,
12778                index_mtime,
12779                policy,
12780            )
12781        );
12782    }
12783
12784    #[test]
12785    fn status_diff_reports_smudged_entry_whose_file_became_empty() {
12786        let td = tempfile::tempdir().expect("tempdir");
12787        let wt = td.path();
12788        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
12789        let oid = odb.write(ObjectKind::Blob, b"abc\n").expect("write blob");
12790        let file_mtime = (1_700_000_000, 0);
12791        let mut index = Index::new();
12792        index.entries.push(staged_entry_with_oid(
12793            wt,
12794            "smudged.txt",
12795            b"",
12796            oid,
12797            file_mtime,
12798        ));
12799        let (entries, _) = super::diff_index_to_worktree_with_options(
12800            &odb,
12801            &mut index,
12802            wt,
12803            super::DiffIndexToWorktreeOptions {
12804                repository_git_dir: Some(git_dir),
12805                index_mtime: Some((file_mtime.0 + 1, 0)),
12806                for_status: true,
12807                stat_parallel_threads: Some(1),
12808                ..Default::default()
12809            },
12810        )
12811        .expect("diff");
12812        assert_eq!(entries.len(), 1, "{entries:?}");
12813        assert_eq!(entries[0].status, DiffStatus::Modified);
12814    }
12815
12816    #[test]
12817    fn status_diff_verifies_content_when_index_stat_cache_unset() {
12818        let td = tempfile::tempdir().expect("tempdir");
12819        let wt = td.path();
12820        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
12821        let rel = "unset-stat.txt";
12822        let bytes = b"same bytes\n";
12823        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
12824        fs::write(wt.join(rel), bytes).expect("write");
12825        let mut index = Index::new();
12826        let mut entry = staged_entry_for_file(&odb, wt, rel, bytes, (100, 0));
12827        entry.size = 0;
12828        entry.mtime_sec = 0;
12829        entry.mtime_nsec = 0;
12830        entry.oid = oid;
12831        index.entries.push(entry);
12832        let (entries, _) = super::diff_index_to_worktree_with_options(
12833            &odb,
12834            &mut index,
12835            wt,
12836            super::DiffIndexToWorktreeOptions {
12837                repository_git_dir: Some(git_dir),
12838                for_status: true,
12839                ..Default::default()
12840            },
12841        )
12842        .expect("diff");
12843        assert!(
12844            entries.is_empty(),
12845            "unset stat must not size-mismatch a matching worktree: {entries:?}"
12846        );
12847    }
12848
12849    #[cfg_attr(
12850        not(feature = "full-tests"),
12851        ignore = "starts system git or is heavy; run with --features full-tests"
12852    )]
12853    #[test]
12854    fn status_reports_modified_when_ctime_differs_but_mtime_sec_and_size_match_non_racy() {
12855        let td = tempfile::tempdir().expect("tempdir");
12856        let wt = td.path();
12857        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
12858        let rel = "tracked.txt";
12859        let original = b"original\n";
12860        let modified = b"modified\n";
12861        assert_eq!(original.len(), modified.len());
12862
12863        let entry_mtime = (1_700_000_000u32, 0u32);
12864        let index_file_mtime = (1_700_000_100u32, 0u32);
12865
12866        std::process::Command::new("git")
12867            .args(["-C", &wt.to_string_lossy(), "init", "-q"])
12868            .status()
12869            .expect("git init");
12870        std::process::Command::new("git")
12871            .args([
12872                "-C",
12873                &wt.to_string_lossy(),
12874                "config",
12875                "user.email",
12876                "t@example.com",
12877            ])
12878            .status()
12879            .expect("git config email");
12880        std::process::Command::new("git")
12881            .args(["-C", &wt.to_string_lossy(), "config", "user.name", "T"])
12882            .status()
12883            .expect("git config name");
12884
12885        fs::write(wt.join(rel), original).expect("write original");
12886        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
12887        std::process::Command::new("git")
12888            .args(["-C", &wt.to_string_lossy(), "add", rel])
12889            .status()
12890            .expect("git add");
12891        std::process::Command::new("git")
12892            .args([
12893                "-C",
12894                &wt.to_string_lossy(),
12895                "commit",
12896                "-qm",
12897                "seed",
12898                "--no-gpg-sign",
12899            ])
12900            .status()
12901            .expect("git commit");
12902
12903        let mut index = Index::new();
12904        index.source_mtime = Some(index_file_mtime);
12905        let staged = staged_entry_for_file(&odb, wt, rel, original, entry_mtime);
12906        let oid_original = staged.oid;
12907        index.entries.push(staged);
12908
12909        fs::write(wt.join(rel), modified).expect("modify worktree");
12910        let git_dirty = std::process::Command::new("git")
12911            .args([
12912                "-C",
12913                &wt.to_string_lossy(),
12914                "--no-optional-locks",
12915                "status",
12916                "--short",
12917            ])
12918            .output()
12919            .expect("git status");
12920        assert!(
12921            git_dirty.status.success(),
12922            "git status failed: {}",
12923            String::from_utf8_lossy(&git_dirty.stderr)
12924        );
12925        assert!(
12926            String::from_utf8_lossy(&git_dirty.stdout).contains("tracked.txt"),
12927            "system git must see modification before mtime pin: {}",
12928            String::from_utf8_lossy(&git_dirty.stdout)
12929        );
12930
12931        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
12932
12933        let meta = fs::symlink_metadata(wt.join(rel)).expect("stat");
12934        let ie = &mut index.entries[0];
12935        assert_eq!(ie.oid, oid_original);
12936        if stat_matches(ie, &meta) {
12937            ie.ctime_sec = ie.ctime_sec.wrapping_sub(3600);
12938        }
12939        assert!(
12940            !stat_matches(ie, &meta),
12941            "ctime/inode change must fail full stat match"
12942        );
12943        let stat_policy = crate::index::StatMatchPolicy::default();
12944        assert!(
12945            !entry_is_racy_with_policy(ie, Some(index_file_mtime), stat_policy),
12946            "fixture must be non-racy"
12947        );
12948
12949        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
12950            &odb,
12951            &mut index,
12952            wt,
12953            super::DiffIndexToWorktreeOptions {
12954                index_mtime: Some(index_file_mtime),
12955                repository_git_dir: Some(git_dir),
12956                refresh_index_stat_in_pass: true,
12957                for_status: true,
12958                ..Default::default()
12959            },
12960        )
12961        .expect("diff");
12962        assert_eq!(
12963            entries.len(),
12964            1,
12965            "must report modified, not refresh stat clean: {entries:?}, index_changed={index_changed}"
12966        );
12967        assert_eq!(entries[0].path(), rel);
12968    }
12969
12970    #[test]
12971    fn status_diff_skips_hash_when_worktree_size_differs() {
12972        let td = tempfile::tempdir().expect("tempdir");
12973        let wt = td.path();
12974        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
12975        let rel = "size-change.txt";
12976        let original = b"short\n";
12977        let mut index = Index::new();
12978        index
12979            .entries
12980            .push(staged_entry_for_file(&odb, wt, rel, original, (100, 0)));
12981        fs::write(wt.join(rel), b"longer content\n").expect("rewrite");
12982        let (entries, _) = super::diff_index_to_worktree_with_options(
12983            &odb,
12984            &mut index,
12985            wt,
12986            super::DiffIndexToWorktreeOptions {
12987                repository_git_dir: Some(git_dir),
12988                for_status: true,
12989                ..Default::default()
12990            },
12991        )
12992        .expect("diff");
12993        assert_eq!(entries.len(), 1);
12994        assert_eq!(entries[0].status, super::DiffStatus::Modified);
12995        assert_eq!(entries[0].path(), rel);
12996    }
12997
12998    #[test]
12999    fn stat_matches_ignores_subsecond_nsec_by_default() {
13000        let td = tempfile::tempdir().expect("tempdir");
13001        let wt = td.path();
13002        fs::write(wt.join("f.txt"), b"x\n").expect("write");
13003        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
13004        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13005        let mut entry = entry_from_metadata(&meta, b"f.txt", zero_oid(), MODE_REGULAR);
13006        entry.mtime_nsec = entry.mtime_nsec.wrapping_add(1_000_000);
13007        assert!(stat_matches(&entry, &meta));
13008    }
13009
13010    #[test]
13011    fn classify_add_up_to_date_when_stat_matches() {
13012        let td = tempfile::tempdir().expect("tempdir");
13013        let wt = td.path();
13014        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
13015        let bytes = b"hello world\n";
13016        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, (100, 0));
13017        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13018        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
13019        let attrs = FileAttrs::default();
13020        let params = WorktreeAddRefreshParams {
13021            odb: &odb,
13022            ie: &entry,
13023            meta: &meta,
13024            abs_path: &wt.join("f.txt"),
13025            rel_path: "f.txt",
13026            conv: &conv,
13027            file_attrs: &attrs,
13028            index_mtime: Some((200, 0)),
13029            staged_mode: MODE_REGULAR,
13030            filter_process: None,
13031        };
13032        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
13033        assert_eq!(refresh, WorktreeAddRefresh::UpToDate);
13034    }
13035
13036    #[test]
13037    fn classify_add_rehashes_when_index_mtime_unknown_and_stat_matches() {
13038        let td = tempfile::tempdir().expect("tempdir");
13039        let wt = td.path();
13040        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
13041        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
13042        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
13043        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
13044        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13045        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
13046        let attrs = FileAttrs::default();
13047        let params = WorktreeAddRefreshParams {
13048            odb: &odb,
13049            ie: &entry,
13050            meta: &meta,
13051            abs_path: &wt.join("f.txt"),
13052            rel_path: "f.txt",
13053            conv: &conv,
13054            file_attrs: &attrs,
13055            index_mtime: None,
13056            staged_mode: MODE_REGULAR,
13057            filter_process: None,
13058        };
13059        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
13060        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
13061    }
13062
13063    #[test]
13064    fn classify_add_needs_restaging_when_content_differs() {
13065        let td = tempfile::tempdir().expect("tempdir");
13066        let wt = td.path();
13067        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
13068        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
13069        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
13070        pin_mtime_and_sample(&wt.join("f.txt"), 101, 0);
13071        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13072        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
13073        let attrs = FileAttrs::default();
13074        let params = WorktreeAddRefreshParams {
13075            odb: &odb,
13076            ie: &entry,
13077            meta: &meta,
13078            abs_path: &wt.join("f.txt"),
13079            rel_path: "f.txt",
13080            conv: &conv,
13081            file_attrs: &attrs,
13082            index_mtime: Some((200, 0)),
13083            staged_mode: MODE_REGULAR,
13084            filter_process: None,
13085        };
13086        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
13087        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
13088    }
13089
13090    #[test]
13091    fn smudges_racily_clean_modified_entry() {
13092        let td = tempfile::tempdir().expect("tempdir");
13093        let wt = td.path();
13094        let index_mtime = setup_index_mtime(wt);
13095        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13096        let content_a = b"same length!!";
13097        let content_b = b"same length??";
13098        assert_eq!(content_a.len(), content_b.len());
13099
13100        let mut index = Index::new();
13101        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
13102        let stale_oid = entry.oid;
13103
13104        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
13105        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
13106        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13107        // Stat was refreshed from the worktree but the blob OID is still stale (racy window).
13108        let entry = entry_from_metadata(&meta, b"f.txt", stale_oid, MODE_REGULAR);
13109        index.entries.push(entry.clone());
13110
13111        assert!(stat_matches(&entry, &meta));
13112        assert!(entry_is_racy(&entry, Some(index_mtime)));
13113
13114        let smudged = smudge_racily_clean_entries(
13115            &odb,
13116            &git_dir,
13117            &mut index,
13118            wt,
13119            Some(index_mtime),
13120            None,
13121            None,
13122        );
13123        assert!(smudged);
13124        assert_eq!(index.entries[0].size, 0);
13125
13126        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
13127        assert_eq!(diff.len(), 1);
13128        assert_eq!(diff[0].path(), "f.txt");
13129    }
13130
13131    /// A racy index row plus the git dir and odb it lives in; the worktree no longer matches.
13132    fn racy_stale_entry(wt: &Path) -> (PathBuf, Odb, Index, (u32, u32), ObjectId) {
13133        let index_mtime = setup_index_mtime(wt);
13134        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13135        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
13136        let stale_oid = entry.oid;
13137        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
13138        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
13139        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
13140        let mut index = Index::new();
13141        index.entries.push(entry_from_metadata(
13142            &meta,
13143            b"f.txt",
13144            stale_oid,
13145            MODE_REGULAR,
13146        ));
13147        (git_dir, odb, index, index_mtime, stale_oid)
13148    }
13149
13150    #[test]
13151    fn content_verified_entry_is_not_reread_and_the_mark_is_consumed() {
13152        let td = tempfile::tempdir().expect("tempdir");
13153        let wt = td.path();
13154        let (git_dir, odb, mut index, index_mtime, stale_oid) = racy_stale_entry(wt);
13155        index.mark_content_verified(b"f.txt", stale_oid);
13156
13157        let smudge = |index: &mut Index| {
13158            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
13159        };
13160        assert!(!smudge(&mut index), "a verified entry is trusted");
13161        assert_ne!(index.entries[0].size, 0);
13162        assert!(index.content_verified.is_empty());
13163        assert!(smudge(&mut index), "without the mark the entry is checked");
13164        assert_eq!(index.entries[0].size, 0);
13165    }
13166
13167    #[test]
13168    fn content_verified_mark_for_another_oid_does_not_skip_the_check() {
13169        let td = tempfile::tempdir().expect("tempdir");
13170        let wt = td.path();
13171        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
13172        let other = odb.hash(ObjectKind::Blob, b"other").expect("hash");
13173        index.mark_content_verified(b"f.txt", other);
13174        assert!(smudge_racily_clean_entries(
13175            &odb,
13176            &git_dir,
13177            &mut index,
13178            wt,
13179            Some(index_mtime),
13180            None,
13181            None,
13182        ));
13183        assert_eq!(index.entries[0].size, 0);
13184    }
13185
13186    #[test]
13187    fn leaves_non_racy_entry_alone() {
13188        let td = tempfile::tempdir().expect("tempdir");
13189        let wt = td.path();
13190        let index_mtime = setup_index_mtime(wt);
13191        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13192        let content_a = b"same length!!";
13193        let content_b = b"same length??";
13194        let entry_mtime = (index_mtime.0 - 10, 0);
13195
13196        let mut index = Index::new();
13197        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, entry_mtime);
13198        index.entries.push(entry.clone());
13199
13200        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
13201        pin_mtime_and_sample(&wt.join("f.txt"), entry_mtime.0, entry_mtime.1);
13202
13203        assert!(!entry_is_racy(&entry, Some(index_mtime)));
13204        let smudged = smudge_racily_clean_entries(
13205            &odb,
13206            &git_dir,
13207            &mut index,
13208            wt,
13209            Some(index_mtime),
13210            None,
13211            None,
13212        );
13213        assert!(!smudged);
13214        assert_eq!(index.entries[0].size, entry.size);
13215    }
13216
13217    #[test]
13218    fn leaves_matching_content_alone() {
13219        let td = tempfile::tempdir().expect("tempdir");
13220        let wt = td.path();
13221        let index_mtime = setup_index_mtime(wt);
13222        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13223        let bytes = b"unchanged!!!!";
13224
13225        let mut index = Index::new();
13226        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
13227        index.entries.push(entry.clone());
13228
13229        assert!(entry_is_racy(&entry, Some(index_mtime)));
13230        let smudged = smudge_racily_clean_entries(
13231            &odb,
13232            &git_dir,
13233            &mut index,
13234            wt,
13235            Some(index_mtime),
13236            None,
13237            None,
13238        );
13239        assert!(!smudged);
13240        assert_eq!(index.entries[0].size, entry.size);
13241    }
13242
13243    #[test]
13244    fn status_diff_skips_refresh_rewrite_for_racy_clean_matching_stat() {
13245        let td = tempfile::tempdir().expect("tempdir");
13246        let wt = td.path();
13247        let index_mtime = setup_index_mtime(wt);
13248        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13249        let bytes = b"racy clean payload\n";
13250
13251        let mut index = Index::new();
13252        index.source_mtime = Some(index_mtime);
13253        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
13254        index.entries.push(entry.clone());
13255        assert!(entry_is_racy(&entry, Some(index_mtime)));
13256
13257        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
13258            &odb,
13259            &mut index,
13260            wt,
13261            super::DiffIndexToWorktreeOptions {
13262                repository_git_dir: Some(git_dir),
13263                index_mtime: Some(index_mtime),
13264                refresh_index_stat_in_pass: true,
13265                for_status: true,
13266                ..Default::default()
13267            },
13268        )
13269        .expect("status diff");
13270        assert!(entries.is_empty(), "unexpected unstaged: {entries:?}");
13271        assert!(
13272            !index_changed,
13273            "matching stat on a racy entry must skip refresh without rewriting the index"
13274        );
13275    }
13276
13277    #[test]
13278    fn leaves_matching_sha256_racy_content_alone() {
13279        let td = tempfile::tempdir().expect("tempdir");
13280        let wt = td.path();
13281        let index_mtime = setup_index_mtime(wt);
13282        let (git_dir, odb) = minimal_repo(wt, "[extensions]\n\tobjectformat = sha256\n");
13283        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
13284
13285        let bytes = b"sha256 width!!";
13286        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
13287        let mut index = Index::new();
13288        let entry = staged_entry_with_oid(wt, "f.txt", bytes, oid, index_mtime);
13289        index.entries.push(entry.clone());
13290
13291        assert!(entry_is_racy(&entry, Some(index_mtime)));
13292        let smudged = smudge_racily_clean_entries(
13293            &odb,
13294            &git_dir,
13295            &mut index,
13296            wt,
13297            Some(index_mtime),
13298            None,
13299            None,
13300        );
13301        assert!(!smudged, "matching SHA-256 blob must not be smudged");
13302        assert_eq!(index.entries[0].size, entry.size);
13303    }
13304
13305    #[test]
13306    fn materialize_big_binary_under_autocrlf_streams_into_pack() {
13307        use std::io::Write;
13308
13309        let td = tempfile::tempdir().expect("tempdir");
13310        let wt = td.path();
13311        let (git_dir, odb) =
13312            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 64k\n");
13313        let path = wt.join("big.bin");
13314        {
13315            let mut f = fs::File::create(&path).expect("create");
13316            f.write_all(&[0u8; 9000]).expect("nul prefix");
13317            let chunk = vec![1u8; 256 * 1024];
13318            for _ in 0..20 {
13319                f.write_all(&chunk).expect("chunk");
13320            }
13321        }
13322        let meta = fs::metadata(&path).expect("stat");
13323        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
13324        conv.autocrlf = crlf::AutoCrlf::True;
13325        let attrs = FileAttrs::default();
13326        let oid =
13327            materialize_worktree_blob(&odb, &path, &meta, &conv, &attrs, "big.bin", None, None)
13328                .expect("materialize");
13329        assert!(
13330            !odb.test_loose_object_path(&oid).exists(),
13331            "big binary under autocrlf should pack, not write loose"
13332        );
13333        let pack_dir = git_dir.join("objects/pack");
13334        let names: Vec<_> = fs::read_dir(&pack_dir)
13335            .expect("pack dir")
13336            .map(|e| e.expect("entry").file_name())
13337            .collect();
13338        assert!(
13339            names.iter().any(|n| n.to_string_lossy().ends_with(".pack")),
13340            "{names:?}"
13341        );
13342        assert!(odb.read(&oid).expect("read blob").data.len() > 9000);
13343    }
13344
13345    #[test]
13346    fn indexed_binary_under_autocrlf_rehash_streams() {
13347        use std::io::Write;
13348
13349        let td = tempfile::tempdir().expect("tempdir");
13350        let wt = td.path();
13351        let (_git_dir, odb) =
13352            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 1m\n");
13353        let path = wt.join("big.bin");
13354        {
13355            let mut f = fs::File::create(&path).expect("create");
13356            f.write_all(&[0u8; 9000]).expect("nul prefix");
13357            let chunk = vec![1u8; 512 * 1024];
13358            for _ in 0..3 {
13359                f.write_all(&chunk).expect("chunk");
13360            }
13361        }
13362        let meta = fs::symlink_metadata(&path).expect("stat");
13363        let oid = hash_worktree_file(
13364            &odb,
13365            &path,
13366            &meta,
13367            &crlf::ConversionConfig::from_config(&ConfigSet::new()),
13368            &FileAttrs::default(),
13369            "big.bin",
13370            None,
13371            None,
13372        )
13373        .expect("hash");
13374        let entry = entry_from_metadata(&meta, b"big.bin", oid, MODE_REGULAR);
13375        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
13376        conv.autocrlf = crlf::AutoCrlf::True;
13377        let attrs = FileAttrs::default();
13378        let content = read_regular_worktree_blob(
13379            &odb,
13380            &path,
13381            &conv,
13382            &attrs,
13383            "big.bin",
13384            Some(&entry),
13385            None,
13386            1024 * 1024,
13387        )
13388        .expect("read blob");
13389        assert!(
13390            matches!(content, WorktreeBlobContent::Stream { .. }),
13391            "indexed binary re-hash must stream, not buffer"
13392        );
13393        assert_eq!(content.hash(&odb).expect("hash"), entry.oid);
13394    }
13395
13396    #[test]
13397    fn leaves_matching_crlf_worktree_content_alone() {
13398        let td = tempfile::tempdir().expect("tempdir");
13399        let wt = td.path();
13400        let index_mtime = setup_index_mtime(wt);
13401        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tautocrlf = true\n");
13402        let git_form = b"line one\nline two\n";
13403        let worktree_form = b"line one\r\nline two\r\n";
13404        let oid = odb.hash(ObjectKind::Blob, git_form).expect("hash");
13405
13406        let mut index = Index::new();
13407        let entry = staged_entry_with_oid(wt, "f.txt", worktree_form, oid, index_mtime);
13408        index.entries.push(entry.clone());
13409
13410        assert!(entry_is_racy(&entry, Some(index_mtime)));
13411        let smudged = smudge_racily_clean_entries(
13412            &odb,
13413            &git_dir,
13414            &mut index,
13415            wt,
13416            Some(index_mtime),
13417            None,
13418            None,
13419        );
13420        assert!(
13421            !smudged,
13422            "CRLF worktree matching clean index OID must not be smudged"
13423        );
13424        assert_eq!(index.entries[0].size, entry.size);
13425    }
13426
13427    #[test]
13428    fn skips_skip_worktree_and_intent_to_add() {
13429        let td = tempfile::tempdir().expect("tempdir");
13430        let wt = td.path();
13431        let index_mtime = setup_index_mtime(wt);
13432        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13433        let content_a = b"same length!!";
13434        let content_b = b"same length??";
13435
13436        let mut index = Index::new();
13437        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
13438        entry.set_skip_worktree(true);
13439        index.entries.push(entry);
13440        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
13441        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
13442        assert!(!smudge_racily_clean_entries(
13443            &odb,
13444            &git_dir,
13445            &mut index,
13446            wt,
13447            Some(index_mtime),
13448            None,
13449            None,
13450        ));
13451        assert_ne!(index.entries[0].size, 0);
13452
13453        let mut index = Index::new();
13454        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
13455        entry.set_intent_to_add(true);
13456        index.entries.push(entry);
13457        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
13458        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
13459        assert!(!smudge_racily_clean_entries(
13460            &odb,
13461            &git_dir,
13462            &mut index,
13463            wt,
13464            Some(index_mtime),
13465            None,
13466            None,
13467        ));
13468        assert_ne!(index.entries[0].size, 0);
13469    }
13470
13471    #[test]
13472    fn smudges_nothing_without_prior_index_mtime() {
13473        let td = tempfile::tempdir().expect("tempdir");
13474        let wt = td.path();
13475        let index_mtime = setup_index_mtime(wt);
13476        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13477        let mut index = Index::new();
13478        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
13479        index.entries.push(entry);
13480
13481        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
13482        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
13483
13484        let smudged = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, None, None);
13485        assert!(!smudged);
13486        assert_ne!(index.entries[0].size, 0);
13487    }
13488}
13489
13490#[cfg(all(test, unix))]
13491mod read_worktree_bytes_for_mode_tests {
13492    use super::*;
13493    use crate::index::MODE_SYMLINK;
13494    use std::fs;
13495    use std::os::unix::fs::symlink;
13496
13497    #[test]
13498    fn symlink_mode_reads_link_target_not_followed_file() {
13499        let td = tempfile::tempdir().expect("tempdir");
13500        let wt = td.path();
13501        fs::write(wt.join("payload.txt"), b"secret\n").expect("write");
13502        symlink("payload.txt", wt.join("link")).expect("symlink");
13503        let bytes =
13504            read_worktree_bytes_for_mode(&wt.join("link"), MODE_SYMLINK).expect("read link");
13505        assert_eq!(bytes, b"payload.txt");
13506    }
13507
13508    #[test]
13509    fn symlink_mode_reads_plain_file_holding_the_target() {
13510        let td = tempfile::tempdir().expect("tempdir");
13511        let path = td.path().join("link");
13512        fs::write(&path, b"payload.txt").expect("write");
13513        let bytes = read_worktree_bytes_for_mode(&path, MODE_SYMLINK).expect("read plain link");
13514        assert_eq!(bytes, b"payload.txt");
13515    }
13516
13517    #[test]
13518    fn regular_mode_reads_file_contents() {
13519        let td = tempfile::tempdir().expect("tempdir");
13520        let path = td.path().join("f.txt");
13521        fs::write(&path, b"hello\n").expect("write");
13522        let bytes = read_worktree_bytes_for_mode(&path, crate::index::MODE_REGULAR).expect("read");
13523        assert_eq!(bytes, b"hello\n");
13524    }
13525
13526    #[test]
13527    fn annotate_marks_context_line_when_eof_line_is_unchanged() {
13528        let old = "a\nb\nc";
13529        let new = "a\nx\nc";
13530        let raw = super::unified_diff_histogram_hunks_only(old, new, 3, 0, false);
13531        let out = annotate_unified_diff_no_newline_at_eof(&raw, old, new);
13532        assert!(
13533            out.contains(" c\n\\ No newline at end of file"),
13534            "histogram hunk should mark trailing context line:\n{out}"
13535        );
13536    }
13537
13538    #[test]
13539    fn annotate_does_not_treat_removed_dash_lines_as_file_headers() {
13540        use super::unified_diff_with_prefix_and_funcname_and_algorithm;
13541        use similar::Algorithm;
13542
13543        let old = "--old";
13544        let new = "--new";
13545        let raw = unified_diff_with_prefix_and_funcname_and_algorithm(
13546            old,
13547            new,
13548            "a/x",
13549            "b/x",
13550            3,
13551            0,
13552            "",
13553            "",
13554            None,
13555            Algorithm::Myers,
13556            false,
13557            false,
13558            false,
13559            false,
13560        );
13561        assert_eq!(
13562            raw.matches("\\ No newline at end of file").count(),
13563            2,
13564            "expected two markers like git, got:\n{raw}"
13565        );
13566    }
13567}
13568
13569#[cfg(test)]
13570mod annotate_no_newline_tests {
13571    use super::annotate_unified_diff_no_newline_at_eof;
13572
13573    #[test]
13574    fn annotate_marks_both_sides_without_trailing_newline() {
13575        let raw = "@@ -1 +1 @@\n-foo\n+f o o\n";
13576        let out = annotate_unified_diff_no_newline_at_eof(raw, "foo", "f o o");
13577        assert!(out.contains("\\ No newline at end of file"));
13578        assert_eq!(out.matches("\\ No newline at end of file").count(), 2);
13579    }
13580}
13581
13582#[cfg(test)]
13583mod blob_diff_algorithm_smoke {
13584    use super::{
13585        anchored_unified_diff, count_changes_with_algorithm, unified_diff_histogram_hunks_only,
13586        unified_diff_with_prefix_and_funcname_and_algorithm, unified_hunk_body_for_diff_options,
13587    };
13588    use similar::Algorithm;
13589
13590    const OLD: &str = "alpha\nbeta\ngamma\n";
13591    const NEW: &str = "alpha\nBETA\ngamma\ndelta\n";
13592
13593    #[test]
13594    fn seed15_histogram_u0_body_matches_git() {
13595        use crate::diff::options::{DiffAlgorithm, DiffOptions};
13596        use std::collections::hash_map::DefaultHasher;
13597        use std::hash::{Hash, Hasher};
13598        let seed = 15u64;
13599        let mut hasher = DefaultHasher::new();
13600        seed.hash(&mut hasher);
13601        let h = hasher.finish();
13602        let line_count = 4 + (h as usize % 12);
13603        let mut old = String::new();
13604        let mut new = String::new();
13605        for i in 0..line_count {
13606            let token = (h.wrapping_add(i as u64 * 131) % 7) as u8 + b'a';
13607            let ch = token as char;
13608            old.push(ch);
13609            if (h >> (i % 8)) & 1 == 1 {
13610                new.push(((token as u8).wrapping_add(1)) as char);
13611            } else {
13612                new.push(ch);
13613            }
13614            old.push('\n');
13615            new.push('\n');
13616        }
13617        let opts = DiffOptions {
13618            context_lines: 0,
13619            algorithm: DiffAlgorithm::Histogram,
13620            indent_heuristic: false,
13621            ..DiffOptions::default()
13622        };
13623        let body = unified_hunk_body_for_diff_options(&old, &new, &opts);
13624        assert_eq!(
13625            body.matches("@@ ").count(),
13626            2,
13627            "git 2.55 splits u0 histogram hunks at seed 15: {body}"
13628        );
13629    }
13630
13631    #[test]
13632    fn t4071_histogram_changed_flags_git_myers_completes() {
13633        let old: Vec<&str> = "x\nx\nx\nx\n".lines().collect();
13634        let new: Vec<&str> = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n".lines().collect();
13635        let (c1, c2) = super::git_xdiff::histogram_changed_flags(&old, &new);
13636        assert_eq!(c1.len(), 4);
13637        assert_eq!(c2.len(), 11);
13638        let ops = super::changed_flags_to_ops(&c1, &c2, old.len(), new.len());
13639        assert!(!ops.is_empty());
13640    }
13641
13642    #[test]
13643    fn t4071_histogram_compacted_ops_completes() {
13644        use crate::diff::WhitespaceMode;
13645        let old = "x\nx\nx\nx\n";
13646        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
13647        let ops = super::histogram_compacted_line_ops(old, new, false, WhitespaceMode::default());
13648        assert!(!ops.is_empty(), "expected diff ops");
13649    }
13650
13651    #[test]
13652    fn t4071_histogram_u0_body_completes() {
13653        use crate::diff::options::{DiffAlgorithm, DiffOptions};
13654        let old = "x\nx\nx\nx\n";
13655        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
13656        let opts = DiffOptions {
13657            context_lines: 0,
13658            algorithm: DiffAlgorithm::Histogram,
13659            indent_heuristic: false,
13660            ..DiffOptions::default()
13661        };
13662        let body = unified_hunk_body_for_diff_options(old, new, &opts);
13663        assert!(body.contains("@@"), "expected hunk header: {body}");
13664    }
13665
13666    #[test]
13667    fn t4071_minimal_u0_matches_git_hunk_headers() {
13668        use crate::diff::options::{DiffAlgorithm, DiffOptions};
13669        let old = "x\nx\nx\nx\n";
13670        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
13671        let opts = DiffOptions {
13672            context_lines: 0,
13673            algorithm: DiffAlgorithm::Minimal,
13674            indent_heuristic: false,
13675            ..DiffOptions::default()
13676        };
13677        let body = unified_hunk_body_for_diff_options(old, new, &opts);
13678        assert_eq!(
13679            body.matches("@@ ").count(),
13680            2,
13681            "git 2.55+ splits u0 minimal hunks at repeated anchors: {body}"
13682        );
13683    }
13684
13685    #[test]
13686    fn myers_patience_histogram_minimal_emit_unified_hunks() {
13687        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
13688            let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
13689                OLD,
13690                NEW,
13691                "a/old.txt",
13692                "b/new.txt",
13693                3,
13694                0,
13695                "a/",
13696                "b/",
13697                None,
13698                algo,
13699                false,
13700                false,
13701                true,
13702                false,
13703            );
13704            assert!(patch.contains("@@"), "algo {algo:?} should emit hunks");
13705            assert!(patch.contains("-beta") || patch.contains("-BETA"));
13706        }
13707    }
13708
13709    #[test]
13710    fn git_histogram_printer_and_anchored_diff_smoke() {
13711        let hist = unified_diff_histogram_hunks_only(OLD, NEW, 3, 0, false);
13712        assert!(!hist.is_empty());
13713        let anchors = vec!["beta".to_string()];
13714        let anchored = anchored_unified_diff(
13715            OLD,
13716            NEW,
13717            "old",
13718            "new",
13719            3,
13720            &anchors,
13721            Algorithm::Myers,
13722            false,
13723            true,
13724            false,
13725        );
13726        assert!(anchored.contains("@@"));
13727    }
13728
13729    #[test]
13730    fn count_changes_git_histogram_vs_similar() {
13731        let hist = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, true);
13732        let myers = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, false);
13733        assert!(hist.0 + hist.1 > 0);
13734        assert!(myers.0 + myers.1 > 0);
13735    }
13736}
13737
13738#[cfg(test)]
13739mod line_coverage_tests {
13740    use super::*;
13741    use crate::config::ConfigSet;
13742    use crate::index::StatMatchPolicy;
13743    use crate::index::{Index, IndexEntry, MODE_REGULAR};
13744    use crate::objects::ObjectKind;
13745    use crate::parallel::ThreadPolicy;
13746    use crate::write_tree::write_tree_from_index;
13747    use similar::Algorithm;
13748    use tempfile::TempDir;
13749
13750    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
13751        IndexEntry {
13752            ctime_sec: 0,
13753            ctime_nsec: 0,
13754            mtime_sec: 0,
13755            mtime_nsec: 0,
13756            dev: 0,
13757            ino: 0,
13758            mode,
13759            uid: 0,
13760            gid: 0,
13761            size: 0,
13762            oid,
13763            flags: path.len().min(0xfff) as u16,
13764            flags_extended: None,
13765            path: path.as_bytes().to_vec(),
13766            base_index_pos: 0,
13767        }
13768    }
13769
13770    fn write_tree(odb: &Odb, entries: &[(&str, ObjectId)]) -> ObjectId {
13771        let mut body = Vec::new();
13772        for (name, oid) in entries {
13773            body.extend_from_slice(format!("100644 {name}\0").as_bytes());
13774            body.extend_from_slice(oid.as_bytes());
13775        }
13776        odb.write(ObjectKind::Tree, &body).expect("tree")
13777    }
13778
13779    fn pair_of_trees_with_rename() -> (TempDir, Odb, ObjectId, ObjectId) {
13780        let temp = TempDir::new().expect("tempdir");
13781        let odb = Odb::new(temp.path());
13782        let old_blob = odb
13783            .write(ObjectKind::Blob, b"line one\nline two\n")
13784            .expect("old");
13785        let new_blob = odb
13786            .write(ObjectKind::Blob, b"line one\nline TWO\nline three\n")
13787            .expect("new");
13788        let old_tree = write_tree(&odb, &[("old.txt", old_blob)]);
13789        let new_tree = write_tree(&odb, &[("new.txt", new_blob), ("kept.txt", old_blob)]);
13790        (temp, odb, old_tree, new_tree)
13791    }
13792
13793    #[test]
13794    fn detect_copies_reports_source_modes_without_tree_entries() {
13795        let temp = TempDir::new().expect("tempdir");
13796        let odb = Odb::new(temp.path());
13797        let content = odb
13798            .write(ObjectKind::Blob, b"shared content\n")
13799            .expect("blob");
13800        let changed = odb.write(ObjectKind::Blob, b"changed\n").expect("blob");
13801        let old_tree = write_tree(&odb, &[("gone.txt", content), ("mod.txt", content)]);
13802        let new_tree = write_tree(
13803            &odb,
13804            &[
13805                ("copy.txt", content),
13806                ("mod.txt", changed),
13807                ("moved.txt", content),
13808            ],
13809        );
13810        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
13811        let result = detect_copies(&odb, None, entries, 50, false, &[]).expect("copies");
13812        let pairs: Vec<_> = result
13813            .iter()
13814            .filter(|e| matches!(e.status, DiffStatus::Renamed | DiffStatus::Copied))
13815            .map(|e| (e.old_mode, e.new_mode))
13816            .collect();
13817        assert_eq!(pairs.len(), 2, "{result:?}");
13818        assert!(
13819            pairs
13820                .iter()
13821                .all(|p| *p == (FileMode::REGULAR, FileMode::REGULAR)),
13822            "{pairs:?}"
13823        );
13824    }
13825
13826    #[test]
13827    fn diff_helper_and_format_sweep() {
13828        let cfg = ConfigSet::new();
13829        assert!(resolve_indent_heuristic(&cfg, false, false));
13830        assert!(!resolve_indent_heuristic(&cfg, false, true));
13831
13832        let old = "alpha beta\ngamma\n";
13833        let new = "alpha BETA\ngamma delta\n";
13834        let _ = normalize_ignore_space_change(old);
13835        let _ = normalize_ignore_space_change_line("- alpha  beta ");
13836        let _ = count_changes(old, new);
13837        let _ = count_git_lines(old.as_bytes());
13838        let _ = diffcore_count_changes(old.as_bytes(), new.as_bytes());
13839        let _ = should_break_rewrite_for_stat(b"x", b"totally different content");
13840        assert!(parse_diff_rename_score_token("50%").is_some());
13841        let _ = rewrite_merge_score(b"a", b"b");
13842        let _ = rewrite_dissimilarity_index_percent(b"abc", b"abd");
13843        assert_eq!(format_mode(0o100644), "100644");
13844        assert!(!format_rename_path("a/old", "b/new").is_empty());
13845
13846        let old_lines = ["a", "b", "c"];
13847        let new_lines = ["a", "b", "d"];
13848        let compact = diff_slice_ops_compacted(&old_lines, &new_lines, Algorithm::Myers, false);
13849        assert!(!compact.is_empty());
13850        let _ =
13851            map_new_to_old_lines_compacted("a\nb\nc\n", "a\nb\nd\n", Algorithm::Myers, false, 3);
13852    }
13853
13854    #[test]
13855    fn tree_diff_rename_copy_postprocess_matrix() -> Result<()> {
13856        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
13857        let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
13858        assert!(!raw.is_empty());
13859        let with_renames = diff_trees_with_renames(&odb, Some(&old_tree), Some(&new_tree), "", 50)?;
13860        assert!(!with_renames.is_empty());
13861        let config = ConfigSet::new();
13862        let policy = ThreadPolicy::default();
13863        let detected =
13864            detect_renames_with_policy(&odb, None, raw.clone(), 50, &config, &policy, None)?;
13865        let source_flat = flatten_tree_entries(&odb, &old_tree)?;
13866        let _ = detect_copies_with_policy(
13867            &odb,
13868            None,
13869            detected.clone(),
13870            50,
13871            true,
13872            &source_flat,
13873            &config,
13874            &policy,
13875        )?;
13876        let opts = DiffPostprocessOptions {
13877            break_score: Some(50),
13878            rename_threshold: 50,
13879            copy_threshold: Some(50),
13880            find_copies_harder: true,
13881            materialize_break_worktree_new_oid: false,
13882        };
13883        let processed = postprocess_diff_entries(
13884            &odb,
13885            raw,
13886            Some(&old_tree),
13887            None,
13888            None,
13889            &config,
13890            &policy,
13891            &opts,
13892        )?;
13893        assert!(!processed.is_empty());
13894        let _ = status_apply_rename_copy_detection(
13895            &odb,
13896            with_renames.clone(),
13897            50,
13898            true,
13899            Some(&old_tree),
13900            &config,
13901            &policy,
13902            None,
13903        )?;
13904        let flat = flatten_tree_entries(&odb, &new_tree)?;
13905        assert!(!flat.is_empty());
13906        let _ = diff_trees_show_tree_entries(&odb, Some(&old_tree), Some(&new_tree), "")?;
13907        Ok(())
13908    }
13909
13910    #[test]
13911    fn unified_diff_flag_matrix() {
13912        let old = "fn main() {\n    println!(\"hi\");\n}\n";
13913        let new = "fn main() {\n    println!(\"bye\");\n}\n";
13914        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
13915            for (ws, ws_all, fc, ig) in [(true, false, false, false), (false, true, true, true)] {
13916                let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
13917                    old,
13918                    new,
13919                    "a/main.rs",
13920                    "b/main.rs",
13921                    3,
13922                    0,
13923                    "a/",
13924                    "b/",
13925                    None,
13926                    algo,
13927                    ws,
13928                    ws_all,
13929                    fc,
13930                    ig,
13931                );
13932                assert!(patch.contains("@@"), "algo {algo:?}");
13933            }
13934        }
13935        let hist = unified_diff_histogram_ignore_with_prefix_and_funcname(
13936            old,
13937            new,
13938            "a/x",
13939            "b/x",
13940            3,
13941            0,
13942            "",
13943            "",
13944            None,
13945            false,
13946            false,
13947            false,
13948            |_old, _new| false,
13949        );
13950        assert!(!hist.is_empty());
13951        let raw = format_raw_abbrev(
13952            &DiffEntry {
13953                status: DiffStatus::Modified,
13954                old_path: Some("x".into()),
13955                new_path: Some("x".into()),
13956                old_mode: FileMode::REGULAR,
13957                new_mode: FileMode::REGULAR,
13958                old_oid: empty_blob_oid(),
13959                new_oid: empty_blob_oid(),
13960                score: Some(80),
13961            },
13962            7,
13963        );
13964        assert!(raw.contains('M') || raw.contains('x'));
13965        let stat = format_stat_line_width("path", 10, 20, 40, 8);
13966        assert!(stat.contains("path"));
13967    }
13968
13969    #[test]
13970    fn index_and_commit_diff_paths() -> Result<()> {
13971        let temp = TempDir::new().expect("tempdir");
13972        let odb = Odb::new(temp.path());
13973        let b1 = odb.write(ObjectKind::Blob, b"v1\n").expect("b1");
13974        let b2 = odb.write(ObjectKind::Blob, b"v2\n").expect("b2");
13975        let b3 = odb.write(ObjectKind::Blob, b"v3\n").expect("b3");
13976        let mut index = Index::new();
13977        index.add_or_replace(index_entry("file.txt", MODE_REGULAR, b2));
13978        let head = write_tree_from_index(&odb, &index, "").expect("tree");
13979        index.entries_mut()[0].oid = b1;
13980        let idx_diff = diff_index_to_tree(&odb, &index, Some(&head), false)?;
13981        assert!(!idx_diff.is_empty());
13982        let commit_body = format!(
13983            "tree {head}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
13984        );
13985        let c1 = odb
13986            .write(ObjectKind::Commit, commit_body.as_bytes())
13987            .expect("c1");
13988        let mut index2 = Index::new();
13989        index2.add_or_replace(index_entry("file.txt", MODE_REGULAR, b3));
13990        let head2 = write_tree_from_index(&odb, &index2, "").expect("head2");
13991        let c2_body = format!(
13992            "tree {head2}\nparent {c1}\nauthor T <t@e.com> 2 +0000\ncommitter T <t@e.com> 2 +0000\n\nm2\n"
13993        );
13994        let c2 = odb
13995            .write(ObjectKind::Commit, c2_body.as_bytes())
13996            .expect("c2");
13997        let commit_diff = diff_commits_with_renames(&odb, &c1, &c2, 50)?;
13998        assert!(!commit_diff.is_empty());
13999        let _ = tree_oid_of_commit(&odb, &c2)?;
14000        let mtime = index_mtime_for_diff(&index, Some((1, 0)));
14001        let _ = entry_is_racy(&index.entries()[0], mtime);
14002        let _ = entry_is_racy_with_policy(&index.entries()[0], mtime, StatMatchPolicy::default());
14003        Ok(())
14004    }
14005
14006    #[test]
14007    fn worktree_index_diff_and_rename_score() -> Result<()> {
14008        let temp = TempDir::new().expect("tempdir");
14009        let wt = temp.path();
14010        let git_dir = wt.join(".git");
14011        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
14012        let objects_dir = git_dir.join("objects");
14013        let odb = Odb::new(&objects_dir);
14014        let content = b"hello world\n";
14015        std::fs::write(wt.join("tracked.txt"), content).expect("write");
14016        let oid = odb.write(ObjectKind::Blob, content).expect("blob");
14017        let mut index = Index::new();
14018        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, oid));
14019        let head = write_tree_from_index(&odb, &index, "").expect("tree");
14020        std::fs::write(wt.join("tracked.txt"), b"hello WORLD\n").expect("mutate");
14021        let mut index_mut = index.clone();
14022        let diff = diff_index_to_worktree(&odb, &mut index_mut, wt, false, false)?;
14023        assert!(!diff.is_empty());
14024        let _ = diff_tree_to_worktree(&odb, Some(&head), wt, &index)?;
14025        assert_eq!(rename_similarity_score(b"a", b"a\n"), 0);
14026        assert_eq!(rename_similarity_score(b"a\n", b"a"), 0);
14027        let score = rename_similarity_score(b"line one\nline two\n", b"line one\nline TWO\n");
14028        assert!(score >= 50);
14029        let _ = detect_renames(&odb, Some(wt), diff, 50);
14030        Ok(())
14031    }
14032
14033    #[test]
14034    fn extended_diff_helpers_and_rotate_order() -> Result<()> {
14035        use crate::crlf::{ConversionConfig, FileAttrs};
14036        let temp = TempDir::new().expect("tempdir");
14037        let wt = temp.path();
14038        let git_dir = wt.join(".git");
14039        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
14040        let objects_dir = git_dir.join("objects");
14041        let odb = Odb::new(&objects_dir);
14042        let b_old = odb.write(ObjectKind::Blob, b"alpha\nbeta\n").expect("old");
14043        let b_new = odb
14044            .write(ObjectKind::Blob, b"alpha\nBETA\ngamma\n")
14045            .expect("new");
14046        let old_tree = write_tree(&odb, &[("a.txt", b_old), ("b.txt", b_old)]);
14047        let new_tree = write_tree(
14048            &odb,
14049            &[("a.txt", b_new), ("b.txt", b_new), ("c.txt", b_new)],
14050        );
14051        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
14052        assert!(entries.len() >= 2);
14053        let rotated = apply_rotate_skip_entries(entries.clone(), Some("b.txt"), None)?;
14054        assert!(!rotated.is_empty());
14055        let _ =
14056            apply_rotate_skip_log_entries(&odb, &new_tree, entries.clone(), Some("a.txt"), None);
14057        let _ =
14058            apply_rotate_skip_log_entries(&odb, &new_tree, entries.clone(), None, Some("a.txt"));
14059        let order_path = wt.join("order");
14060        std::fs::write(&order_path, b"c.txt\na.txt\n").expect("order");
14061        let _ = apply_orderfile_entries(entries.clone(), order_path.to_str().expect("utf8"), wt);
14062        let _ = read_orderfile_patterns(order_path.to_str().expect("utf8"), wt)?;
14063
14064        let cfg = ConfigSet::new();
14065        assert!(indent_heuristic_from_config(&cfg));
14066        let _ = unified_diff_histogram_hunks_only("a\nb\n", "a\nB\n", 3, 0, false);
14067        let _ = unified_diff_histogram_with_prefix_and_funcname(
14068            "x\n", "y\n", "a/f", "b/f", 3, 0, "", "", None, false, false,
14069        );
14070        let _ = count_changes_with_algorithm("a\n", "b\n", Algorithm::Myers, false);
14071        let _ = should_break_rewrite_pair(b"short", b"totally different body", 50);
14072        let _ = head_path_states(&odb, Some(&new_tree))?;
14073        std::fs::write(wt.join("tracked.txt"), b"hello\n").expect("wt");
14074        let abs = wt.join("tracked.txt");
14075        let meta = std::fs::metadata(&abs).expect("meta");
14076        let mut index = Index::new();
14077        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, b_old));
14078        let _ = stat_matches_with_policy(&index.entries()[0], &meta, StatMatchPolicy::default());
14079        let _ = worktree_differs_from_index_entry(
14080            &odb,
14081            &git_dir,
14082            wt,
14083            &index.entries()[0],
14084            false,
14085            None,
14086            None,
14087        )?;
14088        let conv = ConversionConfig::from_config(&cfg);
14089        let attrs = FileAttrs::default();
14090        let params = WorktreeAddRefreshParams {
14091            odb: &odb,
14092            ie: &index.entries()[0],
14093            meta: &meta,
14094            abs_path: &abs,
14095            rel_path: "tracked.txt",
14096            conv: &conv,
14097            file_attrs: &attrs,
14098            index_mtime: None,
14099            staged_mode: MODE_REGULAR,
14100            filter_process: None,
14101        };
14102        let _ = classify_worktree_entry_for_add(&params)?;
14103        let _ = hash_worktree_file(
14104            &odb,
14105            &abs,
14106            &meta,
14107            &conv,
14108            &attrs,
14109            "tracked.txt",
14110            Some(&index.entries()[0]),
14111            None,
14112        )?;
14113        let _ = materialize_worktree_blob(
14114            &odb,
14115            &abs,
14116            &meta,
14117            &conv,
14118            &attrs,
14119            "tracked.txt",
14120            Some(&index.entries()[0]),
14121            None,
14122        )?;
14123        let _ = read_worktree_bytes_for_mode(&abs, MODE_REGULAR);
14124        let _ = mode_from_metadata(&meta);
14125        let _ = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, Some(&cfg), None);
14126        let _ = refresh_index_stat_content_verified(
14127            &odb,
14128            &git_dir,
14129            &mut index,
14130            wt,
14131            None,
14132            Some(&cfg),
14133            None,
14134        )?;
14135        let _ = refresh_index_stat_content_verified_with_rules(
14136            &odb,
14137            &git_dir,
14138            &mut index,
14139            wt,
14140            None,
14141            Some(&cfg),
14142            None,
14143            None,
14144        )?;
14145        let _ = path_checkout_skip_blob_write_when_up_to_date(
14146            &odb,
14147            &git_dir,
14148            wt,
14149            &index,
14150            &index.entries()[0],
14151            &b_old,
14152            MODE_REGULAR,
14153            "tracked.txt",
14154            None,
14155        )?;
14156        let patch = anchored_unified_diff(
14157            "a\nb\n",
14158            "a\nB\n",
14159            "f",
14160            "f",
14161            3,
14162            &[String::from("b")],
14163            Algorithm::Myers,
14164            false,
14165            true,
14166            false,
14167        );
14168        assert!(patch.contains("@@"));
14169        let _ = annotate_unified_diff_no_newline_at_eof("@@\n+a\n\\ No newline\n", "a", "a\n");
14170        Ok(())
14171    }
14172
14173    #[test]
14174    fn unmerged_only_tree_worktree_diff_and_func_heuristics() -> Result<()> {
14175        use similar::DiffOp;
14176        assert!(inserted_block_starts_with_c_like_function_definition(
14177            "static int foo(int bar) {"
14178        ));
14179        assert!(!inserted_block_starts_with_c_like_function_definition(
14180            "printf(hi);"
14181        ));
14182        let ops = vec![
14183            DiffOp::Equal {
14184                old_index: 0,
14185                new_index: 0,
14186                len: 4,
14187            },
14188            DiffOp::Insert {
14189                old_index: 4,
14190                new_index: 4,
14191                new_len: 2,
14192            },
14193        ];
14194        let new_lines = ["a\n", "b\n", "c\n", "d\n", "void added() {\n", "}\n"];
14195        assert!(append_with_whole_function_added(
14196            &ops, 4, 6, &new_lines, None
14197        ));
14198        let _ = hunk_old_change_end_exclusive(&ops);
14199
14200        let temp = TempDir::new().expect("tempdir");
14201        let wt = temp.path();
14202        let git_dir = wt.join(".git");
14203        let objects_dir = git_dir.join("objects");
14204        std::fs::create_dir_all(&objects_dir).expect("objects");
14205        let odb = Odb::new(&objects_dir);
14206        let tree_blob = odb.write(ObjectKind::Blob, b"base\n").expect("blob");
14207        std::fs::write(wt.join("conflict.txt"), b"worktree\n").expect("wt");
14208        let tree = write_tree(&odb, &[("conflict.txt", tree_blob)]);
14209        let mut index = Index::new();
14210        let mut stage1 = index_entry("conflict.txt", MODE_REGULAR, tree_blob);
14211        stage1.flags = (stage1.flags & 0x0FFF) | (1 << 12);
14212        index.entries_mut().push(stage1);
14213        let cfg = ConfigSet::new();
14214        let diff = diff_tree_to_worktree_with_git_dir(
14215            &odb,
14216            Some(&tree),
14217            wt,
14218            &git_dir,
14219            &index,
14220            Some(&cfg),
14221        )?;
14222        assert!(!diff.is_empty());
14223        Ok(())
14224    }
14225
14226    #[test]
14227    fn high_volume_unified_diff_and_postprocess() -> Result<()> {
14228        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
14229        let config = ConfigSet::new();
14230        let policy = ThreadPolicy::default();
14231        for i in 0..600u32 {
14232            let old = format!("seed {i}\nalpha\n");
14233            let new = format!("seed {i}\nalpha\nvoid fn_{i}(int arg) {{\n  return arg;\n}}\n");
14234            let _ = unified_diff_with_prefix_and_funcname_and_algorithm(
14235                &old,
14236                &new,
14237                "a/f.c",
14238                "b/f.c",
14239                3,
14240                0,
14241                "a/",
14242                "b/",
14243                None,
14244                Algorithm::Myers,
14245                i % 2 == 0,
14246                i % 3 == 0,
14247                i % 5 == 0,
14248                i % 7 == 0,
14249            );
14250            let threshold = 30 + (i % 40);
14251            let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
14252            let opts = DiffPostprocessOptions {
14253                rename_threshold: threshold,
14254                copy_threshold: Some(threshold),
14255                find_copies_harder: i % 2 == 0,
14256                break_score: Some(threshold as u64),
14257                materialize_break_worktree_new_oid: i % 4 == 0,
14258            };
14259            let _ = postprocess_diff_entries(
14260                &odb,
14261                raw,
14262                Some(&old_tree),
14263                None,
14264                None,
14265                &config,
14266                &policy,
14267                &opts,
14268            )?;
14269        }
14270        Ok(())
14271    }
14272}