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 std::borrow::Cow;
24use std::collections::HashSet;
25use std::fs;
26#[cfg(unix)]
27use std::os::unix::fs::MetadataExt;
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
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::{parse_commit, CommitData, ObjectId, ObjectKind, TreeEntry, TreeRefIter};
40use crate::odb::Odb;
41use crate::parallel::ThreadPolicy;
42use crate::repo::Repository;
43use crate::userdiff::FuncnameMatcher;
44use crate::worktree_rules::WorktreeRules;
45
46use super::rename_lines::RenameLineTable;
47use super::unified_emit::{
48    emit_unified_body_from_edits, line_ending_only_edits, EditSpan, LineSide, UnifiedEmitOptions,
49};
50
51/// Comparison keys for every line of `side`, which must be [`LineSide::from_text`] of `text`.
52///
53/// Without whitespace normalization a key is the raw line including its `\n` (the same bytes as
54/// `line_diff_token`), borrowed from `text`. Under `-w`/`-b`/`--ignore-space-at-eol` the keys
55/// are normalized line bodies, kept in `normalized` so the returned slices can borrow them.
56fn line_compare_keys<'a>(
57    text: &'a str,
58    side: &LineSide<'a>,
59    ws: super::WhitespaceMode,
60    normalized: &'a mut Vec<Cow<'a, str>>,
61) -> Vec<&'a str> {
62    if ws.normalizes_line_compare() {
63        // Git -w/-b compare normalized bodies only; EOF newline is whitespace.
64        normalized.extend(
65            side.lines
66                .iter()
67                .map(|line| ws.normalize_line_for_compare(line.body)),
68        );
69        return normalized.iter().map(Cow::as_ref).collect();
70    }
71    let mut keys = Vec::with_capacity(side.len());
72    let mut rest = text;
73    while !rest.is_empty() {
74        let end = memchr::memchr(b'\n', rest.as_bytes()).map_or(rest.len(), |nl| nl + 1);
75        let (line, tail) = rest.split_at(end);
76        keys.push(line);
77        rest = tail;
78    }
79    debug_assert_eq!(keys.len(), side.len());
80    keys
81}
82
83#[path = "../structured_diff.rs"]
84pub mod structured;
85
86pub(crate) fn imara_unified_hunk_slices(body: &str) -> Vec<&str> {
87    let mut starts: Vec<usize> = Vec::new();
88    if body.starts_with("@@") {
89        starts.push(0);
90    }
91    for (idx, _) in body.match_indices("\n@@ ") {
92        starts.push(idx + 1);
93    }
94    starts.push(body.len());
95    starts.windows(2).map(|w| &body[w[0]..w[1]]).collect()
96}
97
98/// Histogram line alignment (Git `xhistogram.c`), then `xdl_change_compact` and indent heuristic.
99pub(crate) fn histogram_compacted_line_ops(
100    old_content: &str,
101    new_content: &str,
102    indent_heuristic: bool,
103    ws: super::WhitespaceMode,
104) -> Vec<similar::DiffOp> {
105    let before = LineSide::from_text(old_content);
106    let after = LineSide::from_text(new_content);
107    let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
108    let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
109    let old_len = old_lines.len();
110    let new_len = new_lines.len();
111    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
112    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
113    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
114
115    let (changed1, changed2) = git_xdiff::histogram_changed_flags(&old_compare, &new_compare);
116
117    if !changed1.iter().any(|c| *c) && !changed2.iter().any(|c| *c) {
118        return Vec::new();
119    }
120    let raw = changed_flags_to_ops(&changed1, &changed2, old_len, new_len);
121    diff_indent_heuristic::apply_change_compact_to_ops_with_ws_histogram(
122        &raw,
123        &old_lines,
124        &new_lines,
125        indent_heuristic,
126        ws,
127        true,
128    )
129}
130
131pub(crate) fn histogram_unified_body_raw(
132    old_content: &str,
133    new_content: &str,
134    context_lines: usize,
135    inter_hunk_context: usize,
136    indent_heuristic: bool,
137    ws: super::WhitespaceMode,
138) -> String {
139    let before_side = LineSide::from_text(old_content);
140    let after_side = LineSide::from_text(new_content);
141    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
142    let mut edits = diff_ops_to_edit_spans(&ops);
143    if edits.is_empty() {
144        if old_content != new_content && !ws.normalizes_line_compare() {
145            edits = line_ending_only_edits(&before_side, &after_side);
146        }
147        if edits.is_empty() {
148            return String::new();
149        }
150    }
151    emit_unified_body_from_edits(
152        &before_side,
153        &after_side,
154        &edits,
155        UnifiedEmitOptions {
156            context_lines,
157            inter_hunk_context,
158            function_context: false,
159        },
160        None,
161    )
162}
163
164/// Build the unified-diff body (no `---`/`+++` header) using Git's histogram
165/// algorithm, but applying `--ignore-blank-lines` / `-I` semantics through a
166/// Git-compatible implementation of the xdiff change-record machinery
167/// (`xdl_mark_ignorable_lines` + `xdl_get_hunk` + `xdl_emit_diff`).
168///
169/// `is_ignorable_change` is called with the slices of removed and added lines
170/// for one change record (each item is the line text without its trailing
171/// newline); it returns `true` when the whole change is ignorable (Git's
172/// `xch->ignore`). Ignorable changes that are far from substantive changes are
173/// dropped, and hunk boundaries are computed with Git's `max_ignorable`
174/// distance rules so the line numbers and surviving context match Git exactly.
175///
176/// Returns the body only; an empty string means no substantive change survives.
177/// Optional `--function-context` (`-W`) hunk expansion is applied *after*
178/// ignorable change-record pruning so the boundaries/funcname header match Git.
179#[allow(clippy::too_many_arguments)]
180fn diff_ops_to_ignorable_edit_spans<F>(
181    ops: &[similar::DiffOp],
182    old_lines: &[&str],
183    new_lines: &[&str],
184    is_ignorable_change: F,
185) -> Vec<EditSpan>
186where
187    F: Fn(&[&str], &[&str]) -> bool,
188{
189    use similar::DiffOp;
190    let mut out = Vec::new();
191    for op in ops {
192        match *op {
193            DiffOp::Equal { .. } => {}
194            DiffOp::Delete {
195                old_index,
196                old_len,
197                new_index,
198            } => {
199                let removed = &old_lines[old_index..old_index + old_len];
200                out.push(EditSpan {
201                    before_start: old_index as i64,
202                    before_len: old_len as i64,
203                    after_start: new_index as i64,
204                    after_len: 0,
205                    skip_in_output: is_ignorable_change(removed, &[]),
206                });
207            }
208            DiffOp::Insert {
209                old_index,
210                new_index,
211                new_len,
212            } => {
213                let added = &new_lines[new_index..new_index + new_len];
214                out.push(EditSpan {
215                    before_start: old_index as i64,
216                    before_len: 0,
217                    after_start: new_index as i64,
218                    after_len: new_len as i64,
219                    skip_in_output: is_ignorable_change(&[], added),
220                });
221            }
222            DiffOp::Replace {
223                old_index,
224                old_len,
225                new_index,
226                new_len,
227            } => {
228                let removed = &old_lines[old_index..old_index + old_len];
229                let added = &new_lines[new_index..new_index + new_len];
230                out.push(EditSpan {
231                    before_start: old_index as i64,
232                    before_len: old_len as i64,
233                    after_start: new_index as i64,
234                    after_len: new_len as i64,
235                    skip_in_output: is_ignorable_change(removed, added),
236                });
237            }
238        }
239    }
240    out
241}
242
243#[allow(clippy::too_many_arguments)]
244pub(crate) fn histogram_unified_body_ignore_fc<F>(
245    old_content: &str,
246    new_content: &str,
247    context_lines: usize,
248    inter_hunk_context: usize,
249    function_context: bool,
250    funcname_matcher: Option<&FuncnameMatcher>,
251    indent_heuristic: bool,
252    ws: super::WhitespaceMode,
253    is_ignorable_change: F,
254) -> String
255where
256    F: Fn(&[&str], &[&str]) -> bool,
257{
258    let before = LineSide::from_text(old_content);
259    let after = LineSide::from_text(new_content);
260    let old_lines: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
261    let new_lines: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
262    let ops = histogram_compacted_line_ops(old_content, new_content, indent_heuristic, ws);
263    if ops.is_empty() {
264        return String::new();
265    }
266    let edits = diff_ops_to_ignorable_edit_spans(&ops, &old_lines, &new_lines, is_ignorable_change);
267    emit_unified_body_from_edits(
268        &before,
269        &after,
270        &edits,
271        UnifiedEmitOptions {
272            context_lines,
273            inter_hunk_context,
274            function_context,
275        },
276        funcname_matcher,
277    )
278}
279
280pub(crate) fn git_xdiff_compacted_line_ops(
281    old_compare: &[&str],
282    new_compare: &[&str],
283    old_lines: &[&str],
284    new_lines: &[&str],
285    indent_heuristic: bool,
286    need_min: bool,
287) -> Vec<similar::DiffOp> {
288    use crate::line_interner::LineInterner;
289
290    let mut interner = LineInterner::with_capacity(old_compare.len() + new_compare.len());
291    let ids1 = interner.intern_all(old_compare);
292    let ids2 = interner.intern_all(new_compare);
293    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, need_min);
294    diff_indent_heuristic::apply_change_compact_to_masks(
295        changed1,
296        changed2,
297        old_lines,
298        new_lines,
299        indent_heuristic,
300    )
301}
302
303fn diff_ops_to_edit_spans(ops: &[similar::DiffOp]) -> Vec<EditSpan> {
304    use similar::DiffOp;
305    let mut out = Vec::new();
306    for op in ops {
307        match *op {
308            DiffOp::Equal { .. } => {}
309            DiffOp::Delete {
310                old_index,
311                old_len,
312                new_index,
313            } => out.push(EditSpan {
314                before_start: old_index as i64,
315                before_len: old_len as i64,
316                after_start: new_index as i64,
317                after_len: 0,
318                skip_in_output: false,
319            }),
320            DiffOp::Insert {
321                old_index,
322                new_index,
323                new_len,
324            } => out.push(EditSpan {
325                before_start: old_index as i64,
326                before_len: 0,
327                after_start: new_index as i64,
328                after_len: new_len as i64,
329                skip_in_output: false,
330            }),
331            DiffOp::Replace {
332                old_index,
333                old_len,
334                new_index,
335                new_len,
336            } => out.push(EditSpan {
337                before_start: old_index as i64,
338                before_len: old_len as i64,
339                after_start: new_index as i64,
340                after_len: new_len as i64,
341                skip_in_output: false,
342            }),
343        }
344    }
345    out
346}
347
348/// Unified hunk body using Git's xdiff engine (no `---` / `+++`).
349fn git_xdiff_unified_body_raw(
350    old_content: &str,
351    new_content: &str,
352    context_lines: usize,
353    inter_hunk_context: usize,
354    indent_heuristic: bool,
355    need_min: bool,
356    ws: super::WhitespaceMode,
357) -> String {
358    git_xdiff_unified_body_raw_with_ignorable(
359        old_content,
360        new_content,
361        context_lines,
362        inter_hunk_context,
363        indent_heuristic,
364        need_min,
365        ws,
366        None::<fn(&[&str], &[&str]) -> bool>,
367    )
368}
369
370#[allow(clippy::too_many_arguments)]
371fn git_xdiff_unified_body_raw_with_ignorable<F>(
372    old_content: &str,
373    new_content: &str,
374    context_lines: usize,
375    inter_hunk_context: usize,
376    indent_heuristic: bool,
377    need_min: bool,
378    ws: super::WhitespaceMode,
379    is_ignorable_change: Option<F>,
380) -> String
381where
382    F: Fn(&[&str], &[&str]) -> bool,
383{
384    let before = LineSide::from_text(old_content);
385    let after = LineSide::from_text(new_content);
386    if before.is_empty() && after.is_empty() {
387        return String::new();
388    }
389    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
390    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
391    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
392    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
393    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
394    let ops = git_xdiff_compacted_line_ops(
395        &old_compare,
396        &new_compare,
397        &old_refs,
398        &new_refs,
399        indent_heuristic,
400        need_min,
401    );
402    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
403        diff_ops_to_ignorable_edit_spans(&ops, &old_refs, &new_refs, is_ignorable)
404    } else {
405        diff_ops_to_edit_spans(&ops)
406    };
407    if edits.is_empty() {
408        if old_content != new_content && !ws.normalizes_line_compare() {
409            edits = line_ending_only_edits(&before, &after);
410        }
411        if edits.is_empty() {
412            return String::new();
413        }
414    }
415    emit_unified_body_from_edits(
416        &before,
417        &after,
418        &edits,
419        UnifiedEmitOptions {
420            context_lines,
421            inter_hunk_context,
422            function_context: false,
423        },
424        None,
425    )
426}
427
428#[allow(clippy::too_many_arguments)]
429fn git_patience_unified_body_raw_with_ignorable<F>(
430    old_content: &str,
431    new_content: &str,
432    context_lines: usize,
433    inter_hunk_context: usize,
434    indent_heuristic: bool,
435    ws: super::WhitespaceMode,
436    is_ignorable_change: Option<F>,
437) -> String
438where
439    F: Fn(&[&str], &[&str]) -> bool,
440{
441    let before = LineSide::from_text(old_content);
442    let after = LineSide::from_text(new_content);
443    if before.is_empty() && after.is_empty() {
444        return String::new();
445    }
446    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
447    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
448    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
449    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
450    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
451    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
452    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
453    let ops = if ws.normalizes_line_compare() {
454        diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
455            &raw,
456            &old_refs,
457            &new_refs,
458            indent_heuristic,
459            ws,
460        )
461    } else {
462        diff_indent_heuristic::apply_change_compact_to_ops(
463            &raw,
464            &old_refs,
465            &new_refs,
466            indent_heuristic,
467        )
468    };
469    let mut edits = if let Some(is_ignorable) = is_ignorable_change {
470        diff_ops_to_ignorable_edit_spans(&ops, &old_refs, &new_refs, is_ignorable)
471    } else {
472        diff_ops_to_edit_spans(&ops)
473    };
474    if edits.is_empty() {
475        if old_content != new_content && !ws.normalizes_line_compare() {
476            edits = line_ending_only_edits(&before, &after);
477        }
478        if edits.is_empty() {
479            return String::new();
480        }
481    }
482    emit_unified_body_from_edits(
483        &before,
484        &after,
485        &edits,
486        UnifiedEmitOptions {
487            context_lines,
488            inter_hunk_context,
489            function_context: false,
490        },
491        None,
492    )
493}
494
495fn blank_line_ignorable(ws: super::WhitespaceMode) -> impl Fn(&[&str], &[&str]) -> bool + Copy {
496    move |removed: &[&str], added: &[&str]| {
497        ws.ignore_blank_lines
498            && removed.iter().all(|l| crate::ws::ws_blank_line(l))
499            && added.iter().all(|l| crate::ws::ws_blank_line(l))
500    }
501}
502
503/// Unified hunk bodies (`@@` …) for [`DiffOptions`], shared by structured and log-patch output.
504pub(crate) fn unified_hunk_body_for_diff_options(
505    old_content: &str,
506    new_content: &str,
507    options: &crate::diff::options::DiffOptions,
508) -> String {
509    use crate::diff::options::DiffAlgorithm;
510
511    let ws = options.whitespace;
512    if ws.is_active() {
513        let blank_only = blank_line_ignorable(ws);
514        if ws.normalizes_line_compare() {
515            let ignorable = ws.ignore_blank_lines.then_some(blank_only);
516            return match options.algorithm {
517                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
518                    old_content,
519                    new_content,
520                    options.context_lines,
521                    options.inter_hunk_context,
522                    options.indent_heuristic,
523                    false,
524                    ws,
525                    ignorable,
526                ),
527                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
528                    old_content,
529                    new_content,
530                    options.context_lines,
531                    options.inter_hunk_context,
532                    options.indent_heuristic,
533                    true,
534                    ws,
535                    ignorable,
536                ),
537                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
538                    old_content,
539                    new_content,
540                    options.context_lines,
541                    options.inter_hunk_context,
542                    options.indent_heuristic,
543                    ws,
544                    ignorable,
545                ),
546                DiffAlgorithm::Histogram => {
547                    if ws.ignore_blank_lines {
548                        histogram_unified_body_ignore_fc(
549                            old_content,
550                            new_content,
551                            options.context_lines,
552                            options.inter_hunk_context,
553                            false,
554                            None,
555                            options.indent_heuristic,
556                            ws,
557                            blank_only,
558                        )
559                    } else {
560                        histogram_unified_body_raw(
561                            old_content,
562                            new_content,
563                            options.context_lines,
564                            options.inter_hunk_context,
565                            options.indent_heuristic,
566                            ws,
567                        )
568                    }
569                }
570            };
571        }
572        if ws.ignore_blank_lines {
573            return match options.algorithm {
574                DiffAlgorithm::Myers => git_xdiff_unified_body_raw_with_ignorable(
575                    old_content,
576                    new_content,
577                    options.context_lines,
578                    options.inter_hunk_context,
579                    options.indent_heuristic,
580                    false,
581                    ws,
582                    Some(blank_only),
583                ),
584                DiffAlgorithm::Minimal => git_xdiff_unified_body_raw_with_ignorable(
585                    old_content,
586                    new_content,
587                    options.context_lines,
588                    options.inter_hunk_context,
589                    options.indent_heuristic,
590                    true,
591                    ws,
592                    Some(blank_only),
593                ),
594                DiffAlgorithm::Patience => git_patience_unified_body_raw_with_ignorable(
595                    old_content,
596                    new_content,
597                    options.context_lines,
598                    options.inter_hunk_context,
599                    options.indent_heuristic,
600                    ws,
601                    Some(blank_only),
602                ),
603                DiffAlgorithm::Histogram => histogram_unified_body_ignore_fc(
604                    old_content,
605                    new_content,
606                    options.context_lines,
607                    options.inter_hunk_context,
608                    false,
609                    None,
610                    options.indent_heuristic,
611                    ws,
612                    blank_only,
613                ),
614            };
615        }
616    }
617    match options.algorithm {
618        DiffAlgorithm::Myers => git_xdiff_unified_body_raw(
619            old_content,
620            new_content,
621            options.context_lines,
622            options.inter_hunk_context,
623            options.indent_heuristic,
624            false,
625            ws,
626        ),
627        DiffAlgorithm::Minimal => git_xdiff_unified_body_raw(
628            old_content,
629            new_content,
630            options.context_lines,
631            options.inter_hunk_context,
632            options.indent_heuristic,
633            true,
634            ws,
635        ),
636        DiffAlgorithm::Patience => git_patience_unified_body_raw(
637            old_content,
638            new_content,
639            options.context_lines,
640            options.inter_hunk_context,
641            options.indent_heuristic,
642        ),
643        DiffAlgorithm::Histogram => histogram_unified_body_raw(
644            old_content,
645            new_content,
646            options.context_lines,
647            options.inter_hunk_context,
648            options.indent_heuristic,
649            ws,
650        ),
651    }
652}
653
654/// Unified hunk body using Git's patience diff (no `---` / `+++`).
655fn git_patience_unified_body_raw(
656    old_content: &str,
657    new_content: &str,
658    context_lines: usize,
659    inter_hunk_context: usize,
660    indent_heuristic: bool,
661) -> String {
662    let before = LineSide::from_text(old_content);
663    let after = LineSide::from_text(new_content);
664    if before.is_empty() && after.is_empty() {
665        return String::new();
666    }
667    let old_refs: Vec<&str> = before.lines.iter().map(|l| l.body).collect();
668    let new_refs: Vec<&str> = after.lines.iter().map(|l| l.body).collect();
669    let ws = super::WhitespaceMode::default();
670    let (mut old_normalized, mut new_normalized) = (Vec::new(), Vec::new());
671    let old_compare = line_compare_keys(old_content, &before, ws, &mut old_normalized);
672    let new_compare = line_compare_keys(new_content, &after, ws, &mut new_normalized);
673    let (changed1, changed2) = git_xdiff::patience_changed_flags(&old_compare, &new_compare);
674    let raw = changed_flags_to_ops(&changed1, &changed2, old_refs.len(), new_refs.len());
675    let ops = diff_indent_heuristic::apply_change_compact_to_ops(
676        &raw,
677        &old_refs,
678        &new_refs,
679        indent_heuristic,
680    );
681    let mut edits = diff_ops_to_edit_spans(&ops);
682    if edits.is_empty() {
683        if old_content != new_content {
684            edits = line_ending_only_edits(&before, &after);
685        }
686        if edits.is_empty() {
687            return String::new();
688        }
689    }
690    emit_unified_body_from_edits(
691        &before,
692        &after,
693        &edits,
694        UnifiedEmitOptions {
695            context_lines,
696            inter_hunk_context,
697            function_context: false,
698        },
699        None,
700    )
701}
702
703/// Parse `@@ -old +new @@` into 1-based starts and line counts (Git display form).
704pub(crate) fn unified_hunk_header_counts(
705    header_line: &str,
706) -> Option<(usize, usize, usize, usize)> {
707    let rest = header_line.strip_prefix("@@ ")?;
708    let (old_part, rest2) = rest.split_once(" +")?;
709    let old_tok = old_part.strip_prefix('-')?.split(' ').next()?;
710    let new_tok = rest2.split(" @@").next()?.split(' ').next()?;
711
712    fn parse_side(spec: &str) -> (usize, usize) {
713        if let Some((start, count)) = spec.split_once(',') {
714            (start.parse().unwrap_or(0), count.parse().unwrap_or(0))
715        } else {
716            (spec.parse().unwrap_or(0), 1)
717        }
718    }
719
720    let (os, ol) = parse_side(old_tok);
721    let (ns, nl) = parse_side(new_tok);
722    Some((os, ol, ns, nl))
723}
724
725/// Write `---` / `+++` and unified hunks using [`unified_hunk_body_for_diff_options`].
726#[allow(clippy::too_many_arguments)]
727pub(crate) fn write_unified_diff_from_diff_options<W: std::io::Write>(
728    out: &mut W,
729    old_content: &str,
730    new_content: &str,
731    old_path: &str,
732    new_path: &str,
733    src_prefix: &str,
734    dst_prefix: &str,
735    funcname_matcher: Option<&FuncnameMatcher>,
736    options: &crate::diff::options::DiffOptions,
737    quote_path_fully: bool,
738) -> Result<()> {
739    use crate::quote_path::format_diff_path_with_prefix;
740
741    if old_path == "/dev/null" {
742        out.write_all(b"--- /dev/null\n")?;
743    } else if src_prefix.is_empty() {
744        writeln!(out, "--- {old_path}")?;
745    } else {
746        out.write_all(b"--- ")?;
747        out.write_all(
748            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
749        )?;
750        out.write_all(b"\n")?;
751    }
752    if new_path == "/dev/null" {
753        out.write_all(b"+++ /dev/null\n")?;
754    } else if dst_prefix.is_empty() {
755        writeln!(out, "+++ {new_path}")?;
756    } else {
757        out.write_all(b"+++ ")?;
758        out.write_all(
759            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
760        )?;
761        out.write_all(b"\n")?;
762    }
763
764    let body = unified_hunk_body_for_diff_options(old_content, new_content, options);
765    if body.is_empty() {
766        return Ok(());
767    }
768    let old_lines: Vec<&str> = old_content.lines().collect();
769    for hunk_str in imara_unified_hunk_slices(&body) {
770        if hunk_str.is_empty() {
771            continue;
772        }
773        if let Some(first_newline) = hunk_str.find('\n') {
774            let header_line = &hunk_str[..first_newline];
775            let rest = &hunk_str[first_newline..];
776            if let Some(func_ctx) =
777                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
778            {
779                out.write_all(header_line.as_bytes())?;
780                out.write_all(b" ")?;
781                out.write_all(func_ctx.as_bytes())?;
782                out.write_all(rest.as_bytes())?;
783            } else {
784                out.write_all(hunk_str.as_bytes())?;
785            }
786        } else {
787            out.write_all(hunk_str.as_bytes())?;
788        }
789    }
790    Ok(())
791}
792
793#[expect(clippy::too_many_arguments)]
794fn unified_diff_git_myers_with_prefix_and_funcname(
795    old_content: &str,
796    new_content: &str,
797    old_path: &str,
798    new_path: &str,
799    context_lines: usize,
800    inter_hunk_context: usize,
801    src_prefix: &str,
802    dst_prefix: &str,
803    funcname_matcher: Option<&FuncnameMatcher>,
804    indent_heuristic: bool,
805    quote_path_fully: bool,
806) -> String {
807    use crate::quote_path::format_diff_path_with_prefix;
808
809    let body = git_xdiff_unified_body_raw(
810        old_content,
811        new_content,
812        context_lines,
813        inter_hunk_context,
814        indent_heuristic,
815        false,
816        super::WhitespaceMode::default(),
817    );
818
819    let mut output = String::new();
820    if old_path == "/dev/null" {
821        output.push_str("--- /dev/null\n");
822    } else if src_prefix.is_empty() {
823        output.push_str(&format!("--- {old_path}\n"));
824    } else {
825        output.push_str("--- ");
826        output.push_str(&format_diff_path_with_prefix(
827            src_prefix,
828            old_path,
829            quote_path_fully,
830        ));
831        output.push('\n');
832    }
833    if new_path == "/dev/null" {
834        output.push_str("+++ /dev/null\n");
835    } else if dst_prefix.is_empty() {
836        output.push_str(&format!("+++ {new_path}\n"));
837    } else {
838        output.push_str("+++ ");
839        output.push_str(&format_diff_path_with_prefix(
840            dst_prefix,
841            new_path,
842            quote_path_fully,
843        ));
844        output.push('\n');
845    }
846
847    let old_lines: Vec<&str> = old_content.lines().collect();
848    for hunk_str in imara_unified_hunk_slices(&body) {
849        if hunk_str.is_empty() {
850            continue;
851        }
852        if let Some(first_newline) = hunk_str.find('\n') {
853            let header_line = &hunk_str[..first_newline];
854            let rest = &hunk_str[first_newline..];
855            if let Some(func_ctx) =
856                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
857            {
858                output.push_str(header_line);
859                output.push(' ');
860                output.push_str(&func_ctx);
861                output.push_str(rest);
862            } else {
863                output.push_str(hunk_str);
864            }
865        } else {
866            output.push_str(hunk_str);
867        }
868    }
869
870    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
871}
872
873#[expect(clippy::too_many_arguments)]
874fn unified_diff_git_minimal_with_prefix_and_funcname(
875    old_content: &str,
876    new_content: &str,
877    old_path: &str,
878    new_path: &str,
879    context_lines: usize,
880    inter_hunk_context: usize,
881    src_prefix: &str,
882    dst_prefix: &str,
883    funcname_matcher: Option<&FuncnameMatcher>,
884    indent_heuristic: bool,
885    quote_path_fully: bool,
886) -> String {
887    use crate::quote_path::format_diff_path_with_prefix;
888
889    let body = git_xdiff_unified_body_raw(
890        old_content,
891        new_content,
892        context_lines,
893        inter_hunk_context,
894        indent_heuristic,
895        true,
896        super::WhitespaceMode::default(),
897    );
898
899    let mut output = String::new();
900    if old_path == "/dev/null" {
901        output.push_str("--- /dev/null\n");
902    } else if src_prefix.is_empty() {
903        output.push_str(&format!("--- {old_path}\n"));
904    } else {
905        output.push_str("--- ");
906        output.push_str(&format_diff_path_with_prefix(
907            src_prefix,
908            old_path,
909            quote_path_fully,
910        ));
911        output.push('\n');
912    }
913    if new_path == "/dev/null" {
914        output.push_str("+++ /dev/null\n");
915    } else if dst_prefix.is_empty() {
916        output.push_str(&format!("+++ {new_path}\n"));
917    } else {
918        output.push_str("+++ ");
919        output.push_str(&format_diff_path_with_prefix(
920            dst_prefix,
921            new_path,
922            quote_path_fully,
923        ));
924        output.push('\n');
925    }
926
927    let old_lines: Vec<&str> = old_content.lines().collect();
928    for hunk_str in imara_unified_hunk_slices(&body) {
929        if hunk_str.is_empty() {
930            continue;
931        }
932        if let Some(first_newline) = hunk_str.find('\n') {
933            let header_line = &hunk_str[..first_newline];
934            let rest = &hunk_str[first_newline..];
935            if let Some(func_ctx) =
936                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
937            {
938                output.push_str(header_line);
939                output.push(' ');
940                output.push_str(&func_ctx);
941                output.push_str(rest);
942            } else {
943                output.push_str(hunk_str);
944            }
945        } else {
946            output.push_str(hunk_str);
947        }
948    }
949
950    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
951}
952
953#[expect(clippy::too_many_arguments)]
954fn unified_diff_git_patience_with_prefix_and_funcname(
955    old_content: &str,
956    new_content: &str,
957    old_path: &str,
958    new_path: &str,
959    context_lines: usize,
960    inter_hunk_context: usize,
961    src_prefix: &str,
962    dst_prefix: &str,
963    funcname_matcher: Option<&FuncnameMatcher>,
964    indent_heuristic: bool,
965    quote_path_fully: bool,
966) -> String {
967    use crate::quote_path::format_diff_path_with_prefix;
968
969    let body = git_patience_unified_body_raw(
970        old_content,
971        new_content,
972        context_lines,
973        inter_hunk_context,
974        indent_heuristic,
975    );
976
977    let mut output = String::new();
978    if old_path == "/dev/null" {
979        output.push_str("--- /dev/null\n");
980    } else if src_prefix.is_empty() {
981        output.push_str(&format!("--- {old_path}\n"));
982    } else {
983        output.push_str("--- ");
984        output.push_str(&format_diff_path_with_prefix(
985            src_prefix,
986            old_path,
987            quote_path_fully,
988        ));
989        output.push('\n');
990    }
991    if new_path == "/dev/null" {
992        output.push_str("+++ /dev/null\n");
993    } else if dst_prefix.is_empty() {
994        output.push_str(&format!("+++ {new_path}\n"));
995    } else {
996        output.push_str("+++ ");
997        output.push_str(&format_diff_path_with_prefix(
998            dst_prefix,
999            new_path,
1000            quote_path_fully,
1001        ));
1002        output.push('\n');
1003    }
1004
1005    let old_lines: Vec<&str> = old_content.lines().collect();
1006    for hunk_str in imara_unified_hunk_slices(&body) {
1007        if hunk_str.is_empty() {
1008            continue;
1009        }
1010        if let Some(first_newline) = hunk_str.find('\n') {
1011            let header_line = &hunk_str[..first_newline];
1012            let rest = &hunk_str[first_newline..];
1013            if let Some(func_ctx) =
1014                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1015            {
1016                output.push_str(header_line);
1017                output.push(' ');
1018                output.push_str(&func_ctx);
1019                output.push_str(rest);
1020            } else {
1021                output.push_str(hunk_str);
1022            }
1023        } else {
1024            output.push_str(hunk_str);
1025        }
1026    }
1027
1028    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1029}
1030
1031/// Unified diff hunks for Git's histogram algorithm (no `---` / `+++` lines).
1032///
1033/// Used by `--no-index` when whitespace normalization is off so the patch matches upstream Git.
1034#[must_use]
1035pub fn unified_diff_histogram_hunks_only(
1036    old_content: &str,
1037    new_content: &str,
1038    context_lines: usize,
1039    inter_hunk_context: usize,
1040    indent_heuristic: bool,
1041) -> String {
1042    histogram_unified_body_raw(
1043        old_content,
1044        new_content,
1045        context_lines,
1046        inter_hunk_context,
1047        indent_heuristic,
1048        super::WhitespaceMode::default(),
1049    )
1050}
1051#[expect(clippy::too_many_arguments)]
1052/// Full unified diff (`---` / `+++` / hunks) using Git's histogram algorithm.
1053#[must_use]
1054pub fn unified_diff_histogram_with_prefix_and_funcname(
1055    old_content: &str,
1056    new_content: &str,
1057    old_path: &str,
1058    new_path: &str,
1059    context_lines: usize,
1060    inter_hunk_context: usize,
1061    src_prefix: &str,
1062    dst_prefix: &str,
1063    funcname_matcher: Option<&FuncnameMatcher>,
1064    indent_heuristic: bool,
1065    quote_path_fully: bool,
1066) -> String {
1067    unified_diff_histogram_with_prefix_and_funcname_indent(
1068        old_content,
1069        new_content,
1070        old_path,
1071        new_path,
1072        context_lines,
1073        inter_hunk_context,
1074        src_prefix,
1075        dst_prefix,
1076        funcname_matcher,
1077        indent_heuristic,
1078        quote_path_fully,
1079    )
1080}
1081
1082#[expect(clippy::too_many_arguments)]
1083fn unified_diff_histogram_with_prefix_and_funcname_indent(
1084    old_content: &str,
1085    new_content: &str,
1086    old_path: &str,
1087    new_path: &str,
1088    context_lines: usize,
1089    inter_hunk_context: usize,
1090    src_prefix: &str,
1091    dst_prefix: &str,
1092    funcname_matcher: Option<&FuncnameMatcher>,
1093    indent_heuristic: bool,
1094    quote_path_fully: bool,
1095) -> String {
1096    use crate::quote_path::format_diff_path_with_prefix;
1097
1098    let body = histogram_unified_body_raw(
1099        old_content,
1100        new_content,
1101        context_lines,
1102        inter_hunk_context,
1103        indent_heuristic,
1104        super::WhitespaceMode::default(),
1105    );
1106
1107    let mut output = String::new();
1108    if old_path == "/dev/null" {
1109        output.push_str("--- /dev/null\n");
1110    } else if src_prefix.is_empty() {
1111        output.push_str(&format!("--- {old_path}\n"));
1112    } else {
1113        output.push_str("--- ");
1114        output.push_str(&format_diff_path_with_prefix(
1115            src_prefix,
1116            old_path,
1117            quote_path_fully,
1118        ));
1119        output.push('\n');
1120    }
1121    if new_path == "/dev/null" {
1122        output.push_str("+++ /dev/null\n");
1123    } else if dst_prefix.is_empty() {
1124        output.push_str(&format!("+++ {new_path}\n"));
1125    } else {
1126        output.push_str("+++ ");
1127        output.push_str(&format_diff_path_with_prefix(
1128            dst_prefix,
1129            new_path,
1130            quote_path_fully,
1131        ));
1132        output.push('\n');
1133    }
1134
1135    let old_lines: Vec<&str> = old_content.lines().collect();
1136    for hunk_str in imara_unified_hunk_slices(&body) {
1137        if hunk_str.is_empty() {
1138            continue;
1139        }
1140        if let Some(first_newline) = hunk_str.find('\n') {
1141            let header_line = &hunk_str[..first_newline];
1142            let rest = &hunk_str[first_newline..];
1143            if let Some(func_ctx) =
1144                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1145            {
1146                output.push_str(header_line);
1147                output.push(' ');
1148                output.push_str(&func_ctx);
1149                output.push_str(rest);
1150            } else {
1151                output.push_str(hunk_str);
1152            }
1153        } else {
1154            output.push_str(hunk_str);
1155        }
1156    }
1157
1158    annotate_unified_diff_no_newline_at_eof(&output, old_content, new_content)
1159}
1160
1161/// Full unified diff (`---` / `+++` / hunks) using Git's histogram algorithm,
1162/// applying `--ignore-blank-lines` / `-I` change-record suppression
1163/// (`histogram_unified_body_ignore_fc`) and then attaching function-name hunk
1164/// headers exactly like [`unified_diff_histogram_with_prefix_and_funcname`].
1165///
1166/// `is_ignorable_change` is given the removed/added line slices (without
1167/// trailing newlines) of one change record and returns whether the whole change
1168/// is ignorable. Returns an empty string when no substantive change survives so
1169/// the caller can hide the file entry.
1170#[allow(clippy::too_many_arguments)]
1171pub fn unified_diff_histogram_ignore_with_prefix_and_funcname<F>(
1172    old_content: &str,
1173    new_content: &str,
1174    old_path: &str,
1175    new_path: &str,
1176    context_lines: usize,
1177    inter_hunk_context: usize,
1178    src_prefix: &str,
1179    dst_prefix: &str,
1180    funcname_matcher: Option<&FuncnameMatcher>,
1181    quote_path_fully: bool,
1182    function_context: bool,
1183    indent_heuristic: bool,
1184    is_ignorable_change: F,
1185) -> String
1186where
1187    F: Fn(&[&str], &[&str]) -> bool,
1188{
1189    use crate::quote_path::format_diff_path_with_prefix;
1190
1191    let body = histogram_unified_body_ignore_fc(
1192        old_content,
1193        new_content,
1194        context_lines,
1195        inter_hunk_context,
1196        function_context,
1197        funcname_matcher,
1198        indent_heuristic,
1199        super::WhitespaceMode::default(),
1200        is_ignorable_change,
1201    );
1202    if body.is_empty() {
1203        return String::new();
1204    }
1205
1206    let mut output = String::new();
1207    if old_path == "/dev/null" {
1208        output.push_str("--- /dev/null\n");
1209    } else if src_prefix.is_empty() {
1210        output.push_str(&format!("--- {old_path}\n"));
1211    } else {
1212        output.push_str("--- ");
1213        output.push_str(&format_diff_path_with_prefix(
1214            src_prefix,
1215            old_path,
1216            quote_path_fully,
1217        ));
1218        output.push('\n');
1219    }
1220    if new_path == "/dev/null" {
1221        output.push_str("+++ /dev/null\n");
1222    } else if dst_prefix.is_empty() {
1223        output.push_str(&format!("+++ {new_path}\n"));
1224    } else {
1225        output.push_str("+++ ");
1226        output.push_str(&format_diff_path_with_prefix(
1227            dst_prefix,
1228            new_path,
1229            quote_path_fully,
1230        ));
1231        output.push('\n');
1232    }
1233
1234    let old_lines: Vec<&str> = old_content.lines().collect();
1235    for hunk_str in imara_unified_hunk_slices(&body) {
1236        if hunk_str.is_empty() {
1237            continue;
1238        }
1239        if let Some(first_newline) = hunk_str.find('\n') {
1240            let header_line = &hunk_str[..first_newline];
1241            let rest = &hunk_str[first_newline..];
1242            if let Some(func_ctx) =
1243                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
1244            {
1245                output.push_str(header_line);
1246                output.push(' ');
1247                output.push_str(&func_ctx);
1248                output.push_str(rest);
1249            } else {
1250                output.push_str(hunk_str);
1251            }
1252        } else {
1253            output.push_str(hunk_str);
1254        }
1255    }
1256
1257    output
1258}
1259
1260/// `diff.indentHeuristic` from config (Git defaults to true when unset).
1261#[must_use]
1262pub fn indent_heuristic_from_config(config: &ConfigSet) -> bool {
1263    match config.get_bool("diff.indentHeuristic") {
1264        Some(Ok(b)) => b,
1265        Some(Err(_)) | None => true,
1266    }
1267}
1268
1269/// Resolve indent heuristic: `--no-indent-heuristic` and `--indent-heuristic` override config.
1270#[must_use]
1271pub fn resolve_indent_heuristic(
1272    config: &ConfigSet,
1273    cli_indent_heuristic: bool,
1274    cli_no_indent_heuristic: bool,
1275) -> bool {
1276    if cli_no_indent_heuristic {
1277        false
1278    } else if cli_indent_heuristic {
1279        true
1280    } else {
1281        indent_heuristic_from_config(config)
1282    }
1283}
1284
1285/// Parse `--indent-heuristic` / `--no-indent-heuristic` from a plumbing argv slice (last occurrence wins).
1286#[must_use]
1287pub fn parse_indent_heuristic_cli_flags(argv: &[String]) -> (bool, bool) {
1288    let mut indent_heuristic = false;
1289    let mut no_indent_heuristic = false;
1290    for a in argv {
1291        match a.as_str() {
1292            "--indent-heuristic" => {
1293                indent_heuristic = true;
1294                no_indent_heuristic = false;
1295            }
1296            "--no-indent-heuristic" => {
1297                no_indent_heuristic = true;
1298                indent_heuristic = false;
1299            }
1300            _ => {}
1301        }
1302    }
1303    (indent_heuristic, no_indent_heuristic)
1304}
1305
1306// ---------------------------------------------------------------------------
1307// Git-compatible implementation of the xdiff Myers engine,
1308// restricted to what the word-diff path needs (no rename/ignore-whitespace
1309// flags). Git's word diff runs the *default* xdiff Myers over the per-word
1310// token streams; matching Git's exact change-record selection requires
1311// reproducing its preprocessing (record classification + `xdl_cleanup_records`
1312// + `xdl_trim_ends`) and its divide-and-conquer split, because `imara-diff`
1313// (and `similar`) pick different — but equally minimal — alignments for inputs
1314// with many repeated tokens (e.g. the `{`/`}`/`,` in the `bibtex` driver).
1315mod git_xdiff {
1316    const XDL_MAX_COST_MIN: i64 = 256;
1317    const XDL_HEUR_MIN_COST: i64 = 256;
1318    const XDL_SNAKE_CNT: i64 = 20;
1319    const XDL_K_HEUR: i64 = 4;
1320    const XDL_LINE_MAX: i64 = i64::MAX;
1321    const XDL_KPDIS_RUN: i64 = 4;
1322    const XDL_MAX_EQLIMIT: i64 = 1024;
1323    const XDL_SIMSCAN_WINDOW: i64 = 100;
1324
1325    // record-classification actions (xprepare.c)
1326    const DISCARD: u8 = 0;
1327    const KEEP: u8 = 1;
1328    const INVESTIGATE: u8 = 2;
1329
1330    /// Classical integer square root approximation using shifts (`xdl_bogosqrt`).
1331    fn bogosqrt(mut n: i64) -> i64 {
1332        let mut i: i64 = 1;
1333        while n > 0 {
1334            i <<= 1;
1335            n >>= 2;
1336        }
1337        i
1338    }
1339
1340    struct XdAlgoEnv {
1341        mxcost: i64,
1342        snake_cnt: i64,
1343        heur_min: i64,
1344    }
1345
1346    struct XdpSplit {
1347        i1: i64,
1348        i2: i64,
1349        min_lo: bool,
1350        min_hi: bool,
1351    }
1352
1353    /// A single side of the diff: token ids plus the `changed` flags and the
1354    /// `reference_index` mapping from effective record -> original record.
1355    struct XdFile<'a> {
1356        ids: &'a [u32],
1357        changed: Vec<bool>,
1358        reference_index: Vec<usize>,
1359        dstart: i64,
1360        dend: i64,
1361        nreff: i64,
1362    }
1363
1364    /// Hash/id lookup over the *effective* records: index into `reference_index`.
1365    #[inline]
1366    fn get_id(xdf: &XdFile<'_>, idx: i64) -> u32 {
1367        xdf.ids[xdf.reference_index[idx as usize]]
1368    }
1369
1370    /// Faithful port of `xdl_split` (the middle-snake finder). `kvdf`/`kvdb` are
1371    /// the forward/backward k-vectors, offset so index 0 maps to diagonal 0.
1372    #[allow(clippy::too_many_arguments)]
1373    fn xdl_split(
1374        xdf1: &XdFile<'_>,
1375        off1: i64,
1376        lim1: i64,
1377        xdf2: &XdFile<'_>,
1378        off2: i64,
1379        lim2: i64,
1380        kvdf: &mut [i64],
1381        kvdb: &mut [i64],
1382        koff: i64,
1383        need_min: bool,
1384        spl: &mut XdpSplit,
1385        xenv: &XdAlgoEnv,
1386    ) -> i64 {
1387        let dmin = off1 - lim2;
1388        let dmax = lim1 - off2;
1389        let fmid = off1 - off2;
1390        let bmid = lim1 - lim2;
1391        let odd = ((fmid - bmid) & 1) != 0;
1392        let mut fmin = fmid;
1393        let mut fmax = fmid;
1394        let mut bmin = bmid;
1395        let mut bmax = bmid;
1396
1397        let kf = |k: i64| -> usize { (k + koff) as usize };
1398
1399        kvdf[kf(fmid)] = off1;
1400        kvdb[kf(bmid)] = lim1;
1401
1402        let mut ec: i64 = 1;
1403        loop {
1404            let mut got_snake = false;
1405
1406            if fmin > dmin {
1407                fmin -= 1;
1408                kvdf[kf(fmin - 1)] = -1;
1409            } else {
1410                fmin += 1;
1411            }
1412            if fmax < dmax {
1413                fmax += 1;
1414                kvdf[kf(fmax + 1)] = -1;
1415            } else {
1416                fmax -= 1;
1417            }
1418
1419            let mut d = fmax;
1420            while d >= fmin {
1421                let mut i1 = if kvdf[kf(d - 1)] >= kvdf[kf(d + 1)] {
1422                    kvdf[kf(d - 1)] + 1
1423                } else {
1424                    kvdf[kf(d + 1)]
1425                };
1426                let prev1 = i1;
1427                let mut i2 = i1 - d;
1428                while i1 < lim1 && i2 < lim2 && get_id(xdf1, i1) == get_id(xdf2, i2) {
1429                    i1 += 1;
1430                    i2 += 1;
1431                }
1432                if i1 - prev1 > xenv.snake_cnt {
1433                    got_snake = true;
1434                }
1435                kvdf[kf(d)] = i1;
1436                if odd && bmin <= d && d <= bmax && kvdb[kf(d)] <= i1 {
1437                    spl.i1 = i1;
1438                    spl.i2 = i2;
1439                    spl.min_lo = true;
1440                    spl.min_hi = true;
1441                    return ec;
1442                }
1443                d -= 2;
1444            }
1445
1446            if bmin > dmin {
1447                bmin -= 1;
1448                kvdb[kf(bmin - 1)] = XDL_LINE_MAX;
1449            } else {
1450                bmin += 1;
1451            }
1452            if bmax < dmax {
1453                bmax += 1;
1454                kvdb[kf(bmax + 1)] = XDL_LINE_MAX;
1455            } else {
1456                bmax -= 1;
1457            }
1458
1459            let mut d = bmax;
1460            while d >= bmin {
1461                let mut i1 = if kvdb[kf(d - 1)] < kvdb[kf(d + 1)] {
1462                    kvdb[kf(d - 1)]
1463                } else {
1464                    kvdb[kf(d + 1)] - 1
1465                };
1466                let prev1 = i1;
1467                let mut i2 = i1 - d;
1468                while i1 > off1 && i2 > off2 && get_id(xdf1, i1 - 1) == get_id(xdf2, i2 - 1) {
1469                    i1 -= 1;
1470                    i2 -= 1;
1471                }
1472                if prev1 - i1 > xenv.snake_cnt {
1473                    got_snake = true;
1474                }
1475                kvdb[kf(d)] = i1;
1476                if !odd && fmin <= d && d <= fmax && i1 <= kvdf[kf(d)] {
1477                    spl.i1 = i1;
1478                    spl.i2 = i2;
1479                    spl.min_lo = true;
1480                    spl.min_hi = true;
1481                    return ec;
1482                }
1483                d -= 2;
1484            }
1485
1486            if need_min {
1487                ec += 1;
1488                continue;
1489            }
1490
1491            if got_snake && ec > xenv.heur_min {
1492                let mut best = 0i64;
1493                let mut d = fmax;
1494                while d >= fmin {
1495                    let dd = if d > fmid { d - fmid } else { fmid - d };
1496                    let i1 = kvdf[kf(d)];
1497                    let i2 = i1 - d;
1498                    let v = (i1 - off1) + (i2 - off2) - dd;
1499                    if v > XDL_K_HEUR * ec
1500                        && v > best
1501                        && off1 + xenv.snake_cnt <= i1
1502                        && i1 < lim1
1503                        && off2 + xenv.snake_cnt <= i2
1504                        && i2 < lim2
1505                    {
1506                        let mut k = 1i64;
1507                        while get_id(xdf1, i1 - k) == get_id(xdf2, i2 - k) {
1508                            if k == xenv.snake_cnt {
1509                                best = v;
1510                                spl.i1 = i1;
1511                                spl.i2 = i2;
1512                                break;
1513                            }
1514                            k += 1;
1515                        }
1516                    }
1517                    d -= 2;
1518                }
1519                if best > 0 {
1520                    spl.min_lo = true;
1521                    spl.min_hi = false;
1522                    return ec;
1523                }
1524
1525                let mut best = 0i64;
1526                let mut d = bmax;
1527                while d >= bmin {
1528                    let dd = if d > bmid { d - bmid } else { bmid - d };
1529                    let i1 = kvdb[kf(d)];
1530                    let i2 = i1 - d;
1531                    let v = (lim1 - i1) + (lim2 - i2) - dd;
1532                    if v > XDL_K_HEUR * ec
1533                        && v > best
1534                        && off1 < i1
1535                        && i1 <= lim1 - xenv.snake_cnt
1536                        && off2 < i2
1537                        && i2 <= lim2 - xenv.snake_cnt
1538                    {
1539                        let mut k = 0i64;
1540                        while get_id(xdf1, i1 + k) == get_id(xdf2, i2 + k) {
1541                            if k == xenv.snake_cnt - 1 {
1542                                best = v;
1543                                spl.i1 = i1;
1544                                spl.i2 = i2;
1545                                break;
1546                            }
1547                            k += 1;
1548                        }
1549                    }
1550                    d -= 2;
1551                }
1552                if best > 0 {
1553                    spl.min_lo = false;
1554                    spl.min_hi = true;
1555                    return ec;
1556                }
1557            }
1558
1559            if ec >= xenv.mxcost {
1560                let mut fbest = -1i64;
1561                let mut fbest1 = -1i64;
1562                let mut d = fmax;
1563                while d >= fmin {
1564                    let mut i1 = kvdf[kf(d)].min(lim1);
1565                    let mut i2 = i1 - d;
1566                    if lim2 < i2 {
1567                        i1 = lim2 + d;
1568                        i2 = lim2;
1569                    }
1570                    if fbest < i1 + i2 {
1571                        fbest = i1 + i2;
1572                        fbest1 = i1;
1573                    }
1574                    d -= 2;
1575                }
1576
1577                let mut bbest = XDL_LINE_MAX;
1578                let mut bbest1 = XDL_LINE_MAX;
1579                let mut d = bmax;
1580                while d >= bmin {
1581                    let mut i1 = off1.max(kvdb[kf(d)]);
1582                    let mut i2 = i1 - d;
1583                    if i2 < off2 {
1584                        i1 = off2 + d;
1585                        i2 = off2;
1586                    }
1587                    if i1 + i2 < bbest {
1588                        bbest = i1 + i2;
1589                        bbest1 = i1;
1590                    }
1591                    d -= 2;
1592                }
1593
1594                if (lim1 + lim2) - bbest < fbest - (off1 + off2) {
1595                    spl.i1 = fbest1;
1596                    spl.i2 = fbest - fbest1;
1597                    spl.min_lo = true;
1598                    spl.min_hi = false;
1599                } else {
1600                    spl.i1 = bbest1;
1601                    spl.i2 = bbest - bbest1;
1602                    spl.min_lo = false;
1603                    spl.min_hi = true;
1604                }
1605                return ec;
1606            }
1607
1608            ec += 1;
1609        }
1610    }
1611
1612    /// Faithful port of `xdl_recs_cmp` (divide & conquer). Marks `changed` flags.
1613    #[allow(clippy::too_many_arguments)]
1614    fn xdl_recs_cmp(
1615        xdf1: &mut XdFile<'_>,
1616        mut off1: i64,
1617        mut lim1: i64,
1618        xdf2: &mut XdFile<'_>,
1619        mut off2: i64,
1620        mut lim2: i64,
1621        kvdf: &mut [i64],
1622        kvdb: &mut [i64],
1623        koff: i64,
1624        need_min: bool,
1625        xenv: &XdAlgoEnv,
1626    ) {
1627        while off1 < lim1 && off2 < lim2 && get_id(xdf1, off1) == get_id(xdf2, off2) {
1628            off1 += 1;
1629            off2 += 1;
1630        }
1631        while off1 < lim1 && off2 < lim2 && get_id(xdf1, lim1 - 1) == get_id(xdf2, lim2 - 1) {
1632            lim1 -= 1;
1633            lim2 -= 1;
1634        }
1635
1636        if off1 == lim1 {
1637            while off2 < lim2 {
1638                let r = xdf2.reference_index[off2 as usize];
1639                xdf2.changed[r] = true;
1640                off2 += 1;
1641            }
1642        } else if off2 == lim2 {
1643            while off1 < lim1 {
1644                let r = xdf1.reference_index[off1 as usize];
1645                xdf1.changed[r] = true;
1646                off1 += 1;
1647            }
1648        } else {
1649            let mut spl = XdpSplit {
1650                i1: 0,
1651                i2: 0,
1652                min_lo: false,
1653                min_hi: false,
1654            };
1655            xdl_split(
1656                xdf1, off1, lim1, xdf2, off2, lim2, kvdf, kvdb, koff, need_min, &mut spl, xenv,
1657            );
1658            xdl_recs_cmp(
1659                xdf1, off1, spl.i1, xdf2, off2, spl.i2, kvdf, kvdb, koff, spl.min_lo, xenv,
1660            );
1661            xdl_recs_cmp(
1662                xdf1, spl.i1, lim1, xdf2, spl.i2, lim2, kvdf, kvdb, koff, spl.min_hi, xenv,
1663            );
1664        }
1665    }
1666
1667    /// `xdl_clean_mmatch`: decide whether a multimatch record should be discarded.
1668    fn clean_mmatch(action: &[u8], i: i64, mut s: i64, mut e: i64) -> bool {
1669        if i - s > XDL_SIMSCAN_WINDOW {
1670            s = i - XDL_SIMSCAN_WINDOW;
1671        }
1672        if e - i > XDL_SIMSCAN_WINDOW {
1673            e = i + XDL_SIMSCAN_WINDOW;
1674        }
1675
1676        let mut rdis0 = 0i64;
1677        let mut rpdis0 = 1i64;
1678        let mut r = 1i64;
1679        while i - r >= s {
1680            match action[(i - r) as usize] {
1681                DISCARD => rdis0 += 1,
1682                INVESTIGATE => rpdis0 += 1,
1683                _ => break, // KEEP
1684            }
1685            r += 1;
1686        }
1687        if rdis0 == 0 {
1688            return false;
1689        }
1690        let mut rdis1 = 0i64;
1691        let mut rpdis1 = 1i64;
1692        let mut r = 1i64;
1693        while i + r <= e {
1694            match action[(i + r) as usize] {
1695                DISCARD => rdis1 += 1,
1696                INVESTIGATE => rpdis1 += 1,
1697                _ => break, // KEEP
1698            }
1699            r += 1;
1700        }
1701        if rdis1 == 0 {
1702            return false;
1703        }
1704        rdis1 += rdis0;
1705        rpdis1 += rpdis0;
1706        rpdis1 * XDL_KPDIS_RUN < (rpdis1 + rdis1)
1707    }
1708
1709    /// Build the `changed` flags for both token streams using Git's xdiff engine.
1710    /// When `need_min` is true, Git's minimal diff (`--minimal`) is used.
1711    /// `ids1`/`ids2` are dense interned token ids from one [`crate::line_interner::LineInterner`]
1712    /// (equal id <=> equal token); per-id tables are sized by the largest id.
1713    /// Returns `(changed1, changed2)`, one bool per original token.
1714    pub fn changed_flags(ids1: &[u32], ids2: &[u32], need_min: bool) -> (Vec<bool>, Vec<bool>) {
1715        let nrec1 = ids1.len();
1716        let nrec2 = ids2.len();
1717
1718        // Per-token occurrence counts on each side (class len1/len2), indexed by the dense ids.
1719        let nclasses = ids1
1720            .iter()
1721            .chain(ids2)
1722            .max()
1723            .map_or(0, |&max| max as usize + 1);
1724        let mut count1 = vec![0i64; nclasses];
1725        let mut count2 = vec![0i64; nclasses];
1726        for &id in ids1 {
1727            count1[id as usize] += 1;
1728        }
1729        for &id in ids2 {
1730            count2[id as usize] += 1;
1731        }
1732
1733        let mut xdf1 = XdFile {
1734            ids: ids1,
1735            changed: vec![false; nrec1],
1736            reference_index: Vec::new(),
1737            dstart: 0,
1738            dend: nrec1 as i64 - 1,
1739            nreff: 0,
1740        };
1741        let mut xdf2 = XdFile {
1742            ids: ids2,
1743            changed: vec![false; nrec2],
1744            reference_index: Vec::new(),
1745            dstart: 0,
1746            dend: nrec2 as i64 - 1,
1747            nreff: 0,
1748        };
1749
1750        // xdl_trim_ends: trim leading/trailing matching records.
1751        let lim = nrec1.min(nrec2) as i64;
1752        let mut i = 0i64;
1753        while i < lim && ids1[i as usize] == ids2[i as usize] {
1754            i += 1;
1755        }
1756        xdf1.dstart = i;
1757        xdf2.dstart = i;
1758        let mut j = 0i64;
1759        let rem = lim - i;
1760        while j < rem && ids1[nrec1 - 1 - j as usize] == ids2[nrec2 - 1 - j as usize] {
1761            j += 1;
1762        }
1763        xdf1.dend = nrec1 as i64 - j - 1;
1764        xdf2.dend = nrec2 as i64 - j - 1;
1765
1766        // xdl_cleanup_records: classify and reduce to effective records.
1767        let mut action1 = vec![0u8; nrec1 + 1];
1768        let mut action2 = vec![0u8; nrec2 + 1];
1769
1770        let mlim1 = if need_min {
1771            i64::MAX
1772        } else {
1773            let mut mlim = bogosqrt(nrec1 as i64);
1774            if mlim > XDL_MAX_EQLIMIT {
1775                mlim = XDL_MAX_EQLIMIT;
1776            }
1777            mlim
1778        };
1779        let mut idx = xdf1.dstart;
1780        while idx <= xdf1.dend {
1781            let id = ids1[idx as usize];
1782            let nm = count2[id as usize];
1783            action1[idx as usize] = if nm == 0 {
1784                DISCARD
1785            } else if nm >= mlim1 {
1786                INVESTIGATE
1787            } else {
1788                KEEP
1789            };
1790            idx += 1;
1791        }
1792
1793        let mlim2 = if need_min {
1794            i64::MAX
1795        } else {
1796            let mut mlim = bogosqrt(nrec2 as i64);
1797            if mlim > XDL_MAX_EQLIMIT {
1798                mlim = XDL_MAX_EQLIMIT;
1799            }
1800            mlim
1801        };
1802        let mut idx = xdf2.dstart;
1803        while idx <= xdf2.dend {
1804            let id = ids2[idx as usize];
1805            let nm = count1[id as usize];
1806            action2[idx as usize] = if nm == 0 {
1807                DISCARD
1808            } else if nm >= mlim2 {
1809                INVESTIGATE
1810            } else {
1811                KEEP
1812            };
1813            idx += 1;
1814        }
1815
1816        let mut idx = xdf1.dstart;
1817        while idx <= xdf1.dend {
1818            let a = action1[idx as usize];
1819            if a == KEEP
1820                || (a == INVESTIGATE && !clean_mmatch(&action1, idx, xdf1.dstart, xdf1.dend))
1821            {
1822                xdf1.reference_index.push(idx as usize);
1823            } else {
1824                xdf1.changed[idx as usize] = true;
1825            }
1826            idx += 1;
1827        }
1828        xdf1.nreff = xdf1.reference_index.len() as i64;
1829
1830        let mut idx = xdf2.dstart;
1831        while idx <= xdf2.dend {
1832            let a = action2[idx as usize];
1833            if a == KEEP
1834                || (a == INVESTIGATE && !clean_mmatch(&action2, idx, xdf2.dstart, xdf2.dend))
1835            {
1836                xdf2.reference_index.push(idx as usize);
1837            } else {
1838                xdf2.changed[idx as usize] = true;
1839            }
1840            idx += 1;
1841        }
1842        xdf2.nreff = xdf2.reference_index.len() as i64;
1843
1844        // Allocate K vectors (xdl_do_diff). koff lets us use negative diagonals.
1845        let ndiags = xdf1.nreff + xdf2.nreff + 3;
1846        let kvd_len = (2 * ndiags + 2) as usize;
1847        let mut kvd = vec![0i64; kvd_len];
1848        // kvdf base offset = nreff2 + 1; kvdb base offset = ndiags + nreff2 + 1.
1849        let koff = xdf2.nreff + 1;
1850        let (kvdf_slice, kvdb_slice) = kvd.split_at_mut(ndiags as usize);
1851        // Both slices are indexed as [k + koff]; their length covers the diagonal range.
1852
1853        let xenv = XdAlgoEnv {
1854            mxcost: bogosqrt(ndiags).max(XDL_MAX_COST_MIN),
1855            snake_cnt: XDL_SNAKE_CNT,
1856            heur_min: XDL_HEUR_MIN_COST,
1857        };
1858
1859        let nreff1 = xdf1.nreff;
1860        let nreff2 = xdf2.nreff;
1861        xdl_recs_cmp(
1862            &mut xdf1, 0, nreff1, &mut xdf2, 0, nreff2, kvdf_slice, kvdb_slice, koff, need_min,
1863            &xenv,
1864        );
1865
1866        (xdf1.changed, xdf2.changed)
1867    }
1868
1869    /// Patience diff: match unique lines, then recurse on gaps with xdiff Myers.
1870    pub fn patience_changed_flags(lines1: &[&str], lines2: &[&str]) -> (Vec<bool>, Vec<bool>) {
1871        let mut changed1 = vec![false; lines1.len()];
1872        let mut changed2 = vec![false; lines2.len()];
1873        patience_recurse(
1874            lines1,
1875            lines2,
1876            &mut changed1,
1877            &mut changed2,
1878            0,
1879            lines1.len(),
1880            0,
1881            lines2.len(),
1882        );
1883        (changed1, changed2)
1884    }
1885
1886    #[allow(clippy::too_many_arguments)]
1887    fn patience_recurse(
1888        lines1: &[&str],
1889        lines2: &[&str],
1890        changed1: &mut [bool],
1891        changed2: &mut [bool],
1892        o_start: usize,
1893        o_end: usize,
1894        n_start: usize,
1895        n_end: usize,
1896    ) {
1897        if o_start >= o_end && n_start >= n_end {
1898            return;
1899        }
1900        if o_start >= o_end {
1901            changed2[n_start..n_end].fill(true);
1902            return;
1903        }
1904        if n_start >= n_end {
1905            changed1[o_start..o_end].fill(true);
1906            return;
1907        }
1908
1909        let anchors = patience_anchors(lines1, lines2, o_start, o_end, n_start, n_end);
1910        if anchors.is_empty() {
1911            merge_myers_region(
1912                lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
1913            );
1914            return;
1915        }
1916
1917        let mut prev_o = o_start;
1918        let mut prev_n = n_start;
1919        for (ao, an) in anchors {
1920            patience_recurse(lines1, lines2, changed1, changed2, prev_o, ao, prev_n, an);
1921            prev_o = ao + 1;
1922            prev_n = an + 1;
1923        }
1924        patience_recurse(
1925            lines1, lines2, changed1, changed2, prev_o, o_end, prev_n, n_end,
1926        );
1927    }
1928
1929    #[allow(clippy::too_many_arguments)]
1930    fn patience_anchors(
1931        lines1: &[&str],
1932        lines2: &[&str],
1933        o_start: usize,
1934        o_end: usize,
1935        n_start: usize,
1936        n_end: usize,
1937    ) -> Vec<(usize, usize)> {
1938        use std::collections::HashMap;
1939
1940        let old_slice = &lines1[o_start..o_end];
1941        let new_slice = &lines2[n_start..n_end];
1942        let mut old_pos: HashMap<&str, Vec<usize>> = HashMap::new();
1943        let mut new_pos: HashMap<&str, Vec<usize>> = HashMap::new();
1944        for (i, line) in old_slice.iter().enumerate() {
1945            old_pos.entry(*line).or_default().push(o_start + i);
1946        }
1947        for (i, line) in new_slice.iter().enumerate() {
1948            new_pos.entry(*line).or_default().push(n_start + i);
1949        }
1950
1951        let mut pairs: Vec<(usize, usize)> = Vec::new();
1952        for (line, o_indices) in &old_pos {
1953            if o_indices.len() != 1 {
1954                continue;
1955            }
1956            if let Some(n_indices) = new_pos.get(line) {
1957                if n_indices.len() == 1 {
1958                    pairs.push((o_indices[0], n_indices[0]));
1959                }
1960            }
1961        }
1962        pairs.sort_by_key(|p| p.0);
1963        longest_increasing_by_second(&pairs)
1964    }
1965
1966    fn longest_increasing_by_second(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
1967        if pairs.is_empty() {
1968            return Vec::new();
1969        }
1970        let n = pairs.len();
1971        let mut tails = vec![0usize; n];
1972        let mut prev = vec![usize::MAX; n];
1973        let mut len = 0usize;
1974        for i in 0..n {
1975            let v = pairs[i].1;
1976            let mut lo = 0usize;
1977            let mut hi = len;
1978            while lo < hi {
1979                let mid = (lo + hi) / 2;
1980                if pairs[tails[mid]].1 < v {
1981                    lo = mid + 1;
1982                } else {
1983                    hi = mid;
1984                }
1985            }
1986            if lo > 0 {
1987                prev[i] = tails[lo - 1];
1988            }
1989            tails[lo] = i;
1990            if lo == len {
1991                len += 1;
1992            }
1993        }
1994        let mut out = Vec::with_capacity(len);
1995        let mut k = tails[len.saturating_sub(1)];
1996        while k != usize::MAX {
1997            out.push(pairs[k]);
1998            k = prev[k];
1999        }
2000        out.reverse();
2001        out
2002    }
2003
2004    pub(crate) fn histogram_changed_flags(
2005        lines1: &[&str],
2006        lines2: &[&str],
2007    ) -> (Vec<bool>, Vec<bool>) {
2008        crate::diff::git_histogram::histogram_changed_flags(
2009            lines1,
2010            lines2,
2011            |changed1, changed2, o_start, o_end, n_start, n_end| {
2012                merge_myers_region(
2013                    lines1, lines2, changed1, changed2, o_start, o_end, n_start, n_end,
2014                );
2015            },
2016        )
2017    }
2018
2019    #[allow(clippy::too_many_arguments)]
2020    fn merge_myers_region(
2021        lines1: &[&str],
2022        lines2: &[&str],
2023        changed1: &mut [bool],
2024        changed2: &mut [bool],
2025        o_start: usize,
2026        o_end: usize,
2027        n_start: usize,
2028        n_end: usize,
2029    ) {
2030        use crate::line_interner::LineInterner;
2031
2032        let old_slice = &lines1[o_start..o_end];
2033        let new_slice = &lines2[n_start..n_end];
2034        if old_slice.is_empty() && new_slice.is_empty() {
2035            return;
2036        }
2037        let mut interner = LineInterner::with_capacity(old_slice.len() + new_slice.len());
2038        let ids1 = interner.intern_all(old_slice);
2039        let ids2 = interner.intern_all(new_slice);
2040        let (sub1, sub2) = changed_flags(&ids1, &ids2, false);
2041        for (i, ch) in sub1.iter().enumerate() {
2042            if *ch {
2043                changed1[o_start + i] = true;
2044            }
2045        }
2046        for (j, ch) in sub2.iter().enumerate() {
2047            if *ch {
2048                changed2[n_start + j] = true;
2049            }
2050        }
2051    }
2052}
2053
2054/// Myers changed-line masks for a line slice, honoring [`super::WhitespaceMode`] like Git `xdl_fall_back_diff`.
2055pub(crate) fn myers_rediff_changed_masks_for_region<'a>(
2056    old_lines: &[&'a str],
2057    new_lines: &[&'a str],
2058    ws: super::WhitespaceMode,
2059) -> (Vec<bool>, Vec<bool>) {
2060    use crate::line_interner::LineInterner;
2061
2062    if old_lines.is_empty() && new_lines.is_empty() {
2063        return (Vec::new(), Vec::new());
2064    }
2065    let normalize = |lines: &[&'a str]| -> Vec<Cow<'a, str>> {
2066        lines
2067            .iter()
2068            .map(|line| ws.normalize_line_for_compare(line))
2069            .collect()
2070    };
2071    let (old_normalized, new_normalized) = (normalize(old_lines), normalize(new_lines));
2072    let old_keys: Vec<&str> = old_normalized.iter().map(Cow::as_ref).collect();
2073    let new_keys: Vec<&str> = new_normalized.iter().map(Cow::as_ref).collect();
2074    let mut interner = LineInterner::with_capacity(old_keys.len() + new_keys.len());
2075    let ids1 = interner.intern_all(&old_keys);
2076    let ids2 = interner.intern_all(&new_keys);
2077    git_xdiff::changed_flags(&ids1, &ids2, false)
2078}
2079
2080/// Convert per-token `changed` flags (Git xdiff style) into `similar::DiffOp`s.
2081fn changed_flags_to_ops(
2082    changed1: &[bool],
2083    changed2: &[bool],
2084    old_len: usize,
2085    new_len: usize,
2086) -> Vec<similar::DiffOp> {
2087    use similar::DiffOp;
2088    let mut ops: Vec<DiffOp> = Vec::new();
2089    let mut i = 0usize;
2090    let mut j = 0usize;
2091    while i < old_len || j < new_len {
2092        let del = i < old_len && changed1[i];
2093        let ins = j < new_len && changed2[j];
2094        if !del && !ins {
2095            if i < old_len && j < new_len {
2096                // Equal run on both sides.
2097                let start_i = i;
2098                let start_j = j;
2099                while i < old_len && j < new_len && !changed1[i] && !changed2[j] {
2100                    i += 1;
2101                    j += 1;
2102                }
2103                ops.push(DiffOp::Equal {
2104                    old_index: start_i,
2105                    new_index: start_j,
2106                    len: i - start_i,
2107                });
2108            } else if i < old_len {
2109                let start_i = i;
2110                while i < old_len {
2111                    i += 1;
2112                }
2113                ops.push(DiffOp::Delete {
2114                    old_index: start_i,
2115                    old_len: i - start_i,
2116                    new_index: j,
2117                });
2118            } else if j < new_len {
2119                let start_j = j;
2120                while j < new_len {
2121                    j += 1;
2122                }
2123                ops.push(DiffOp::Insert {
2124                    old_index: i,
2125                    new_index: start_j,
2126                    new_len: j - start_j,
2127                });
2128            }
2129        } else {
2130            // Changed run: consecutive deletions then/and insertions.
2131            let start_i = i;
2132            let start_j = j;
2133            while i < old_len && changed1[i] {
2134                i += 1;
2135            }
2136            while j < new_len && changed2[j] {
2137                j += 1;
2138            }
2139            let dlen = i - start_i;
2140            let ilen = j - start_j;
2141            if dlen > 0 && ilen > 0 {
2142                ops.push(DiffOp::Replace {
2143                    old_index: start_i,
2144                    old_len: dlen,
2145                    new_index: start_j,
2146                    new_len: ilen,
2147                });
2148            } else if dlen > 0 {
2149                ops.push(DiffOp::Delete {
2150                    old_index: start_i,
2151                    old_len: dlen,
2152                    new_index: start_j,
2153                });
2154            } else if ilen > 0 {
2155                ops.push(DiffOp::Insert {
2156                    old_index: start_i,
2157                    new_index: start_j,
2158                    new_len: ilen,
2159                });
2160            }
2161        }
2162    }
2163    ops
2164}
2165
2166/// Diff two token streams with a Git-compatible implementation of the xdiff Myers engine and return the
2167/// result as `similar::DiffOp`s. Used by the word-diff machinery, where matching Git's exact
2168/// record selection and tie-breaking matters: `imara-diff`/`similar` pick different — but
2169/// equally minimal — alignments for streams with many repeated tokens (e.g. the `bibtex`
2170/// driver's `{`/`}`/`,`), mismatching Git's reference output.
2171#[must_use]
2172pub fn word_diff_ops_imara(old_words: &[&str], new_words: &[&str]) -> Vec<similar::DiffOp> {
2173    use std::collections::HashMap;
2174
2175    // Intern tokens to ids (equal id <=> equal token), mirroring xdiff's record hashing.
2176    let mut interner: HashMap<&str, u32> = HashMap::new();
2177    let mut ids1: Vec<u32> = Vec::with_capacity(old_words.len());
2178    for &w in old_words {
2179        let next = interner.len() as u32;
2180        let id = *interner.entry(w).or_insert(next);
2181        ids1.push(id);
2182    }
2183    let mut ids2: Vec<u32> = Vec::with_capacity(new_words.len());
2184    for &w in new_words {
2185        let next = interner.len() as u32;
2186        let id = *interner.entry(w).or_insert(next);
2187        ids2.push(id);
2188    }
2189
2190    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, false);
2191    let ops = changed_flags_to_ops(&changed1, &changed2, old_words.len(), new_words.len());
2192
2193    // Slide changed runs to Git's canonical position (`xdl_change_compact`); the word
2194    // diff never enables the indent heuristic.
2195    diff_indent_heuristic::apply_change_compact_to_ops(&ops, old_words, new_words, false)
2196}
2197
2198/// Line-diff ops for string slices after Git `xdl_change_compact` (and optional indent heuristic).
2199#[must_use]
2200pub fn diff_slice_ops_compacted(
2201    old_lines: &[&str],
2202    new_lines: &[&str],
2203    algorithm: similar::Algorithm,
2204    indent_heuristic: bool,
2205) -> Vec<similar::DiffOp> {
2206    diff_indent_heuristic::diff_slice_ops_compacted(
2207        old_lines,
2208        new_lines,
2209        algorithm,
2210        indent_heuristic,
2211    )
2212}
2213
2214/// Line-diff ops using Git's xdiff Myers engine (default blame path; faster than `similar` on large files).
2215#[must_use]
2216pub(crate) fn line_diff_ops_git_xdiff_compacted(
2217    old_lines: &[&str],
2218    new_lines: &[&str],
2219    indent_heuristic: bool,
2220) -> Vec<similar::DiffOp> {
2221    let mut interner =
2222        crate::line_interner::LineInterner::with_capacity(old_lines.len() + new_lines.len());
2223    let ids1 = interner.intern_all(old_lines);
2224    let ids2 = interner.intern_all(new_lines);
2225    line_diff_ops_git_xdiff_compacted_ids(
2226        &ids1,
2227        &ids2,
2228        old_lines,
2229        new_lines,
2230        indent_heuristic,
2231        super::WhitespaceMode::default(),
2232    )
2233}
2234
2235/// Like [`line_diff_ops_git_xdiff_compacted`], but reuses precomputed line ids for each side.
2236#[must_use]
2237pub(crate) fn line_diff_ops_git_xdiff_compacted_ids(
2238    old_ids: &[u32],
2239    new_ids: &[u32],
2240    old_lines: &[&str],
2241    new_lines: &[&str],
2242    indent_heuristic: bool,
2243    ws: super::WhitespaceMode,
2244) -> Vec<similar::DiffOp> {
2245    let (changed1, changed2) = git_xdiff::changed_flags(old_ids, new_ids, false);
2246    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2247    diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
2248        &ops,
2249        old_lines,
2250        new_lines,
2251        indent_heuristic,
2252        ws,
2253    )
2254}
2255
2256/// Whether each new-side line is marked changed by Git's xdiff engine (after change compaction).
2257#[cfg(test)]
2258#[must_use]
2259pub(crate) fn xdiff_new_line_changed_flags(
2260    old_lines: &[&str],
2261    new_lines: &[&str],
2262    indent_heuristic: bool,
2263) -> Vec<bool> {
2264    let mut interner =
2265        crate::line_interner::LineInterner::with_capacity(old_lines.len() + new_lines.len());
2266    let ids1 = interner.intern_all(old_lines);
2267    let ids2 = interner.intern_all(new_lines);
2268    let (changed1, changed2) = git_xdiff::changed_flags(&ids1, &ids2, false);
2269    let ops = changed_flags_to_ops(&changed1, &changed2, old_lines.len(), new_lines.len());
2270    let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
2271        &ops,
2272        old_lines,
2273        new_lines,
2274        indent_heuristic,
2275    );
2276    let mut flags = vec![false; new_lines.len()];
2277    for op in compacted {
2278        match op.tag() {
2279            similar::DiffTag::Insert | similar::DiffTag::Replace => {
2280                for idx in op.new_range() {
2281                    if idx < flags.len() {
2282                        flags[idx] = true;
2283                    }
2284                }
2285            }
2286            similar::DiffTag::Equal | similar::DiffTag::Delete => {}
2287        }
2288    }
2289    flags
2290}
2291
2292/// Map each line in `new_joined` to its origin in `old_joined` after Git-style compaction (for blame).
2293#[must_use]
2294pub fn map_new_to_old_lines_compacted(
2295    old_joined: &str,
2296    new_joined: &str,
2297    algorithm: similar::Algorithm,
2298    indent_heuristic: bool,
2299    new_line_count: usize,
2300) -> Vec<Option<usize>> {
2301    let ops = diff_indent_heuristic::diff_lines_ops_compacted(
2302        old_joined,
2303        new_joined,
2304        algorithm,
2305        indent_heuristic,
2306    );
2307    diff_indent_heuristic::map_new_to_old_from_ops(&ops, new_line_count)
2308}
2309
2310/// The kind of change between two sides of a diff.
2311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2312#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2313pub enum DiffStatus {
2314    /// File was added.
2315    Added,
2316    /// File was deleted.
2317    Deleted,
2318    /// File was modified (content or mode change).
2319    Modified,
2320    /// File was renamed (with optional content change).
2321    Renamed,
2322    /// File was copied.
2323    Copied,
2324    /// File type changed (e.g. regular → symlink).
2325    TypeChanged,
2326    /// Unmerged (conflict).
2327    Unmerged,
2328}
2329
2330impl DiffStatus {
2331    /// Single-character status letter used in raw diff output.
2332    #[must_use]
2333    pub fn letter(&self) -> char {
2334        match self {
2335            Self::Added => 'A',
2336            Self::Deleted => 'D',
2337            Self::Modified => 'M',
2338            Self::Renamed => 'R',
2339            Self::Copied => 'C',
2340            Self::TypeChanged => 'T',
2341            Self::Unmerged => 'U',
2342        }
2343    }
2344
2345    /// Status for a path present on both sides with differing content or mode.
2346    ///
2347    /// Returns [`DiffStatus::TypeChanged`] when the object type bits of the two
2348    /// modes differ (regular file, symlink, gitlink) and [`DiffStatus::Modified`]
2349    /// otherwise, so a permission-only change stays `Modified`. Every diff mode
2350    /// (tree, index, work tree) shares this rule.
2351    #[must_use]
2352    pub(crate) fn for_content_change(old_mode: u32, new_mode: u32) -> Self {
2353        if old_mode & 0o170000 != new_mode & 0o170000 {
2354            Self::TypeChanged
2355        } else {
2356            Self::Modified
2357        }
2358    }
2359}
2360
2361/// A single diff entry representing one changed path.
2362#[derive(Debug, Clone, PartialEq, Eq)]
2363#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
2364pub struct DiffEntry {
2365    /// The status of this change.
2366    pub status: DiffStatus,
2367    /// Path in the "old" side (None for Added).
2368    pub old_path: Option<String>,
2369    /// Path in the "new" side (None for Deleted).
2370    pub new_path: Option<String>,
2371    /// Old file mode (as octal string, e.g. "100644").
2372    pub old_mode: String,
2373    /// New file mode.
2374    pub new_mode: String,
2375    /// Old object ID (zero OID for Added).
2376    pub old_oid: ObjectId,
2377    /// New object ID (zero OID for Deleted, or for Modified index↔worktree / tree↔worktree
2378    /// entries whose content lives only on disk — raw output uses the null OID on that side).
2379    pub new_oid: ObjectId,
2380    /// Similarity score (0–100) for renames/copies.
2381    pub score: Option<u32>,
2382}
2383
2384impl DiffEntry {
2385    /// The primary path for display (new_path for adds, old_path for deletes).
2386    #[must_use]
2387    pub fn path(&self) -> &str {
2388        self.new_path
2389            .as_deref()
2390            .or(self.old_path.as_deref())
2391            .unwrap_or("")
2392    }
2393
2394    /// Return a human-oriented path display for this entry.
2395    ///
2396    /// For renames and copies this returns `old -> new`; for all other entry
2397    /// kinds this returns the primary path.
2398    #[must_use]
2399    pub fn display_path(&self) -> String {
2400        match self.status {
2401            DiffStatus::Renamed | DiffStatus::Copied => {
2402                let old = self.old_path.as_deref().unwrap_or("");
2403                let new = self.new_path.as_deref().unwrap_or("");
2404                if old.is_empty() || new.is_empty() {
2405                    self.path().to_owned()
2406                } else {
2407                    format!("{old} -> {new}")
2408                }
2409            }
2410            _ => self.path().to_owned(),
2411        }
2412    }
2413}
2414
2415/// The zero (null) object ID used for "no object" in diff output.
2416pub const ZERO_OID: &str = "0000000000000000000000000000000000000000";
2417
2418/// Return the zero ObjectId.
2419#[must_use]
2420pub fn zero_oid() -> ObjectId {
2421    ObjectId::from_bytes(&[0u8; 20]).unwrap_or_else(|_| {
2422        // This should never fail since we pass exactly 20 bytes
2423        panic!("internal error: failed to create zero OID");
2424    })
2425}
2426
2427/// Return the ObjectId for the empty blob object.
2428#[must_use]
2429pub fn empty_blob_oid() -> ObjectId {
2430    ObjectId::from_hex("e69de29bb2d1d6434b8b29ae775ad8c2e48c5391").unwrap_or_else(|_| {
2431        // This should never fail since the object ID literal is valid.
2432        panic!("internal error: failed to create empty blob OID");
2433    })
2434}
2435
2436// ── Tree-to-tree diff ───────────────────────────────────────────────
2437
2438/// Compare two trees and return the list of changed entries.
2439///
2440/// # Parameters
2441///
2442/// - `odb` — object database to read tree objects from.
2443/// - `old_tree_oid` — OID of the old tree (or `None` for comparison against empty).
2444/// - `new_tree_oid` — OID of the new tree (or `None` for comparison against empty).
2445/// - `prefix` — path prefix for nested tree recursion (empty string for root).
2446///
2447/// # Errors
2448///
2449/// Returns errors from object database reads.
2450pub fn diff_trees(
2451    odb: &Odb,
2452    old_tree_oid: Option<&ObjectId>,
2453    new_tree_oid: Option<&ObjectId>,
2454    prefix: &str,
2455) -> Result<Vec<DiffEntry>> {
2456    crate::trace::trace_span!("grit.diff.tree");
2457    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, false, None)
2458}
2459
2460/// Like [`diff_trees`], but when `index` matches `old_tree_oid` deleted subtrees are expanded
2461/// from sorted index entries instead of reading every tree object from the ODB.
2462pub(crate) fn diff_trees_with_index(
2463    odb: &Odb,
2464    index: &Index,
2465    old_tree_oid: Option<&ObjectId>,
2466    new_tree_oid: Option<&ObjectId>,
2467    prefix: &str,
2468    trust_index_for_old_tree: bool,
2469) -> Result<Vec<DiffEntry>> {
2470    let hint = (trust_index_for_old_tree
2471        && old_tree_oid.is_some()
2472        && index.entries_sorted
2473        && !index.entries.iter().any(|e| e.stage() != 0))
2474    .then_some(DiffTreesIndexHint { index });
2475    diff_trees_opts(
2476        odb,
2477        old_tree_oid,
2478        new_tree_oid,
2479        prefix,
2480        false,
2481        hint.as_ref(),
2482    )
2483}
2484
2485#[derive(Clone, Copy)]
2486struct DiffTreesIndexHint<'a> {
2487    index: &'a Index,
2488}
2489
2490/// Count path-level differences between two trees without building a [`DiffEntry`] list.
2491///
2492/// Same semantics as [`diff_trees`], but avoids allocating diff records (issue #1064 commit hot path).
2493///
2494/// # Errors
2495///
2496/// Returns errors from object database reads.
2497pub fn diff_tree_path_change_count(
2498    odb: &Odb,
2499    old_tree_oid: Option<&ObjectId>,
2500    new_tree_oid: Option<&ObjectId>,
2501    prefix: &str,
2502) -> Result<usize> {
2503    if old_tree_oid == new_tree_oid {
2504        return Ok(0);
2505    }
2506    let old_entries = match old_tree_oid {
2507        Some(oid) => read_tree(odb, oid)?,
2508        None => Vec::new(),
2509    };
2510    let new_entries = match new_tree_oid {
2511        Some(oid) => read_tree(odb, oid)?,
2512        None => Vec::new(),
2513    };
2514    let mut count = 0usize;
2515    diff_tree_entries_count(odb, &old_entries, &new_entries, prefix, false, &mut count)?;
2516    Ok(count)
2517}
2518
2519/// Diff two trees and pair deleted/added paths as renames when content is similar enough.
2520///
2521/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
2522/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
2523///
2524/// # Errors
2525///
2526/// Propagates errors from [`diff_trees`].
2527pub fn diff_trees_with_renames(
2528    odb: &Odb,
2529    old_tree_oid: Option<&ObjectId>,
2530    new_tree_oid: Option<&ObjectId>,
2531    prefix: &str,
2532    rename_threshold: u32,
2533) -> Result<Vec<DiffEntry>> {
2534    let config = ConfigSet::new();
2535    let policy = ThreadPolicy::default();
2536    diff_trees_with_renames_and_policy(
2537        odb,
2538        old_tree_oid,
2539        new_tree_oid,
2540        prefix,
2541        rename_threshold,
2542        &config,
2543        &policy,
2544    )
2545}
2546
2547/// Like [`diff_trees_with_renames`], but honors `index.threads` / embedder thread policy.
2548///
2549/// # Errors
2550///
2551/// Propagates errors from [`diff_trees`].
2552pub fn diff_trees_with_renames_and_policy(
2553    odb: &Odb,
2554    old_tree_oid: Option<&ObjectId>,
2555    new_tree_oid: Option<&ObjectId>,
2556    prefix: &str,
2557    rename_threshold: u32,
2558    config: &ConfigSet,
2559    thread_policy: &ThreadPolicy,
2560) -> Result<Vec<DiffEntry>> {
2561    let mut entries = diff_trees(odb, old_tree_oid, new_tree_oid, prefix)?;
2562    if rename_threshold <= 100 {
2563        entries = detect_renames_with_policy(
2564            odb,
2565            None,
2566            entries,
2567            rename_threshold,
2568            config,
2569            thread_policy,
2570            None,
2571        )?;
2572    }
2573    Ok(entries)
2574}
2575
2576/// Return the tree OID stored in a commit object.
2577///
2578/// # Errors
2579///
2580/// Returns object read/parse errors when `commit_oid` is missing or not a commit.
2581pub fn tree_oid_of_commit(odb: &Odb, commit_oid: &ObjectId) -> Result<ObjectId> {
2582    let obj = odb.read(commit_oid)?;
2583    if obj.kind != ObjectKind::Commit {
2584        return Err(Error::InvalidRef(format!(
2585            "invalid ref: {commit_oid} is not a commit"
2586        )));
2587    }
2588    let commit = parse_commit(&obj.data)?;
2589    Ok(commit.tree)
2590}
2591
2592/// Like [`tree_oid_of_commit`], but uses the repository commit-graph cache when available.
2593///
2594/// # Errors
2595///
2596/// Returns object read/parse errors when `commit_oid` is missing or not a commit.
2597pub fn tree_oid_of_commit_in_repo(
2598    repo: &crate::repo::Repository,
2599    commit_oid: &ObjectId,
2600) -> Result<ObjectId> {
2601    if let Some(chain) = repo.caches().commit_graph_chain(repo.odb().objects_dir()) {
2602        if let Some(tree) = chain.graph_commit_tree(commit_oid) {
2603            return Ok(tree);
2604        }
2605    }
2606    tree_oid_of_commit(repo.odb(), commit_oid)
2607}
2608
2609/// Diff the trees recorded at two commits (`base` is the old side, `tip` the new side).
2610///
2611/// # Errors
2612///
2613/// Propagates object read errors and tree diff failures.
2614pub fn diff_commits(odb: &Odb, base: &ObjectId, tip: &ObjectId) -> Result<Vec<DiffEntry>> {
2615    let base_tree = tree_oid_of_commit(odb, base)?;
2616    let tip_tree = tree_oid_of_commit(odb, tip)?;
2617    diff_trees(odb, Some(&base_tree), Some(&tip_tree), "")
2618}
2619
2620/// Diff the trees at two commits and pair deleted/added paths as renames when similar enough.
2621///
2622/// `rename_threshold` is Git's `-M` percentage (`0`–`100`, inclusive). Use
2623/// [`RENAME_DETECTION_DISABLED`](crate::diff::RENAME_DETECTION_DISABLED) to skip rename detection.
2624///
2625/// # Errors
2626///
2627/// Propagates object read errors and tree diff failures.
2628pub fn diff_commits_with_renames(
2629    odb: &Odb,
2630    base: &ObjectId,
2631    tip: &ObjectId,
2632    rename_threshold: u32,
2633) -> Result<Vec<DiffEntry>> {
2634    let config = ConfigSet::new();
2635    let policy = ThreadPolicy::default();
2636    diff_commits_with_renames_and_policy(odb, base, tip, rename_threshold, &config, &policy)
2637}
2638
2639/// Like [`diff_commits_with_renames`], but honors `index.threads` / embedder thread policy.
2640///
2641/// # Errors
2642///
2643/// Propagates object read errors and tree diff failures.
2644pub fn diff_commits_with_renames_and_policy(
2645    odb: &Odb,
2646    base: &ObjectId,
2647    tip: &ObjectId,
2648    rename_threshold: u32,
2649    config: &ConfigSet,
2650    thread_policy: &ThreadPolicy,
2651) -> Result<Vec<DiffEntry>> {
2652    let base_tree = tree_oid_of_commit(odb, base)?;
2653    let tip_tree = tree_oid_of_commit(odb, tip)?;
2654    diff_trees_with_renames_and_policy(
2655        odb,
2656        Some(&base_tree),
2657        Some(&tip_tree),
2658        "",
2659        rename_threshold,
2660        config,
2661        thread_policy,
2662    )
2663}
2664
2665/// Like [`diff_trees`], but emit entries for tree objects themselves in addition
2666/// to their recursive contents (Git's `diff-tree -t` behavior).
2667///
2668/// # Errors
2669///
2670/// Returns errors from object database reads.
2671pub fn diff_trees_show_tree_entries(
2672    odb: &Odb,
2673    old_tree_oid: Option<&ObjectId>,
2674    new_tree_oid: Option<&ObjectId>,
2675    prefix: &str,
2676) -> Result<Vec<DiffEntry>> {
2677    diff_trees_opts(odb, old_tree_oid, new_tree_oid, prefix, true, None)
2678}
2679
2680struct TreeReadCache<'a> {
2681    odb: &'a Odb,
2682    parsed: std::collections::HashMap<ObjectId, Arc<Vec<TreeEntry>>>,
2683}
2684
2685impl<'a> TreeReadCache<'a> {
2686    fn new(odb: &'a Odb) -> Self {
2687        Self {
2688            odb,
2689            parsed: std::collections::HashMap::new(),
2690        }
2691    }
2692
2693    fn entries_arc(&mut self, oid: &ObjectId) -> Result<Arc<Vec<TreeEntry>>> {
2694        if let Some(e) = self.parsed.get(oid) {
2695            return Ok(Arc::clone(e));
2696        }
2697        let arc = self.odb.cached_tree_entries(oid)?;
2698        self.parsed.insert(*oid, Arc::clone(&arc));
2699        Ok(arc)
2700    }
2701}
2702
2703fn diff_subtrees_in_place(
2704    cache: &mut TreeReadCache<'_>,
2705    old_tree_oid: Option<&ObjectId>,
2706    new_tree_oid: Option<&ObjectId>,
2707    prefix: &str,
2708    show_trees: bool,
2709    index_hint: Option<&DiffTreesIndexHint<'_>>,
2710    result: &mut Vec<DiffEntry>,
2711) -> Result<()> {
2712    if !show_trees {
2713        if let (Some(a), Some(b)) = (old_tree_oid, new_tree_oid) {
2714            if a == b {
2715                return Ok(());
2716            }
2717        }
2718    }
2719    let empty: Arc<Vec<TreeEntry>> = Arc::new(Vec::new());
2720    let old_entries = match old_tree_oid {
2721        Some(oid) => cache.entries_arc(oid)?,
2722        None => Arc::clone(&empty),
2723    };
2724    let new_entries = match new_tree_oid {
2725        Some(oid) => cache.entries_arc(oid)?,
2726        None => Arc::clone(&empty),
2727    };
2728    diff_tree_entries_opts(
2729        cache,
2730        old_entries.as_slice(),
2731        new_entries.as_slice(),
2732        prefix,
2733        show_trees,
2734        index_hint,
2735        result,
2736    )
2737}
2738
2739fn diff_trees_opts(
2740    odb: &Odb,
2741    old_tree_oid: Option<&ObjectId>,
2742    new_tree_oid: Option<&ObjectId>,
2743    prefix: &str,
2744    show_trees: bool,
2745    index_hint: Option<&DiffTreesIndexHint<'_>>,
2746) -> Result<Vec<DiffEntry>> {
2747    let mut result = Vec::new();
2748    let mut cache = TreeReadCache::new(odb);
2749    diff_subtrees_in_place(
2750        &mut cache,
2751        old_tree_oid,
2752        new_tree_oid,
2753        prefix,
2754        show_trees,
2755        index_hint,
2756        &mut result,
2757    )?;
2758    Ok(result)
2759}
2760
2761#[cfg(test)]
2762use std::cell::RefCell;
2763#[cfg(test)]
2764use std::rc::Rc;
2765#[cfg(test)]
2766use std::sync::atomic::{AtomicUsize, Ordering};
2767
2768#[cfg(test)]
2769thread_local! {
2770    static DIFF_TREE_READ_HOOK: RefCell<Option<Rc<AtomicUsize>>> = const { RefCell::new(None) };
2771}
2772
2773/// Install a per-thread tree read counter for unit tests (cleared when `hook` is `None`).
2774#[cfg(test)]
2775pub(crate) fn set_diff_tree_read_hook_for_tests(hook: Option<Rc<AtomicUsize>>) {
2776    DIFF_TREE_READ_HOOK.with(|cell| *cell.borrow_mut() = hook);
2777}
2778
2779/// Read and parse a tree object from the ODB (no session cache).
2780fn read_tree_from_odb(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
2781    #[cfg(test)]
2782    if let Some(counter) = DIFF_TREE_READ_HOOK.with(|cell| cell.borrow().clone()) {
2783        counter.fetch_add(1, Ordering::Relaxed);
2784    }
2785    Ok((*odb.cached_tree_entries(oid)?).clone())
2786}
2787
2788/// Read and parse a tree object from the ODB.
2789fn read_tree(odb: &Odb, oid: &ObjectId) -> Result<Vec<TreeEntry>> {
2790    read_tree_from_odb(odb, oid)
2791}
2792
2793fn diff_tree_entries_count(
2794    odb: &Odb,
2795    old: &[TreeEntry],
2796    new: &[TreeEntry],
2797    prefix: &str,
2798    show_trees: bool,
2799    count: &mut usize,
2800) -> Result<()> {
2801    let mut oi = 0;
2802    let mut ni = 0;
2803
2804    while oi < old.len() || ni < new.len() {
2805        match (old.get(oi), new.get(ni)) {
2806            (Some(o), Some(n)) => {
2807                let cmp = crate::objects::tree_entry_cmp(
2808                    &o.name,
2809                    is_tree_mode(o.mode),
2810                    &n.name,
2811                    is_tree_mode(n.mode),
2812                );
2813                match cmp {
2814                    std::cmp::Ordering::Less => {
2815                        emit_deleted_count(odb, o, prefix, show_trees, count)?;
2816                        oi += 1;
2817                    }
2818                    std::cmp::Ordering::Greater => {
2819                        emit_added_count(odb, n, prefix, show_trees, count)?;
2820                        ni += 1;
2821                    }
2822                    std::cmp::Ordering::Equal => {
2823                        if o.oid != n.oid || o.mode != n.mode {
2824                            let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
2825                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
2826                                if show_trees {
2827                                    *count += 1;
2828                                }
2829                                *count += diff_tree_path_change_count(
2830                                    odb,
2831                                    Some(&o.oid),
2832                                    Some(&n.oid),
2833                                    &path,
2834                                )?;
2835                            } else if is_tree_mode(o.mode) != is_tree_mode(n.mode) {
2836                                emit_deleted_count(odb, o, prefix, show_trees, count)?;
2837                                emit_added_count(odb, n, prefix, show_trees, count)?;
2838                            } else {
2839                                *count += 1;
2840                            }
2841                        }
2842                        oi += 1;
2843                        ni += 1;
2844                    }
2845                }
2846            }
2847            (Some(o), None) => {
2848                emit_deleted_count(odb, o, prefix, show_trees, count)?;
2849                oi += 1;
2850            }
2851            (None, Some(n)) => {
2852                emit_added_count(odb, n, prefix, show_trees, count)?;
2853                ni += 1;
2854            }
2855            (None, None) => break,
2856        }
2857    }
2858
2859    Ok(())
2860}
2861
2862fn emit_deleted_count(
2863    odb: &Odb,
2864    entry: &TreeEntry,
2865    prefix: &str,
2866    show_trees: bool,
2867    count: &mut usize,
2868) -> Result<()> {
2869    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
2870    if is_tree_mode(entry.mode) {
2871        if show_trees {
2872            *count += 1;
2873        }
2874        *count += diff_tree_path_change_count(odb, Some(&entry.oid), None, &path)?;
2875    } else {
2876        *count += 1;
2877    }
2878    Ok(())
2879}
2880
2881fn emit_added_count(
2882    odb: &Odb,
2883    entry: &TreeEntry,
2884    prefix: &str,
2885    show_trees: bool,
2886    count: &mut usize,
2887) -> Result<()> {
2888    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
2889    if is_tree_mode(entry.mode) {
2890        if show_trees {
2891            *count += 1;
2892        }
2893        *count += diff_tree_path_change_count(odb, None, Some(&entry.oid), &path)?;
2894    } else {
2895        *count += 1;
2896    }
2897    Ok(())
2898}
2899
2900/// Compare two sorted lists of tree entries, recursing into subtrees.
2901fn diff_tree_entries_opts(
2902    cache: &mut TreeReadCache<'_>,
2903    old: &[TreeEntry],
2904    new: &[TreeEntry],
2905    prefix: &str,
2906    show_trees: bool,
2907    index_hint: Option<&DiffTreesIndexHint<'_>>,
2908    result: &mut Vec<DiffEntry>,
2909) -> Result<()> {
2910    let mut oi = 0;
2911    let mut ni = 0;
2912
2913    while oi < old.len() || ni < new.len() {
2914        match (old.get(oi), new.get(ni)) {
2915            (Some(o), Some(n)) => {
2916                let cmp = crate::objects::tree_entry_cmp(
2917                    &o.name,
2918                    is_tree_mode(o.mode),
2919                    &n.name,
2920                    is_tree_mode(n.mode),
2921                );
2922                match cmp {
2923                    std::cmp::Ordering::Less => {
2924                        // Old entry not in new → deleted
2925                        emit_deleted_opts(cache, o, prefix, show_trees, index_hint, result)?;
2926                        oi += 1;
2927                    }
2928                    std::cmp::Ordering::Greater => {
2929                        // New entry not in old → added
2930                        emit_added_opts(cache, n, prefix, show_trees, result)?;
2931                        ni += 1;
2932                    }
2933                    std::cmp::Ordering::Equal => {
2934                        // Both present — check for changes
2935                        if o.oid != n.oid || o.mode != n.mode {
2936                            let path = crate::repo_relpath::format_path_stored(prefix, &o.name);
2937                            if is_tree_mode(o.mode) && is_tree_mode(n.mode) {
2938                                // Both are trees
2939                                if show_trees {
2940                                    result.push(DiffEntry {
2941                                        status: DiffStatus::Modified,
2942                                        old_path: Some(path.clone()),
2943                                        new_path: Some(path.clone()),
2944                                        old_mode: format_mode(o.mode),
2945                                        new_mode: format_mode(n.mode),
2946                                        old_oid: o.oid,
2947                                        new_oid: n.oid,
2948                                        score: None,
2949                                    });
2950                                }
2951                                diff_subtrees_in_place(
2952                                    cache,
2953                                    Some(&o.oid),
2954                                    Some(&n.oid),
2955                                    &path,
2956                                    show_trees,
2957                                    index_hint,
2958                                    result,
2959                                )?;
2960                            } else if is_tree_mode(o.mode) && !is_tree_mode(n.mode) {
2961                                // Tree → blob: delete tree contents, add blob
2962                                emit_deleted_opts(
2963                                    cache, o, prefix, show_trees, index_hint, result,
2964                                )?;
2965                                emit_added_opts(cache, n, prefix, show_trees, result)?;
2966                            } else if !is_tree_mode(o.mode) && is_tree_mode(n.mode) {
2967                                // Blob → tree: delete blob, add tree contents
2968                                emit_deleted_opts(
2969                                    cache, o, prefix, show_trees, index_hint, result,
2970                                )?;
2971                                emit_added_opts(cache, n, prefix, show_trees, result)?;
2972                            } else {
2973                                result.push(DiffEntry {
2974                                    status: DiffStatus::for_content_change(o.mode, n.mode),
2975                                    old_path: Some(path.clone()),
2976                                    new_path: Some(path),
2977                                    old_mode: format_mode(o.mode),
2978                                    new_mode: format_mode(n.mode),
2979                                    old_oid: o.oid,
2980                                    new_oid: n.oid,
2981                                    score: None,
2982                                });
2983                            }
2984                        }
2985                        oi += 1;
2986                        ni += 1;
2987                    }
2988                }
2989            }
2990            (Some(o), None) => {
2991                emit_deleted_opts(cache, o, prefix, show_trees, index_hint, result)?;
2992                oi += 1;
2993            }
2994            (None, Some(n)) => {
2995                emit_added_opts(cache, n, prefix, show_trees, result)?;
2996                ni += 1;
2997            }
2998            (None, None) => break,
2999        }
3000    }
3001
3002    Ok(())
3003}
3004
3005fn emit_deleted_tree_from_index(index: &Index, dir_prefix: &str, result: &mut Vec<DiffEntry>) {
3006    let scan_prefix: Vec<u8> = if dir_prefix.is_empty() {
3007        Vec::new()
3008    } else {
3009        format!("{dir_prefix}/").into_bytes()
3010    };
3011    if index.entries_sorted {
3012        let start = if scan_prefix.is_empty() {
3013            0
3014        } else {
3015            index
3016                .entries
3017                .partition_point(|e| e.path.as_slice() < scan_prefix.as_slice())
3018        };
3019        for ie in index.entries[start..].iter() {
3020            if ie.stage() != 0 {
3021                continue;
3022            }
3023            if !scan_prefix.is_empty() && !ie.path.starts_with(&scan_prefix) {
3024                break;
3025            }
3026            if scan_prefix.is_empty() && ie.path.contains(&b'/') {
3027                continue;
3028            }
3029            let path = String::from_utf8_lossy(&ie.path).into_owned();
3030            result.push(DiffEntry {
3031                status: DiffStatus::Deleted,
3032                old_path: Some(path),
3033                new_path: None,
3034                old_mode: format_mode(ie.mode),
3035                new_mode: "000000".to_owned(),
3036                old_oid: ie.oid,
3037                new_oid: zero_oid(),
3038                score: None,
3039            });
3040        }
3041        return;
3042    }
3043    for ie in &index.entries {
3044        if ie.stage() != 0 {
3045            continue;
3046        }
3047        let Ok(path) = std::str::from_utf8(&ie.path) else {
3048            continue;
3049        };
3050        let under = if dir_prefix.is_empty() {
3051            !path.contains('/')
3052        } else if path == dir_prefix {
3053            false
3054        } else {
3055            path.strip_prefix(dir_prefix)
3056                .is_some_and(|rest| rest.starts_with('/'))
3057        };
3058        if !under {
3059            continue;
3060        }
3061        result.push(DiffEntry {
3062            status: DiffStatus::Deleted,
3063            old_path: Some(path.to_owned()),
3064            new_path: None,
3065            old_mode: format_mode(ie.mode),
3066            new_mode: "000000".to_owned(),
3067            old_oid: ie.oid,
3068            new_oid: zero_oid(),
3069            score: None,
3070        });
3071    }
3072}
3073
3074fn emit_deleted_opts(
3075    cache: &mut TreeReadCache<'_>,
3076    entry: &TreeEntry,
3077    prefix: &str,
3078    show_trees: bool,
3079    index_hint: Option<&DiffTreesIndexHint<'_>>,
3080    result: &mut Vec<DiffEntry>,
3081) -> Result<()> {
3082    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3083    if is_tree_mode(entry.mode) {
3084        if show_trees {
3085            result.push(DiffEntry {
3086                status: DiffStatus::Deleted,
3087                old_path: Some(path.clone()),
3088                new_path: None,
3089                old_mode: format_mode(entry.mode),
3090                new_mode: "000000".to_owned(),
3091                old_oid: entry.oid,
3092                new_oid: zero_oid(),
3093                score: None,
3094            });
3095        }
3096        if let Some(hint) = index_hint {
3097            emit_deleted_tree_from_index(hint.index, &path, result);
3098            return Ok(());
3099        }
3100        diff_subtrees_in_place(
3101            cache,
3102            Some(&entry.oid),
3103            None,
3104            &path,
3105            show_trees,
3106            None,
3107            result,
3108        )?;
3109    } else {
3110        result.push(DiffEntry {
3111            status: DiffStatus::Deleted,
3112            old_path: Some(path.clone()),
3113            new_path: None,
3114            old_mode: format_mode(entry.mode),
3115            new_mode: "000000".to_owned(),
3116            old_oid: entry.oid,
3117            new_oid: zero_oid(),
3118            score: None,
3119        });
3120    }
3121    Ok(())
3122}
3123
3124fn emit_added_opts(
3125    cache: &mut TreeReadCache<'_>,
3126    entry: &TreeEntry,
3127    prefix: &str,
3128    show_trees: bool,
3129    result: &mut Vec<DiffEntry>,
3130) -> Result<()> {
3131    let path = crate::repo_relpath::format_path_stored(prefix, &entry.name);
3132    if is_tree_mode(entry.mode) {
3133        if show_trees {
3134            result.push(DiffEntry {
3135                status: DiffStatus::Added,
3136                old_path: None,
3137                new_path: Some(path.clone()),
3138                old_mode: "000000".to_owned(),
3139                new_mode: format_mode(entry.mode),
3140                old_oid: zero_oid(),
3141                new_oid: entry.oid,
3142                score: None,
3143            });
3144        }
3145        diff_subtrees_in_place(
3146            cache,
3147            None,
3148            Some(&entry.oid),
3149            &path,
3150            show_trees,
3151            None,
3152            result,
3153        )?;
3154    } else {
3155        result.push(DiffEntry {
3156            status: DiffStatus::Added,
3157            old_path: None,
3158            new_path: Some(path),
3159            old_mode: "000000".to_owned(),
3160            new_mode: format_mode(entry.mode),
3161            old_oid: zero_oid(),
3162            new_oid: entry.oid,
3163            score: None,
3164        });
3165    }
3166    Ok(())
3167}
3168
3169// ── Index-to-tree diff (staged changes) ─────────────────────────────
3170
3171/// Compare the index against a tree (usually HEAD's tree).
3172///
3173/// This shows "staged" changes — what would be committed.
3174///
3175/// # Parameters
3176///
3177/// - `odb` — object database.
3178/// - `index` — the current index.
3179/// - `tree_oid` — the tree to compare against (e.g. HEAD's tree), or `None`
3180///   for comparison against an empty tree (initial commit).
3181///
3182/// # Errors
3183///
3184/// Returns errors from ODB reads.
3185///
3186/// When `ignore_submodules` is true, gitlink (`160000`) paths are omitted from the diff, matching
3187/// Git's `require_clean_work_tree(..., ignore_submodules=1)` used by `git rebase` / `git pull`.
3188pub fn diff_index_to_tree(
3189    odb: &Odb,
3190    index: &Index,
3191    tree_oid: Option<&ObjectId>,
3192    ignore_submodules: bool,
3193) -> Result<Vec<DiffEntry>> {
3194    match index_cache_tree_walk(index, tree_oid) {
3195        CacheTreeWalk::Fast {
3196            head_tree,
3197            cache_root,
3198        } => {
3199            diff_index_to_tree_with_cache_tree(odb, index, head_tree, cache_root, ignore_submodules)
3200        }
3201        CacheTreeWalk::Flatten => {
3202            diff_index_to_tree_flatten(odb, index, tree_oid, ignore_submodules)
3203        }
3204    }
3205}
3206
3207/// Diff the index against a tree and pair deleted/added paths as renames when similar enough.
3208///
3209/// `rename_threshold` is Git's `-M` percentage (50 is the usual default); pass `100` to skip
3210/// rename detection.
3211///
3212/// # Errors
3213///
3214/// Propagates errors from [`diff_index_to_tree`].
3215pub fn diff_index_to_tree_with_renames(
3216    odb: &Odb,
3217    index: &Index,
3218    tree_oid: Option<&ObjectId>,
3219    ignore_submodules: bool,
3220    rename_threshold: u32,
3221    config: &ConfigSet,
3222    thread_policy: &ThreadPolicy,
3223) -> Result<Vec<DiffEntry>> {
3224    let entries = diff_index_to_tree(odb, index, tree_oid, ignore_submodules)?;
3225    apply_rename_detection_to_diff_entries(
3226        odb,
3227        entries,
3228        None,
3229        rename_threshold,
3230        config,
3231        thread_policy,
3232    )
3233}
3234
3235/// Apply Git `-M` rename detection to a flat diff (index↔tree, tree↔tree, or index↔worktree).
3236///
3237/// Pass `work_root` when added sides may be read from the working tree (index↔worktree diffs).
3238/// Use `rename_threshold` `100` to leave entries unchanged.
3239///
3240/// # Errors
3241///
3242/// Propagates object read failures while scoring renames.
3243pub fn apply_rename_detection_to_diff_entries(
3244    odb: &Odb,
3245    entries: Vec<DiffEntry>,
3246    work_root: Option<&Path>,
3247    rename_threshold: u32,
3248    config: &ConfigSet,
3249    thread_policy: &ThreadPolicy,
3250) -> Result<Vec<DiffEntry>> {
3251    if entries.is_empty() || rename_threshold >= 100 {
3252        return Ok(entries);
3253    }
3254    if work_root.is_some() {
3255        let opts = DiffPostprocessOptions {
3256            rename_threshold,
3257            ..DiffPostprocessOptions::default()
3258        };
3259        postprocess_diff_entries(
3260            odb,
3261            entries,
3262            None,
3263            work_root,
3264            None,
3265            config,
3266            thread_policy,
3267            &opts,
3268        )
3269    } else {
3270        detect_renames_with_policy(
3271            odb,
3272            None,
3273            entries,
3274            rename_threshold,
3275            config,
3276            thread_policy,
3277            None,
3278        )
3279    }
3280}
3281
3282/// Add [`DiffStatus::Added`] entries for untracked worktree files so rename detection can pair
3283/// plain filesystem moves (index still lists the old path, new path only on disk).
3284///
3285/// The returned path set lists every synthetic added candidate. After rename detection, drop
3286/// entries whose `new_path` is still in that set and still [`DiffStatus::Added`] — they were
3287/// not consumed by a rename pair and must not appear in the diff (Git does not report unrelated
3288/// untracked paths in `git diff`).
3289///
3290/// # Errors
3291///
3292/// Propagates I/O and ignore-rule load failures while scanning the work tree.
3293pub fn append_untracked_worktree_added_for_rename_detection(
3294    repo: &Repository,
3295    index: &Index,
3296    rules: &WorktreeRules,
3297    mut entries: Vec<DiffEntry>,
3298) -> Result<(Vec<DiffEntry>, HashSet<String>)> {
3299    let mut synthetic_new_paths = HashSet::new();
3300    if !entries.iter().any(|e| e.status == DiffStatus::Deleted) {
3301        return Ok((entries, synthetic_new_paths));
3302    }
3303    let mut existing_new: HashSet<String> =
3304        entries.iter().filter_map(|e| e.new_path.clone()).collect();
3305    let untracked = list_untracked_worktree_files_for_rename(repo, index, rules)?;
3306    for path in untracked {
3307        if existing_new.contains(&path) {
3308            continue;
3309        }
3310        existing_new.insert(path.clone());
3311        synthetic_new_paths.insert(path.clone());
3312        entries.push(DiffEntry {
3313            status: DiffStatus::Added,
3314            old_path: None,
3315            new_path: Some(path),
3316            old_mode: "000000".to_owned(),
3317            new_mode: "100644".to_owned(),
3318            old_oid: zero_oid(),
3319            new_oid: zero_oid(),
3320            score: None,
3321        });
3322    }
3323    Ok((entries, synthetic_new_paths))
3324}
3325
3326/// Drop synthetic untracked rename candidates that rename detection did not pair.
3327#[must_use]
3328pub fn drop_unused_synthetic_rename_candidates(
3329    entries: Vec<DiffEntry>,
3330    synthetic_new_paths: &HashSet<String>,
3331) -> Vec<DiffEntry> {
3332    if synthetic_new_paths.is_empty() {
3333        return entries;
3334    }
3335    entries
3336        .into_iter()
3337        .filter(|e| {
3338            !(e.status == DiffStatus::Added
3339                && e.new_path
3340                    .as_ref()
3341                    .is_some_and(|p| synthetic_new_paths.contains(p)))
3342        })
3343        .collect()
3344}
3345
3346fn list_untracked_worktree_files_for_rename(
3347    repo: &Repository,
3348    index: &Index,
3349    rules: &WorktreeRules,
3350) -> Result<Vec<String>> {
3351    let work_tree = repo.work_tree().ok_or(Error::RequiresWorkTree)?;
3352    let mut out = Vec::new();
3353    walk_untracked_worktree_files(repo, index, work_tree, "", rules, &mut out)?;
3354    out.sort();
3355    Ok(out)
3356}
3357
3358fn walk_untracked_worktree_files(
3359    repo: &Repository,
3360    index: &Index,
3361    dir: &Path,
3362    rel_prefix: &str,
3363    rules: &WorktreeRules,
3364    out: &mut Vec<String>,
3365) -> Result<()> {
3366    let read = match fs::read_dir(dir) {
3367        Ok(r) => r,
3368        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
3369        Err(e) => return Err(Error::Io(e)),
3370    };
3371    for entry in read {
3372        let entry = entry.map_err(Error::Io)?;
3373        let name = entry.file_name();
3374        let name = name.to_string_lossy();
3375        if rel_prefix.is_empty() && name == ".git" {
3376            continue;
3377        }
3378        let rel_path = if rel_prefix.is_empty() {
3379            name.to_string()
3380        } else {
3381            format!("{rel_prefix}/{name}")
3382        };
3383        let file_type = entry.file_type().map_err(Error::Io)?;
3384        if file_type.is_dir() {
3385            let (ignored, _) = rules
3386                .ignore_mut()
3387                .check_path(repo, Some(index), &rel_path, true)?;
3388            if ignored {
3389                continue;
3390            }
3391            walk_untracked_worktree_files(
3392                repo,
3393                index,
3394                &dir.join(name.as_ref()),
3395                &rel_path,
3396                rules,
3397                out,
3398            )?;
3399        } else if file_type.is_file() || file_type.is_symlink() {
3400            if index.get(rel_path.as_bytes(), 0).is_some() {
3401                continue;
3402            }
3403            let (ignored, _) =
3404                rules
3405                    .ignore_mut()
3406                    .check_path(repo, Some(index), &rel_path, false)?;
3407            if !ignored {
3408                out.push(rel_path);
3409            }
3410        }
3411    }
3412    Ok(())
3413}
3414
3415/// Diff the index against the working tree and pair deleted/added paths as renames when similar.
3416///
3417/// Runs [`diff_index_to_worktree_with_options`], appends untracked worktree paths as added
3418/// candidates when needed, then [`apply_rename_detection_to_diff_entries`] with `work_root`
3419/// set to `work_tree`.
3420///
3421/// # Errors
3422///
3423/// Propagates errors from the index↔worktree diff or rename post-processing.
3424pub fn diff_index_to_worktree_with_renames(
3425    repo: &Repository,
3426    index: &mut Index,
3427    work_tree: &Path,
3428    options: DiffIndexToWorktreeOptions,
3429    rename_threshold: u32,
3430) -> Result<(Vec<DiffEntry>, bool)> {
3431    let config = repo.config()?.as_ref().clone();
3432    let thread_policy = repo.thread_policy();
3433    let odb = repo.odb();
3434    let rules_for_append = options.worktree_rules.clone();
3435    let (mut entries, changed) =
3436        diff_index_to_worktree_with_options(odb, index, work_tree, options)?;
3437    let mut synthetic_new_paths = HashSet::new();
3438    if rename_threshold < 100 {
3439        if let Some(rules_arc) = rules_for_append {
3440            let rules = rules_arc
3441                .lock()
3442                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))?;
3443            (entries, synthetic_new_paths) =
3444                append_untracked_worktree_added_for_rename_detection(repo, index, &rules, entries)?;
3445        } else {
3446            let rules = WorktreeRules::from_repository(repo, index)?;
3447            (entries, synthetic_new_paths) =
3448                append_untracked_worktree_added_for_rename_detection(repo, index, &rules, entries)?;
3449        }
3450    }
3451    let entries = apply_rename_detection_to_diff_entries(
3452        odb,
3453        entries,
3454        Some(work_tree),
3455        rename_threshold,
3456        &config,
3457        &thread_policy,
3458    )?;
3459    let entries = drop_unused_synthetic_rename_candidates(entries, &synthetic_new_paths);
3460    Ok((entries, changed))
3461}
3462
3463enum CacheTreeWalk<'a> {
3464    Fast {
3465        head_tree: &'a ObjectId,
3466        cache_root: &'a CacheTreeNode,
3467    },
3468    Flatten,
3469}
3470
3471/// Whether the index may use the cache-tree walker (possibly with an invalid root and valid
3472/// descendant nodes after partial invalidation).
3473fn index_cache_tree_walk<'a>(
3474    index: &'a Index,
3475    tree_oid: Option<&'a ObjectId>,
3476) -> CacheTreeWalk<'a> {
3477    let Some(head_tree) = tree_oid else {
3478        return CacheTreeWalk::Flatten;
3479    };
3480    let Some(cache_root) = index.cache_tree.as_ref() else {
3481        return CacheTreeWalk::Flatten;
3482    };
3483    if index.entries.iter().any(|e| e.stage() != 0) {
3484        return CacheTreeWalk::Flatten;
3485    }
3486    CacheTreeWalk::Fast {
3487        head_tree,
3488        cache_root,
3489    }
3490}
3491
3492/// Flatten-tree index-vs-tree diff (used when the index has no cache-tree extension).
3493fn diff_index_to_tree_flatten(
3494    odb: &Odb,
3495    index: &Index,
3496    tree_oid: Option<&ObjectId>,
3497    ignore_submodules: bool,
3498) -> Result<Vec<DiffEntry>> {
3499    // Flatten the tree into a sorted list of (path, mode, oid)
3500    let tree_entries = match tree_oid {
3501        Some(oid) => flatten_tree(odb, oid, "")?,
3502        None => Vec::new(),
3503    };
3504
3505    // Build maps keyed by path
3506    let mut tree_map: std::collections::BTreeMap<&str, &FlatEntry> =
3507        std::collections::BTreeMap::new();
3508    for entry in &tree_entries {
3509        tree_map.insert(&entry.path, entry);
3510    }
3511
3512    let mut result = Vec::new();
3513    let mut stage0_paths = std::collections::BTreeSet::new();
3514    let mut unmerged_modes: std::collections::BTreeMap<String, (u8, u32)> =
3515        std::collections::BTreeMap::new();
3516
3517    // Check index entries against tree
3518    for ie in &index.entries {
3519        let path = String::from_utf8_lossy(&ie.path).to_string();
3520        if ie.stage() == 0 && ie.intent_to_add() {
3521            // Intent-to-add entries are not "staged" for diff-index / status
3522            // (matches Git: `git diff --cached` is empty for `-N` paths).
3523            continue;
3524        }
3525        if ie.stage() != 0 {
3526            let rank = match ie.stage() {
3527                2 => 0u8,
3528                3 => 1u8,
3529                1 => 2u8,
3530                _ => 3u8,
3531            };
3532            match unmerged_modes.get(&path) {
3533                Some((existing_rank, _)) if *existing_rank <= rank => {}
3534                _ => {
3535                    unmerged_modes.insert(path, (rank, ie.mode));
3536                }
3537            }
3538            continue;
3539        }
3540        if ignore_submodules && ie.mode == 0o160000 {
3541            let _ = tree_map.remove(path.as_str());
3542            stage0_paths.insert(path.clone());
3543            continue;
3544        }
3545        stage0_paths.insert(path.clone());
3546        match tree_map.remove(path.as_str()) {
3547            Some(te) => {
3548                // Present in both — check for differences
3549                if te.oid != ie.oid || te.mode != ie.mode {
3550                    result.push(DiffEntry {
3551                        status: DiffStatus::for_content_change(te.mode, ie.mode),
3552                        old_path: Some(path.clone()),
3553                        new_path: Some(path),
3554                        old_mode: format_mode(te.mode),
3555                        new_mode: format_mode(ie.mode),
3556                        old_oid: te.oid,
3557                        new_oid: ie.oid,
3558                        score: None,
3559                    });
3560                }
3561            }
3562            None => {
3563                // In index but not tree → added
3564                result.push(DiffEntry {
3565                    status: DiffStatus::Added,
3566                    old_path: None,
3567                    new_path: Some(path),
3568                    old_mode: "000000".to_owned(),
3569                    new_mode: format_mode(ie.mode),
3570                    old_oid: zero_oid(),
3571                    new_oid: ie.oid,
3572                    score: None,
3573                });
3574            }
3575        }
3576    }
3577
3578    diff_index_to_tree_finish_unmerged_and_deleted(
3579        ignore_submodules,
3580        &mut tree_map,
3581        &unmerged_modes,
3582        &stage0_paths,
3583        &mut result,
3584    );
3585
3586    result.sort_by(|a, b| a.path().cmp(b.path()));
3587    Ok(result)
3588}
3589
3590fn diff_index_to_tree_finish_unmerged_and_deleted(
3591    ignore_submodules: bool,
3592    tree_map: &mut std::collections::BTreeMap<&str, &FlatEntry>,
3593    unmerged_modes: &std::collections::BTreeMap<String, (u8, u32)>,
3594    stage0_paths: &std::collections::BTreeSet<String>,
3595    result: &mut Vec<DiffEntry>,
3596) {
3597    for (path, (_, mode)) in unmerged_modes {
3598        if stage0_paths.contains(path) {
3599            continue;
3600        }
3601        tree_map.remove(path.as_str());
3602        result.push(DiffEntry {
3603            status: DiffStatus::Unmerged,
3604            old_path: Some(path.clone()),
3605            new_path: Some(path.clone()),
3606            old_mode: "000000".to_owned(),
3607            new_mode: format_mode(*mode),
3608            old_oid: zero_oid(),
3609            new_oid: zero_oid(),
3610            score: None,
3611        });
3612    }
3613
3614    // Remaining tree entries not in index → deleted
3615    for (path, te) in std::mem::take(tree_map) {
3616        if ignore_submodules && te.mode == 0o160000 {
3617            continue;
3618        }
3619        result.push(DiffEntry {
3620            status: DiffStatus::Deleted,
3621            old_path: Some(path.to_owned()),
3622            new_path: None,
3623            old_mode: format_mode(te.mode),
3624            new_mode: "000000".to_owned(),
3625            old_oid: te.oid,
3626            new_oid: zero_oid(),
3627            score: None,
3628        });
3629    }
3630}
3631
3632/// Cache-tree-aware index-vs-tree diff: walk HEAD tree and index together and skip
3633/// subtrees whose valid cache-tree node OID matches the tree entry (Git unpack-trees).
3634fn diff_index_to_tree_with_cache_tree(
3635    odb: &Odb,
3636    index: &Index,
3637    head_tree: &ObjectId,
3638    cache_root: &CacheTreeNode,
3639    ignore_submodules: bool,
3640) -> Result<Vec<DiffEntry>> {
3641    let mut result = Vec::new();
3642    let mut idx_cursor = 0usize;
3643
3644    let mut walk = DiffIndexTreeWalk {
3645        odb,
3646        index,
3647        idx_cursor: &mut idx_cursor,
3648        ignore_submodules,
3649        result: &mut result,
3650        abort_to_flatten: false,
3651    };
3652    diff_index_tree_at(&mut walk, head_tree, Some(cache_root), b"")?;
3653    if walk.abort_to_flatten {
3654        return diff_index_to_tree_flatten(odb, index, Some(head_tree), ignore_submodules);
3655    }
3656
3657    // Index entries not consumed by the tree walk (added paths).
3658    while idx_cursor < index.entries.len() {
3659        let ie = &index.entries[idx_cursor];
3660        idx_cursor += 1;
3661        let path = String::from_utf8_lossy(&ie.path).to_string();
3662        if ie.stage() == 0 && ie.intent_to_add() {
3663            continue;
3664        }
3665        if ie.stage() != 0 {
3666            continue;
3667        }
3668        if ignore_submodules && ie.mode == 0o160000 {
3669            continue;
3670        }
3671        result.push(DiffEntry {
3672            status: DiffStatus::Added,
3673            old_path: None,
3674            new_path: Some(path),
3675            old_mode: "000000".to_owned(),
3676            new_mode: format_mode(ie.mode),
3677            old_oid: zero_oid(),
3678            new_oid: ie.oid,
3679            score: None,
3680        });
3681    }
3682
3683    result.sort_by(|a, b| a.path().cmp(b.path()));
3684    Ok(result)
3685}
3686
3687enum IndexLevelKind<'a> {
3688    Blob {
3689        entry: &'a IndexEntry,
3690        name: &'a [u8],
3691    },
3692    Tree {
3693        name: &'a [u8],
3694    },
3695}
3696
3697/// `path` relative to the directory `dir_prefix` (raw bytes, no trailing slash), or `None` when
3698/// `path` lies outside it. The directory's own path maps to an empty slice.
3699fn index_relative_in_dir<'a>(path: &'a [u8], dir_prefix: &[u8]) -> Option<&'a [u8]> {
3700    if dir_prefix.is_empty() {
3701        return Some(path);
3702    }
3703    let rest = path.strip_prefix(dir_prefix)?;
3704    match rest.split_first() {
3705        None => Some(rest),
3706        Some((b'/', tail)) => Some(tail),
3707        Some(_) => None,
3708    }
3709}
3710
3711fn join_index_path(dir_prefix: &[u8], name: &[u8]) -> Vec<u8> {
3712    if dir_prefix.is_empty() {
3713        return name.to_vec();
3714    }
3715    let mut path = Vec::with_capacity(dir_prefix.len() + 1 + name.len());
3716    path.extend_from_slice(dir_prefix);
3717    path.push(b'/');
3718    path.extend_from_slice(name);
3719    path
3720}
3721
3722fn peek_index_level_entry<'a>(
3723    index: &'a Index,
3724    cursor: &mut usize,
3725    dir_prefix: &[u8],
3726    ignore_submodules: bool,
3727) -> Option<IndexLevelKind<'a>> {
3728    while let Some(ie) = index.entries.get(*cursor) {
3729        let rel = index_relative_in_dir(&ie.path, dir_prefix)?;
3730        if ie.stage() != 0 || ie.intent_to_add() || rel.is_empty() {
3731            *cursor += 1;
3732            continue;
3733        }
3734        if let Some(slash) = rel.iter().position(|&b| b == b'/') {
3735            return Some(IndexLevelKind::Tree {
3736                name: &rel[..slash],
3737            });
3738        }
3739        if ignore_submodules && ie.mode == 0o160000 {
3740            *cursor += 1;
3741            continue;
3742        }
3743        return Some(IndexLevelKind::Blob {
3744            entry: ie,
3745            name: rel,
3746        });
3747    }
3748    None
3749}
3750
3751fn skip_index_subtree(index: &Index, cursor: &mut usize, dir_prefix: &[u8]) {
3752    while index
3753        .entries
3754        .get(*cursor)
3755        .is_some_and(|ie| index_relative_in_dir(&ie.path, dir_prefix).is_some())
3756    {
3757        *cursor += 1;
3758    }
3759}
3760
3761/// Children of a cache-tree node up to which a linear name scan beats sorting.
3762const CACHE_CHILD_LINEAR_SCAN_MAX: usize = 16;
3763
3764/// Name lookup over one cache-tree node's subtrees.
3765///
3766/// The index extension does not promise any child order, and a wide directory is looked up
3767/// once per tree entry, so nodes with many children are sorted once per visit instead of
3768/// being scanned for every entry. The first child with a matching name wins either way.
3769struct CacheChildren<'a> {
3770    children: &'a [CacheTreeNode],
3771    sorted: Vec<&'a CacheTreeNode>,
3772}
3773
3774impl<'a> CacheChildren<'a> {
3775    fn new(node: Option<&'a CacheTreeNode>) -> Self {
3776        let children = node.map_or(&[][..], |n| n.children.as_slice());
3777        let sorted = if children.len() > CACHE_CHILD_LINEAR_SCAN_MAX {
3778            let mut sorted: Vec<&CacheTreeNode> = children.iter().collect();
3779            sorted.sort_by(|a, b| a.name.cmp(&b.name));
3780            sorted
3781        } else {
3782            Vec::new()
3783        };
3784        Self { children, sorted }
3785    }
3786
3787    fn get(&self, name: &[u8]) -> Option<&'a CacheTreeNode> {
3788        if self.sorted.is_empty() {
3789            return self.children.iter().find(|c| c.name == name);
3790        }
3791        let pos = self.sorted.partition_point(|c| c.name.as_slice() < name);
3792        self.sorted.get(pos).copied().filter(|c| c.name == name)
3793    }
3794}
3795
3796struct DiffIndexTreeWalk<'a, 'b> {
3797    odb: &'a Odb,
3798    index: &'a Index,
3799    idx_cursor: &'b mut usize,
3800    ignore_submodules: bool,
3801    result: &'b mut Vec<DiffEntry>,
3802    /// Set when a tree object has duplicate entry names; the caller falls back to flatten.
3803    abort_to_flatten: bool,
3804}
3805
3806fn tree_entries_have_duplicate_names(entries: &[TreeEntry]) -> bool {
3807    let mut seen = std::collections::BTreeSet::<&[u8]>::new();
3808    for entry in entries {
3809        if !seen.insert(entry.name.as_slice()) {
3810            return true;
3811        }
3812    }
3813    false
3814}
3815
3816/// Walk the tree `tree_oid` against the index entries under `dir_prefix` (raw path bytes).
3817fn diff_index_tree_at(
3818    walk: &mut DiffIndexTreeWalk<'_, '_>,
3819    tree_oid: &ObjectId,
3820    cache_node: Option<&CacheTreeNode>,
3821    dir_prefix: &[u8],
3822) -> Result<()> {
3823    let tree_entries = read_tree(walk.odb, tree_oid)?;
3824    if tree_entries_have_duplicate_names(&tree_entries) {
3825        walk.abort_to_flatten = true;
3826        return Ok(());
3827    }
3828    let cache_children = CacheChildren::new(cache_node);
3829    let mut ti = 0usize;
3830
3831    loop {
3832        let te = tree_entries.get(ti);
3833        let ie_kind = peek_index_level_entry(
3834            walk.index,
3835            walk.idx_cursor,
3836            dir_prefix,
3837            walk.ignore_submodules,
3838        );
3839
3840        match (te, ie_kind) {
3841            (Some(t_entry), Some(ie)) => {
3842                let t_name = &t_entry.name;
3843                let (i_name, i_is_tree) = match &ie {
3844                    IndexLevelKind::Blob { name, .. } => (*name, false),
3845                    IndexLevelKind::Tree { name } => (*name, true),
3846                };
3847                let t_is_tree = is_tree_mode(t_entry.mode);
3848                let cmp = crate::objects::tree_entry_cmp(t_name, t_is_tree, i_name, i_is_tree);
3849                match cmp {
3850                    std::cmp::Ordering::Less => {
3851                        emit_index_tree_deleted(
3852                            walk.odb,
3853                            t_entry,
3854                            dir_prefix,
3855                            walk.ignore_submodules,
3856                            walk.result,
3857                        )?;
3858                        ti += 1;
3859                    }
3860                    std::cmp::Ordering::Greater => {
3861                        emit_index_tree_added(walk, dir_prefix)?;
3862                    }
3863                    std::cmp::Ordering::Equal => {
3864                        if t_is_tree && i_is_tree {
3865                            let full_path = join_index_path(dir_prefix, t_name);
3866                            let child_cache = cache_children.get(t_name);
3867                            if child_cache
3868                                .is_some_and(|c| c.is_valid() && c.oid == Some(t_entry.oid))
3869                            {
3870                                skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
3871                                ti += 1;
3872                                continue;
3873                            }
3874                            diff_index_tree_at(walk, &t_entry.oid, child_cache, &full_path)?;
3875                        } else if t_is_tree != i_is_tree {
3876                            emit_index_tree_deleted(
3877                                walk.odb,
3878                                t_entry,
3879                                dir_prefix,
3880                                walk.ignore_submodules,
3881                                walk.result,
3882                            )?;
3883                            emit_index_tree_added(walk, dir_prefix)?;
3884                        } else {
3885                            let IndexLevelKind::Blob { entry: ie, .. } = ie else {
3886                                ti += 1;
3887                                continue;
3888                            };
3889                            *walk.idx_cursor += 1;
3890                            if t_entry.oid != ie.oid || t_entry.mode != ie.mode {
3891                                let path = String::from_utf8_lossy(&ie.path).into_owned();
3892                                walk.result.push(DiffEntry {
3893                                    status: DiffStatus::for_content_change(t_entry.mode, ie.mode),
3894                                    old_path: Some(path.clone()),
3895                                    new_path: Some(path),
3896                                    old_mode: format_mode(t_entry.mode),
3897                                    new_mode: format_mode(ie.mode),
3898                                    old_oid: t_entry.oid,
3899                                    new_oid: ie.oid,
3900                                    score: None,
3901                                });
3902                            }
3903                        }
3904                        ti += 1;
3905                    }
3906                }
3907            }
3908            (Some(t_entry), None) => {
3909                emit_index_tree_deleted(
3910                    walk.odb,
3911                    t_entry,
3912                    dir_prefix,
3913                    walk.ignore_submodules,
3914                    walk.result,
3915                )?;
3916                ti += 1;
3917            }
3918            (None, Some(_)) => {
3919                emit_index_tree_added(walk, dir_prefix)?;
3920            }
3921            (None, None) => break,
3922        }
3923    }
3924
3925    Ok(())
3926}
3927
3928fn emit_index_tree_deleted(
3929    odb: &Odb,
3930    entry: &TreeEntry,
3931    dir_prefix: &[u8],
3932    ignore_submodules: bool,
3933    result: &mut Vec<DiffEntry>,
3934) -> Result<()> {
3935    if ignore_submodules && entry.mode == 0o160000 {
3936        return Ok(());
3937    }
3938    let path = String::from_utf8_lossy(&join_index_path(dir_prefix, &entry.name)).into_owned();
3939    if is_tree_mode(entry.mode) {
3940        for fe in flatten_tree(odb, &entry.oid, &path)? {
3941            if ignore_submodules && fe.mode == 0o160000 {
3942                continue;
3943            }
3944            result.push(DiffEntry {
3945                status: DiffStatus::Deleted,
3946                old_path: Some(fe.path.clone()),
3947                new_path: None,
3948                old_mode: format_mode(fe.mode),
3949                new_mode: "000000".to_owned(),
3950                old_oid: fe.oid,
3951                new_oid: zero_oid(),
3952                score: None,
3953            });
3954        }
3955    } else {
3956        result.push(DiffEntry {
3957            status: DiffStatus::Deleted,
3958            old_path: Some(path),
3959            new_path: None,
3960            old_mode: format_mode(entry.mode),
3961            new_mode: "000000".to_owned(),
3962            old_oid: entry.oid,
3963            new_oid: zero_oid(),
3964            score: None,
3965        });
3966    }
3967    Ok(())
3968}
3969
3970fn emit_index_tree_added(walk: &mut DiffIndexTreeWalk<'_, '_>, dir_prefix: &[u8]) -> Result<()> {
3971    let ie_kind = peek_index_level_entry(
3972        walk.index,
3973        walk.idx_cursor,
3974        dir_prefix,
3975        walk.ignore_submodules,
3976    )
3977    .ok_or_else(|| Error::IndexError("missing index entry during tree walk".to_owned()))?;
3978    match ie_kind {
3979        IndexLevelKind::Blob { entry: ie, .. } => {
3980            let path = String::from_utf8_lossy(&ie.path).into_owned();
3981            *walk.idx_cursor += 1;
3982            walk.result.push(DiffEntry {
3983                status: DiffStatus::Added,
3984                old_path: None,
3985                new_path: Some(path),
3986                old_mode: "000000".to_owned(),
3987                new_mode: format_mode(ie.mode),
3988                old_oid: zero_oid(),
3989                new_oid: ie.oid,
3990                score: None,
3991            });
3992        }
3993        IndexLevelKind::Tree { name } => {
3994            let full_path = join_index_path(dir_prefix, name);
3995            let start = *walk.idx_cursor;
3996            skip_index_subtree(walk.index, walk.idx_cursor, &full_path);
3997            for ie in &walk.index.entries[start..*walk.idx_cursor] {
3998                if ie.stage() != 0 || ie.intent_to_add() {
3999                    continue;
4000                }
4001                if walk.ignore_submodules && ie.mode == 0o160000 {
4002                    continue;
4003                }
4004                let path = String::from_utf8_lossy(&ie.path).into_owned();
4005                walk.result.push(DiffEntry {
4006                    status: DiffStatus::Added,
4007                    old_path: None,
4008                    new_path: Some(path),
4009                    old_mode: "000000".to_owned(),
4010                    new_mode: format_mode(ie.mode),
4011                    old_oid: zero_oid(),
4012                    new_oid: ie.oid,
4013                    score: None,
4014                });
4015            }
4016        }
4017    }
4018    Ok(())
4019}
4020
4021// ── Index-to-worktree diff (unstaged changes) ───────────────────────
4022
4023/// Compare the index against the working tree.
4024///
4025/// This shows "unstaged" changes — modifications not yet staged.
4026///
4027/// Entries with [`IndexEntry::assume_unchanged`] or [`IndexEntry::skip_worktree`] are treated as
4028/// matching the work tree without examining the filesystem (Git `CE_VALID` / skip-worktree).
4029///
4030/// # Parameters
4031///
4032/// - `odb` — object database (for hashing worktree files).
4033/// - `index` — the current index.
4034/// - `work_tree` — path to the working tree root.
4035/// - `ignore_submodule_untracked` — when true, gitlink entries are not dirty solely from untracked
4036///   files inside the submodule (matches `git status -uno`).
4037/// - `simplify_gitlinks` — when true, nested gitlink entries only compare the submodule checkout
4038///   HEAD to the recorded OID (ignore dirty work trees inside nested submodules). Used when
4039///   computing `submodule_porcelain_flags` so untracked files under a nested submodule do not set
4040///   the parent submodule's `modified` bit (Git `DIRTY_SUBMODULE_MODIFIED`; t7506).
4041///
4042/// # Errors
4043///
4044/// Returns errors from I/O or hashing.
4045pub fn diff_index_to_worktree(
4046    odb: &Odb,
4047    index: &mut Index,
4048    work_tree: &Path,
4049    ignore_submodule_untracked: bool,
4050    simplify_gitlinks: bool,
4051) -> Result<Vec<DiffEntry>> {
4052    diff_index_to_worktree_with_options(
4053        odb,
4054        index,
4055        work_tree,
4056        DiffIndexToWorktreeOptions {
4057            ignore_submodule_untracked,
4058            simplify_gitlinks,
4059            ..DiffIndexToWorktreeOptions::default()
4060        },
4061    )
4062    .map(|(entries, _)| entries)
4063}
4064
4065/// Additional inputs for [`diff_index_to_worktree_with_options`].
4066#[derive(Clone)]
4067pub struct DiffIndexToWorktreeOptions {
4068    /// Optional index mtime pair `(sec, nsec)` sampled when the index was read.
4069    ///
4070    /// When provided, entries with matching stat data are still considered dirty candidates if
4071    /// their recorded mtime is "racy" (at or after this timestamp), matching Git's
4072    /// `is_racy_timestamp` behavior.
4073    pub index_mtime: Option<(u32, u32)>,
4074    /// When true, gitlink entries are not dirty solely from untracked files inside the submodule.
4075    pub ignore_submodule_untracked: bool,
4076    /// When true, nested gitlink entries only compare the submodule checkout HEAD to the recorded OID.
4077    pub simplify_gitlinks: bool,
4078    /// When true, a populated gitlink checkout whose `.git` indirection cannot resolve to a HEAD
4079    /// is returned as an error instead of a normal modified gitlink.
4080    pub error_on_broken_gitlinks: bool,
4081    /// Repository git directory used to load config (separate git dir / linked worktrees).
4082    ///
4083    /// When unset, defaults to `work_tree/.git`, which is wrong for gitfile layouts.
4084    pub repository_git_dir: Option<std::path::PathBuf>,
4085    /// When true, adopt refreshed stat data for unchanged blobs during the same directory scan
4086    /// as the worktree diff (one lstat per tracked path).
4087    pub refresh_index_stat_in_pass: bool,
4088    /// When set, used instead of loading config from [`Self::repository_git_dir`].
4089    pub config: Option<std::sync::Arc<crate::config::ConfigSet>>,
4090    /// Cap parallel stat workers (`Some(1)` forces serial), also applied to hashing the
4091    /// racily clean entries the stat refresh verifies. When `None`, honors
4092    /// `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
4093    pub stat_parallel_threads: Option<usize>,
4094    /// CLI / embedder thread override applied to stat preload and hashing in this diff.
4095    pub thread_policy: Option<crate::parallel::ThreadPolicy>,
4096    /// Shared per-operation attribute/conversion context (see [`crate::worktree_rules::WorktreeRules`]).
4097    pub worktree_rules:
4098        Option<std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
4099    /// When true, omit worktree-vs-index diffs for unmerged paths (used by `status` only).
4100    pub for_status: bool,
4101    /// When true (default), query `core.fsmonitor` before scanning the worktree.
4102    pub refresh_fsmonitor: bool,
4103    /// Injectable subprocess runner for fsmonitor hooks; defaults to the system runner.
4104    pub command_runner: Option<std::sync::Arc<dyn crate::command_runner::CommandRunner>>,
4105    /// When set, used as the hook protocol v1 query boundary; otherwise [`crate::environment::wall_clock_unix_timestamp_nanos`] is sampled once per diff.
4106    pub fsmonitor_query_boundary: Option<crate::fsmonitor::FsmonitorQueryBoundary>,
4107    /// When set, only index paths in this set are compared against the work tree.
4108    ///
4109    /// Used by branch switch to verify dirty paths only where the tree-to-tree diff would
4110    /// update the worktree, avoiding a full tracked scan on large indexes. Callers must
4111    /// refuse the operation when the index has unmerged entries; this filter does not
4112    /// substitute for a full unmerged scan.
4113    pub restrict_to_paths: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
4114    /// When true with staging materialization, compute blob OIDs without writing loose objects.
4115    pub hash_blobs_without_write: bool,
4116    /// When set, long loops abort with [`crate::error::Error::Cancelled`] once the flag is raised
4117    /// (see [`crate::progress::cancel_watch_arc`] and [`crate::progress::poll_cancel_watch`]).
4118    pub cancel_watch: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
4119}
4120
4121impl Default for DiffIndexToWorktreeOptions {
4122    fn default() -> Self {
4123        Self {
4124            index_mtime: None,
4125            ignore_submodule_untracked: false,
4126            simplify_gitlinks: false,
4127            error_on_broken_gitlinks: false,
4128            repository_git_dir: None,
4129            refresh_index_stat_in_pass: false,
4130            config: None,
4131            stat_parallel_threads: None,
4132            thread_policy: None,
4133            worktree_rules: None,
4134            for_status: false,
4135            refresh_fsmonitor: true,
4136            command_runner: None,
4137            fsmonitor_query_boundary: None,
4138            restrict_to_paths: None,
4139            hash_blobs_without_write: false,
4140            cancel_watch: None,
4141        }
4142    }
4143}
4144
4145#[inline]
4146fn poll_diff_cancel_watch(
4147    watch: Option<&std::sync::Arc<std::sync::atomic::AtomicBool>>,
4148) -> crate::error::Result<()> {
4149    if let Some(flag) = watch {
4150        crate::progress::poll_watch(flag)?;
4151    }
4152    Ok(())
4153}
4154
4155struct StagingHashJob {
4156    entry_index: usize,
4157    rel_path: String,
4158    file_path: PathBuf,
4159    entry: IndexEntry,
4160    /// Worktree mode after [`crate::index::WorktreeModePolicy`] against `entry.mode`.
4161    worktree_mode: u32,
4162    file_attrs: crate::crlf::FileAttrs,
4163}
4164
4165#[allow(clippy::too_many_arguments)]
4166fn parallel_process_blob_worktree_jobs(
4167    odb: &Odb,
4168    jobs: &[StagingHashJob],
4169    thread_policy: &crate::parallel::ThreadPolicy,
4170    config: &crate::config::ConfigSet,
4171    materialize_dirty_blobs: bool,
4172    hash_blobs_without_write: bool,
4173    conv: &crate::crlf::ConversionConfig,
4174    filter_process: Option<&crate::filter_process::FilterProcessState>,
4175    result: &mut Vec<DiffEntry>,
4176) -> Result<()> {
4177    use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
4178
4179    if jobs.is_empty() {
4180        return Ok(());
4181    }
4182    let mut par_opts = thread_policy.resolve(ParallelOp::IndexRefresh, config);
4183    par_opts.min_items = 0;
4184    par_opts.min_total_bytes = None;
4185    let est_bytes = jobs.len().saturating_mul(8192);
4186    let store_dirty_blobs = (materialize_dirty_blobs && !hash_blobs_without_write)
4187        .then_some(crate::odb::BULK_NEW_LOOSE_WRITE);
4188    let stream_from = if store_dirty_blobs.is_some() {
4189        odb.big_file_threshold()?
4190    } else {
4191        hash_stream_threshold(odb)?
4192    };
4193
4194    let rows = try_par_map::<StagingHashJob, Option<(usize, DiffEntry)>, Error, _>(
4195        jobs,
4196        par_opts,
4197        est_bytes,
4198        |job| {
4199            let eff_oid = read_regular_worktree_blob(
4200                odb,
4201                &job.file_path,
4202                conv,
4203                &job.file_attrs,
4204                &job.rel_path,
4205                Some(&job.entry),
4206                filter_process,
4207                stream_from,
4208            )?
4209            .object_id(odb, store_dirty_blobs)?;
4210            if eff_oid != job.entry.oid || job.worktree_mode != job.entry.mode {
4211                let path_owned = job.rel_path.clone();
4212                let new_oid = if eff_oid != job.entry.oid && !materialize_dirty_blobs {
4213                    zero_oid()
4214                } else {
4215                    eff_oid
4216                };
4217                Ok(Some((
4218                    job.entry_index,
4219                    DiffEntry {
4220                        status: DiffStatus::for_content_change(job.entry.mode, job.worktree_mode),
4221                        old_path: Some(path_owned.clone()),
4222                        new_path: Some(path_owned),
4223                        old_mode: format_mode(job.entry.mode),
4224                        new_mode: format_mode(job.worktree_mode),
4225                        old_oid: job.entry.oid,
4226                        new_oid,
4227                        score: None,
4228                    },
4229                )))
4230            } else {
4231                Ok(None)
4232            }
4233        },
4234    )
4235    .map_err(|e| match e {
4236        ParallelHashError::Task(err) => err,
4237    })?;
4238
4239    let mut rows: Vec<(usize, DiffEntry)> = rows.into_iter().flatten().collect();
4240    rows.sort_by_key(|(idx, _)| *idx);
4241    result.extend(rows.into_iter().map(|(_, d)| d));
4242    Ok(())
4243}
4244
4245/// Git `compare_stat_data`: a size mismatch means modified without reading blob contents.
4246#[must_use]
4247fn try_push_status_modified_on_size_mismatch(
4248    ie: &IndexEntry,
4249    path_str_ref: &str,
4250    meta: &fs::Metadata,
4251    worktree_mode: u32,
4252    for_status: bool,
4253    result: &mut Vec<DiffEntry>,
4254) -> bool {
4255    if ie.stat_cache_unset() {
4256        return false;
4257    }
4258    if !for_status || crate::index::munge_index_size(meta.len()) == ie.size {
4259        return false;
4260    }
4261    let path_owned = path_str_ref.to_owned();
4262    result.push(DiffEntry {
4263        status: DiffStatus::for_content_change(ie.mode, worktree_mode),
4264        old_path: Some(path_owned.clone()),
4265        new_path: Some(path_owned),
4266        old_mode: format_mode(ie.mode),
4267        new_mode: format_mode(worktree_mode),
4268        old_oid: ie.oid,
4269        new_oid: ie.oid,
4270        score: None,
4271    });
4272    true
4273}
4274
4275fn try_push_status_modified_on_size_mismatch_stat(
4276    ie: &IndexEntry,
4277    path_str_ref: &str,
4278    stat: &crate::worktree_scan::BlobDiskStat,
4279    worktree_mode: u32,
4280    for_status: bool,
4281    result: &mut Vec<DiffEntry>,
4282) -> bool {
4283    if ie.stat_cache_unset() {
4284        return false;
4285    }
4286    if !for_status || stat.worktree_size() == ie.size {
4287        return false;
4288    }
4289    let path_owned = path_str_ref.to_owned();
4290    result.push(DiffEntry {
4291        status: DiffStatus::for_content_change(ie.mode, worktree_mode),
4292        old_path: Some(path_owned.clone()),
4293        new_path: Some(path_owned),
4294        old_mode: format_mode(ie.mode),
4295        new_mode: format_mode(worktree_mode),
4296        old_oid: ie.oid,
4297        new_oid: ie.oid,
4298        score: None,
4299    });
4300    true
4301}
4302
4303#[allow(clippy::too_many_arguments)]
4304fn push_index_blob_worktree_diff(
4305    odb: &Odb,
4306    ie: &IndexEntry,
4307    path_str_ref: &str,
4308    file_path: &Path,
4309    meta: fs::Metadata,
4310    index_mtime: Option<(u32, u32)>,
4311    mode_policy: crate::index::WorktreeModePolicy,
4312    stat_policy: crate::index::StatMatchPolicy,
4313    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
4314    conv: &crate::crlf::ConversionConfig,
4315    materialize_dirty_blobs: bool,
4316    hash_blobs_without_write: bool,
4317    for_status: bool,
4318    filter_process: Option<&crate::filter_process::FilterProcessState>,
4319    result: &mut Vec<DiffEntry>,
4320) -> Result<()> {
4321    if meta.is_dir() {
4322        if file_path.join(".git").exists() {
4323            let head = read_submodule_head_oid(file_path).unwrap_or_else(zero_oid);
4324            let path_owned = path_str_ref.to_owned();
4325            result.push(DiffEntry {
4326                status: DiffStatus::TypeChanged,
4327                old_path: Some(path_owned.clone()),
4328                new_path: Some(path_owned),
4329                old_mode: format_mode(ie.mode),
4330                new_mode: format_mode(0o160000),
4331                old_oid: ie.oid,
4332                new_oid: head,
4333                score: None,
4334            });
4335            return Ok(());
4336        }
4337        result.push(DiffEntry {
4338            status: DiffStatus::Deleted,
4339            old_path: Some(path_str_ref.to_owned()),
4340            new_path: None,
4341            old_mode: format_mode(ie.mode),
4342            new_mode: String::new(),
4343            old_oid: ie.oid,
4344            new_oid: zero_oid(),
4345            score: None,
4346        });
4347        return Ok(());
4348    }
4349
4350    let worktree_mode =
4351        mode_policy.index_mode_for_worktree(mode_from_metadata(&meta), Some(ie.mode));
4352    let stat_same = stat_matches_with_policy(ie, &meta, stat_policy);
4353    if stat_same && worktree_mode != ie.mode {
4354        let path_owned = path_str_ref.to_owned();
4355        result.push(DiffEntry {
4356            status: DiffStatus::for_content_change(ie.mode, worktree_mode),
4357            old_path: Some(path_owned.clone()),
4358            new_path: Some(path_owned),
4359            old_mode: format_mode(ie.mode),
4360            new_mode: format_mode(worktree_mode),
4361            old_oid: ie.oid,
4362            new_oid: ie.oid,
4363            score: None,
4364        });
4365        return Ok(());
4366    }
4367
4368    if stat_same
4369        && index_entry_skips_worktree_content_hash_after_stat_match(ie, index_mtime, stat_policy)
4370    {
4371        return Ok(());
4372    }
4373
4374    if try_push_status_modified_on_size_mismatch(
4375        ie,
4376        path_str_ref,
4377        &meta,
4378        worktree_mode,
4379        for_status,
4380        result,
4381    ) {
4382        return Ok(());
4383    }
4384
4385    let file_attrs = file_attrs_for(path_str_ref);
4386    let store = (materialize_dirty_blobs && !hash_blobs_without_write)
4387        .then_some(crate::odb::BULK_NEW_LOOSE_WRITE);
4388    let stream_from = if store.is_some() {
4389        odb.big_file_threshold()?
4390    } else {
4391        hash_stream_threshold(odb)?
4392    };
4393    let eff_oid = worktree_blob_content_from(
4394        odb,
4395        file_path,
4396        &meta,
4397        conv,
4398        &file_attrs,
4399        path_str_ref,
4400        Some(ie),
4401        filter_process,
4402        stream_from,
4403    )?
4404    .object_id(odb, store)?;
4405
4406    if eff_oid != ie.oid || worktree_mode != ie.mode {
4407        let path_owned = path_str_ref.to_owned();
4408        // `git diff-files --raw` uses the null OID for the worktree side when content differs.
4409        // Staging (`diff_index_to_worktree_for_staging`) materializes blobs and needs the real OID.
4410        let new_oid = if eff_oid != ie.oid && !materialize_dirty_blobs {
4411            zero_oid()
4412        } else {
4413            eff_oid
4414        };
4415        result.push(DiffEntry {
4416            status: DiffStatus::for_content_change(ie.mode, worktree_mode),
4417            old_path: Some(path_owned.clone()),
4418            new_path: Some(path_owned),
4419            old_mode: format_mode(ie.mode),
4420            new_mode: format_mode(worktree_mode),
4421            old_oid: ie.oid,
4422            new_oid,
4423            score: None,
4424        });
4425    }
4426    Ok(())
4427}
4428
4429/// # Errors
4430///
4431/// Returns errors from I/O or hashing.
4432pub fn diff_index_to_worktree_with_options(
4433    odb: &Odb,
4434    index: &mut Index,
4435    work_tree: &Path,
4436    options: DiffIndexToWorktreeOptions,
4437) -> Result<(Vec<DiffEntry>, bool)> {
4438    let default_git_dir = work_tree.join(".git");
4439    let git_dir = options
4440        .repository_git_dir
4441        .clone()
4442        .unwrap_or(default_git_dir);
4443    diff_index_to_worktree_inner(odb, index, work_tree, &git_dir, options, false)
4444        .map(|(entries, changed, _)| (entries, changed))
4445}
4446
4447/// Like [`diff_index_to_worktree_with_options`], but loads config from `repository_git_dir`
4448/// (the main repository for linked worktrees) and persists each hashed worktree blob into `odb`
4449/// so staging can update the index without re-reading file contents.
4450pub fn diff_index_to_worktree_for_staging(
4451    odb: &Odb,
4452    repository_git_dir: &Path,
4453    index: &mut Index,
4454    work_tree: &Path,
4455    options: DiffIndexToWorktreeOptions,
4456) -> Result<(
4457    Vec<DiffEntry>,
4458    bool,
4459    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
4460)> {
4461    diff_index_to_worktree_inner(odb, index, work_tree, repository_git_dir, options, true)
4462}
4463
4464/// First pass of [`diff_index_to_worktree_inner`]: gitlinks and intent-to-add paths only.
4465#[allow(clippy::too_many_arguments)]
4466fn worktree_diff_pass1_index_entry(
4467    odb: &Odb,
4468    work_tree: &Path,
4469    ie: &IndexEntry,
4470    path_str_ref: &str,
4471    simplify_gitlinks: bool,
4472    ignore_submodule_untracked: bool,
4473    error_on_broken_gitlinks: bool,
4474    materialize_dirty_blobs: bool,
4475    filter_fp: Option<&crate::filter_process::FilterProcessState>,
4476    conv: &crate::crlf::ConversionConfig,
4477    file_attrs_for: &dyn Fn(&str) -> crate::crlf::FileAttrs,
4478    precompose_unicode: bool,
4479    ignorecase: bool,
4480    result: &mut Vec<DiffEntry>,
4481) -> Result<()> {
4482    if ie.skip_worktree() || ie.assume_unchanged() {
4483        return Ok(());
4484    }
4485    let is_intent_to_add = ie.intent_to_add();
4486
4487    if ie.mode == 0o160000 {
4488        let sub_dir =
4489            index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
4490        match fs::symlink_metadata(&sub_dir) {
4491            Err(e)
4492                if e.kind() == std::io::ErrorKind::NotFound
4493                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
4494            {
4495                if !simplify_gitlinks {
4496                    let path_owned = path_str_ref.to_owned();
4497                    result.push(DiffEntry {
4498                        status: DiffStatus::Deleted,
4499                        old_path: Some(path_owned.clone()),
4500                        new_path: Some(path_owned),
4501                        old_mode: format_mode(ie.mode),
4502                        new_mode: "000000".to_owned(),
4503                        old_oid: ie.oid,
4504                        new_oid: zero_oid(),
4505                        score: None,
4506                    });
4507                }
4508                return Ok(());
4509            }
4510            Err(e) => return Err(Error::Io(e)),
4511            Ok(meta) if !meta.is_dir() => {
4512                let worktree_mode = mode_from_metadata(&meta);
4513                let file_attrs = file_attrs_for(path_str_ref);
4514                let worktree_oid = worktree_file_oid(
4515                    odb,
4516                    &sub_dir,
4517                    &meta,
4518                    conv,
4519                    &file_attrs,
4520                    path_str_ref,
4521                    None,
4522                    materialize_dirty_blobs,
4523                    filter_fp,
4524                )?;
4525                let path_owned = path_str_ref.to_owned();
4526                result.push(DiffEntry {
4527                    status: DiffStatus::TypeChanged,
4528                    old_path: Some(path_owned.clone()),
4529                    new_path: Some(path_owned),
4530                    old_mode: format_mode(ie.mode),
4531                    new_mode: format_mode(worktree_mode),
4532                    old_oid: ie.oid,
4533                    new_oid: worktree_oid,
4534                    score: None,
4535                });
4536                return Ok(());
4537            }
4538            Ok(_) => {}
4539        }
4540        let sub_head_oid = read_submodule_head_oid(&sub_dir);
4541        let ref_matches = if let Some(oid) = sub_head_oid {
4542            oid == ie.oid
4543        } else {
4544            let is_placeholder = submodule_worktree_is_unpopulated_placeholder(&sub_dir);
4545            if error_on_broken_gitlinks
4546                && !is_placeholder
4547                && submodule_embedded_git_dir(&sub_dir).is_some()
4548            {
4549                return Err(Error::Config(
4550                    format!("could not read submodule HEAD for '{path_str_ref}'").into(),
4551                ));
4552            }
4553            is_placeholder
4554        };
4555        if simplify_gitlinks {
4556            if !ref_matches {
4557                let path_owned = path_str_ref.to_owned();
4558                let new_oid = sub_head_oid.unwrap_or_else(zero_oid);
4559                result.push(DiffEntry {
4560                    status: DiffStatus::Modified,
4561                    old_path: Some(path_owned.clone()),
4562                    new_path: Some(path_owned),
4563                    old_mode: format_mode(ie.mode),
4564                    new_mode: format_mode(ie.mode),
4565                    old_oid: ie.oid,
4566                    new_oid,
4567                    score: None,
4568                });
4569            }
4570            return Ok(());
4571        }
4572        if sub_head_oid.is_some() && submodule_head_object_broken(&sub_dir) {
4573            return Err(Error::Config(
4574                format!("'git status --porcelain=2' failed in submodule {path_str_ref}").into(),
4575            ));
4576        }
4577        let mut flags = submodule_porcelain_flags(work_tree, path_str_ref, ie.oid);
4578        if ignore_submodule_untracked {
4579            flags.untracked = false;
4580        }
4581        let inner_dirty = flags.modified || flags.untracked;
4582        if !ref_matches || inner_dirty {
4583            let path_owned = path_str_ref.to_owned();
4584            let new_oid = if !ref_matches {
4585                sub_head_oid.unwrap_or_else(zero_oid)
4586            } else {
4587                zero_oid()
4588            };
4589            result.push(DiffEntry {
4590                status: DiffStatus::Modified,
4591                old_path: Some(path_owned.clone()),
4592                new_path: Some(path_owned),
4593                old_mode: format_mode(ie.mode),
4594                new_mode: format_mode(ie.mode),
4595                old_oid: ie.oid,
4596                new_oid,
4597                score: None,
4598            });
4599        }
4600        return Ok(());
4601    }
4602
4603    let file_path =
4604        index_entry_worktree_abs_bytes(work_tree, &ie.path, precompose_unicode, ignorecase);
4605    let path_match = crate::repo_relpath::as_utf8_for_matching(path_str_ref);
4606
4607    if is_intent_to_add {
4608        match fs::symlink_metadata(&file_path) {
4609            Ok(meta) => {
4610                let file_attrs = file_attrs_for(path_match.as_ref());
4611                let worktree_oid = worktree_file_oid(
4612                    odb,
4613                    &file_path,
4614                    &meta,
4615                    conv,
4616                    &file_attrs,
4617                    path_match.as_ref(),
4618                    None,
4619                    materialize_dirty_blobs,
4620                    filter_fp,
4621                )?;
4622                let worktree_mode = mode_from_metadata(&meta);
4623                result.push(DiffEntry {
4624                    status: DiffStatus::Added,
4625                    old_path: None,
4626                    new_path: Some(path_str_ref.to_owned()),
4627                    old_mode: "000000".to_owned(),
4628                    new_mode: format_mode(worktree_mode),
4629                    old_oid: zero_oid(),
4630                    new_oid: worktree_oid,
4631                    score: None,
4632                });
4633            }
4634            Err(e)
4635                if e.kind() == std::io::ErrorKind::NotFound
4636                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
4637            {
4638                result.push(DiffEntry {
4639                    status: DiffStatus::Deleted,
4640                    old_path: Some(path_str_ref.to_owned()),
4641                    new_path: None,
4642                    old_mode: format_mode(ie.mode),
4643                    new_mode: "000000".to_owned(),
4644                    old_oid: ie.oid,
4645                    new_oid: zero_oid(),
4646                    score: None,
4647                });
4648            }
4649            Err(e) => return Err(Error::Io(e)),
4650        }
4651    }
4652    Ok(())
4653}
4654
4655#[inline]
4656fn index_entry_needs_worktree_diff_pass1(ie: &IndexEntry) -> bool {
4657    ie.mode == crate::index::MODE_GITLINK || ie.intent_to_add()
4658}
4659
4660fn diff_index_to_worktree_inner(
4661    odb: &Odb,
4662    index: &mut Index,
4663    work_tree: &Path,
4664    repository_git_dir: &Path,
4665    options: DiffIndexToWorktreeOptions,
4666    materialize_dirty_blobs: bool,
4667) -> Result<(
4668    Vec<DiffEntry>,
4669    bool,
4670    std::collections::BTreeMap<String, crate::index::IndexStatFields>,
4671)> {
4672    use crate::config::ConfigSet;
4673    use crate::crlf;
4674    use std::collections::BTreeMap;
4675
4676    let mut staging_stat_cache = BTreeMap::new();
4677    let cancel_watch = options.cancel_watch.as_ref();
4678    poll_diff_cancel_watch(cancel_watch)?;
4679    let index_mtime = index_mtime_for_diff(index, options.index_mtime);
4680
4681    let ignore_submodule_untracked = options.ignore_submodule_untracked;
4682    let simplify_gitlinks = options.simplify_gitlinks;
4683
4684    let rules_lock = options
4685        .worktree_rules
4686        .as_ref()
4687        .map(|shared| {
4688            shared
4689                .lock()
4690                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
4691        })
4692        .transpose()?;
4693    let config = if let Some(rules) = rules_lock.as_ref() {
4694        rules.config().clone()
4695    } else if let Some(c) = options.config.as_ref() {
4696        c.as_ref().clone()
4697    } else {
4698        ConfigSet::load(
4699            &crate::environment::Environment::empty(),
4700            Some(repository_git_dir),
4701            true,
4702        )
4703        .unwrap_or_else(|_| ConfigSet::new())
4704    };
4705    let conv = rules_lock
4706        .as_ref()
4707        .map(|r| r.conversion().clone())
4708        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
4709    let legacy_attrs = if rules_lock.is_none() {
4710        Some(crlf::load_gitattributes_parsed(work_tree))
4711    } else {
4712        None
4713    };
4714    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
4715        if let Some(rules) = rules_lock.as_ref() {
4716            rules.file_attrs(path, false)
4717        } else if let Some(parsed) = legacy_attrs.as_ref() {
4718            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
4719        } else {
4720            crlf::FileAttrs::default()
4721        }
4722    };
4723    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
4724    let precompose_unicode = config
4725        .get_bool("core.precomposeunicode")
4726        .and_then(|r| r.ok())
4727        .unwrap_or(false);
4728    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
4729    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
4730    let ignorecase = config
4731        .get_bool("core.ignorecase")
4732        .and_then(|r| r.ok())
4733        .unwrap_or(false);
4734    let hash_blobs_without_write = options.hash_blobs_without_write;
4735
4736    let mut result = Vec::new();
4737    let refresh_index_stat_in_pass = options.refresh_index_stat_in_pass;
4738    let mut index_stat_refresh_changed = false;
4739    let mut honor_fsmonitor_valid = false;
4740    if options.refresh_fsmonitor {
4741        let default_runner = crate::command_runner::system_command_runner();
4742        let runner = options
4743            .command_runner
4744            .as_deref()
4745            .unwrap_or(default_runner.as_ref());
4746        let boundary = options.fsmonitor_query_boundary.unwrap_or_else(|| {
4747            crate::fsmonitor::FsmonitorQueryBoundary(
4748                crate::environment::wall_clock_unix_timestamp_nanos(),
4749            )
4750        });
4751        let refresh_opts = crate::fsmonitor::FsmonitorRefreshOptions::with_query_boundary(boundary);
4752        let refresh = crate::fsmonitor::refresh_index(
4753            index,
4754            repository_git_dir,
4755            work_tree,
4756            &config,
4757            Some(runner),
4758            &refresh_opts,
4759        )?;
4760        if refresh.index_changed {
4761            index_stat_refresh_changed = true;
4762        }
4763        honor_fsmonitor_valid = refresh.honor_valid_bits;
4764    }
4765    let use_fsmonitor_stat_skip = honor_fsmonitor_valid;
4766    let mut unmerged_base: std::collections::BTreeMap<String, (u8, IndexEntry)> =
4767        std::collections::BTreeMap::new();
4768
4769    // Cache of ancestor-directory symlink-ness so we stat each directory at most
4770    // once, rather than re-lstat'ing every ancestor of every index entry
4771    // (O(unique dirs) instead of O(files × depth)).
4772    let dir_symlinks = SymlinkDirCache::default();
4773
4774    let error_on_broken_gitlinks = options.error_on_broken_gitlinks;
4775    if options.restrict_to_paths.is_none() {
4776        for ie in &index.entries {
4777            poll_diff_cancel_watch(cancel_watch)?;
4778            if ie.stage() != 0 {
4779                let path = String::from_utf8_lossy(&ie.path).to_string();
4780                let rank = match ie.stage() {
4781                    2 => 0u8,
4782                    3 => 1u8,
4783                    1 => 2u8,
4784                    _ => 3u8,
4785                };
4786                match unmerged_base.get(&path) {
4787                    Some((existing_rank, _)) if *existing_rank <= rank => {}
4788                    _ => {
4789                        unmerged_base.insert(path, (rank, ie.clone()));
4790                    }
4791                }
4792            }
4793        }
4794    }
4795
4796    if let Some(limit) = options.restrict_to_paths.as_ref() {
4797        for path in limit.iter() {
4798            let Some(ie) = index.get(path.as_bytes(), 0) else {
4799                continue;
4800            };
4801            if !index_entry_needs_worktree_diff_pass1(ie) {
4802                continue;
4803            }
4804            worktree_diff_pass1_index_entry(
4805                odb,
4806                work_tree,
4807                ie,
4808                path.as_str(),
4809                simplify_gitlinks,
4810                ignore_submodule_untracked,
4811                error_on_broken_gitlinks,
4812                materialize_dirty_blobs,
4813                filter_fp,
4814                &conv,
4815                &file_attrs_for,
4816                precompose_unicode,
4817                ignorecase,
4818                &mut result,
4819            )?;
4820        }
4821    } else {
4822        for ie in &index.entries {
4823            poll_diff_cancel_watch(cancel_watch)?;
4824            if ie.stage() != 0 {
4825                continue;
4826            }
4827            if !index_entry_needs_worktree_diff_pass1(ie) {
4828                continue;
4829            }
4830            let path_stored = crate::repo_relpath::storage_string(&ie.path);
4831            worktree_diff_pass1_index_entry(
4832                odb,
4833                work_tree,
4834                ie,
4835                &path_stored,
4836                simplify_gitlinks,
4837                ignore_submodule_untracked,
4838                error_on_broken_gitlinks,
4839                materialize_dirty_blobs,
4840                filter_fp,
4841                &conv,
4842                &file_attrs_for,
4843                precompose_unicode,
4844                ignorecase,
4845                &mut result,
4846            )?;
4847        }
4848    }
4849
4850    use crate::worktree_scan::{
4851        collect_blob_lookup_batches_with_literal_paths, group_blob_entries_by_dir,
4852        stat_scan_thread_count, visit_blob_lookup_batches, BlobDiskLookup, BlobScanSparseMode,
4853        WorktreeBlobScanOptions, PARALLEL_STAT_MIN_ENTRIES,
4854    };
4855    let preload_index = config
4856        .get_bool("core.preloadIndex")
4857        .and_then(|r| r.ok())
4858        .unwrap_or(true);
4859    let blob_scan = WorktreeBlobScanOptions {
4860        preload_index,
4861        stat_parallel_threads: options.stat_parallel_threads,
4862    };
4863    let blob_dirs = if let Some(limit) = options.restrict_to_paths.as_ref() {
4864        crate::worktree_scan::group_blob_entries_by_dir_for_paths(index, limit)
4865    } else {
4866        group_blob_entries_by_dir(index, honor_fsmonitor_valid)
4867    };
4868    let work_tree_owned = work_tree.to_path_buf();
4869    let dir_abs = move |dir: &[u8]| {
4870        if dir.is_empty() {
4871            work_tree_owned.clone()
4872        } else {
4873            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
4874        }
4875    };
4876    let work_tree_for_files = work_tree.to_path_buf();
4877    let file_abs = move |rel: &[u8]| {
4878        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
4879    };
4880    let blob_count = blob_dirs.len();
4881    let thread_policy = options.thread_policy.unwrap_or_default();
4882    let hash_threads = stat_scan_thread_count(&thread_policy, &config, blob_scan, blob_count);
4883    let parallel_blob_work = blob_count >= PARALLEL_STAT_MIN_ENTRIES
4884        && hash_threads > 1
4885        && (materialize_dirty_blobs || options.for_status);
4886    let mut staging_hash_jobs: Vec<StagingHashJob> = Vec::new();
4887    // Entries the stat refresh just hashed and found matching their recorded blob.
4888    let mut refresh_content_verified = vec![false; index.entries.len()];
4889    // Parallel preload must lstat ancestor directories without following symlinks. When a
4890    // tracked directory is replaced by a symlink, indexed paths under it are deletions even if
4891    // the link target still holds a file at the same relative path with an unchanged stat.
4892    // This is independent of `core.symlinks` (checkout support); detection must stay enabled.
4893    let sparse = if parallel_blob_work {
4894        BlobScanSparseMode::StatusDiff {
4895            stat_policy,
4896            index_mtime,
4897            mode_policy,
4898            for_status: options.for_status,
4899            refresh_index_stat_in_pass,
4900            parallel_blob_work,
4901            honor_fsmonitor_stat_skip: use_fsmonitor_stat_skip,
4902        }
4903    } else {
4904        BlobScanSparseMode::Dense
4905    };
4906    let blob_stat_batches = collect_blob_lookup_batches_with_literal_paths(
4907        index,
4908        &blob_dirs,
4909        blob_scan,
4910        &thread_policy,
4911        &config,
4912        dir_abs,
4913        file_abs,
4914        !precompose_unicode && !ignorecase,
4915        |rel_path| {
4916            dir_symlinks.has_symlink_in_path_bytes(
4917                work_tree,
4918                rel_path,
4919                precompose_unicode,
4920                ignorecase,
4921            )
4922        },
4923        sparse,
4924    )?;
4925    let refresh_checks = if refresh_index_stat_in_pass {
4926        let par_opts =
4927            refresh_hash_parallel_options(&thread_policy, &config, options.stat_parallel_threads);
4928        refresh_content_checks(
4929            odb,
4930            index,
4931            &blob_stat_batches,
4932            index_mtime,
4933            stat_policy,
4934            &|ie, stat| {
4935                !(use_fsmonitor_stat_skip && crate::fsmonitor::entry_skips_worktree_stat(ie, index))
4936                    && worktree_diff_refreshes_entry(
4937                        ie,
4938                        stat,
4939                        index_mtime,
4940                        stat_policy,
4941                        options.for_status,
4942                    )
4943            },
4944            &WorktreeHashContext {
4945                work_tree,
4946                precompose_unicode,
4947                ignorecase,
4948                conv: &conv,
4949                file_attrs_for: &file_attrs_for,
4950                filter_process: filter_fp,
4951            },
4952            par_opts,
4953        )
4954    } else {
4955        Vec::new()
4956    };
4957    poll_diff_cancel_watch(cancel_watch)?;
4958    visit_blob_lookup_batches(blob_stat_batches, |entry_index, lookup| {
4959        poll_diff_cancel_watch(cancel_watch)?;
4960        let path_owned = crate::repo_relpath::storage_string(&index.entries[entry_index].path);
4961        let path_str_ref = path_owned.as_str();
4962        if use_fsmonitor_stat_skip {
4963            let ie_skip = &index.entries[entry_index];
4964            if crate::fsmonitor::entry_skips_worktree_stat(ie_skip, index) {
4965                return Ok(());
4966            }
4967        }
4968        match lookup {
4969            BlobDiskLookup::Missing => {
4970                let ie = &index.entries[entry_index];
4971                result.push(DiffEntry {
4972                    status: DiffStatus::Deleted,
4973                    old_path: Some(path_str_ref.to_owned()),
4974                    new_path: None,
4975                    old_mode: format_mode(ie.mode),
4976                    new_mode: "000000".to_owned(),
4977                    old_oid: ie.oid,
4978                    new_oid: zero_oid(),
4979                    score: None,
4980                });
4981            }
4982            BlobDiskLookup::Io(e) => return Err(Error::Io(e)),
4983            BlobDiskLookup::Present(stat) => {
4984                if materialize_dirty_blobs {
4985                    staging_stat_cache.insert(path_str_ref.to_owned(), stat.fields);
4986                }
4987                if refresh_index_stat_in_pass
4988                    && worktree_diff_refreshes_entry(
4989                        &index.entries[entry_index],
4990                        &stat,
4991                        index_mtime,
4992                        stat_policy,
4993                        options.for_status,
4994                    )
4995                {
4996                    let refresh = refresh_index_blob_stat_from_scan(
4997                        &mut index.entries[entry_index],
4998                        &stat,
4999                        index_mtime,
5000                        stat_policy,
5001                        refresh_checks[entry_index].unwrap_or(false),
5002                    );
5003                    if refresh.index_changed {
5004                        index_stat_refresh_changed = true;
5005                    }
5006                    if refresh.content_verified {
5007                        refresh_content_verified[entry_index] = true;
5008                        let path = index.entries[entry_index].path.clone();
5009                        let oid = index.entries[entry_index].oid;
5010                        index.mark_content_verified(&path, oid);
5011                    }
5012                    if index.fsmonitor_last_update.is_some()
5013                        && stat_matches_blob_stat(&index.entries[entry_index], &stat, stat_policy)
5014                        && !refresh.index_changed
5015                    {
5016                        index.entries[entry_index].set_fsmonitor_valid(true);
5017                    }
5018                }
5019                let ie = &index.entries[entry_index];
5020                let worktree_mode =
5021                    mode_policy.index_mode_for_worktree(stat.git_mode, Some(ie.mode));
5022                if parallel_blob_work {
5023                    let stat_same = stat_matches_blob_stat(ie, &stat, stat_policy);
5024                    if stat_same
5025                        && worktree_mode == ie.mode
5026                        && index_entry_skips_worktree_content_hash_after_stat_match(
5027                            ie,
5028                            index_mtime,
5029                            stat_policy,
5030                        )
5031                    {
5032                        return Ok(());
5033                    }
5034                    if stat_same && worktree_mode != ie.mode {
5035                        let path_owned = path_str_ref.to_owned();
5036                        result.push(DiffEntry {
5037                            status: DiffStatus::for_content_change(ie.mode, worktree_mode),
5038                            old_path: Some(path_owned.clone()),
5039                            new_path: Some(path_owned),
5040                            old_mode: format_mode(ie.mode),
5041                            new_mode: format_mode(worktree_mode),
5042                            old_oid: ie.oid,
5043                            new_oid: ie.oid,
5044                            score: None,
5045                        });
5046                    } else if try_push_status_modified_on_size_mismatch_stat(
5047                        ie,
5048                        path_str_ref,
5049                        &stat,
5050                        worktree_mode,
5051                        options.for_status,
5052                        &mut result,
5053                    ) {
5054                    } else if !refresh_content_verified[entry_index] {
5055                        let file_path = index_entry_worktree_abs_bytes(
5056                            work_tree,
5057                            &index.entries[entry_index].path,
5058                            precompose_unicode,
5059                            ignorecase,
5060                        );
5061                        staging_hash_jobs.push(StagingHashJob {
5062                            entry_index,
5063                            rel_path: path_str_ref.to_owned(),
5064                            file_path,
5065                            entry: ie.clone(),
5066                            worktree_mode,
5067                            file_attrs: file_attrs_for(path_str_ref),
5068                        });
5069                    }
5070                } else {
5071                    // The scan's lstat already proves an unchanged, non-racy entry clean, and the
5072                    // stat refresh above may have just verified a racy entry by content; only
5073                    // re-stat paths that `push_index_blob_worktree_diff` has to inspect.
5074                    let same_file_type = (worktree_mode == crate::index::MODE_SYMLINK)
5075                        == (ie.mode == crate::index::MODE_SYMLINK);
5076                    if !stat.is_dir
5077                        && worktree_mode == ie.mode
5078                        && ((refresh_content_verified[entry_index] && same_file_type)
5079                            || (stat_matches_blob_stat(ie, &stat, stat_policy)
5080                                && index_entry_skips_worktree_content_hash_after_stat_match(
5081                                    ie,
5082                                    index_mtime,
5083                                    stat_policy,
5084                                )))
5085                    {
5086                        return Ok(());
5087                    }
5088                    let file_path = index_entry_worktree_abs_bytes(
5089                        work_tree,
5090                        &index.entries[entry_index].path,
5091                        precompose_unicode,
5092                        ignorecase,
5093                    );
5094                    let meta = fs::symlink_metadata(&file_path).map_err(Error::Io)?;
5095                    push_index_blob_worktree_diff(
5096                        odb,
5097                        ie,
5098                        path_str_ref,
5099                        &file_path,
5100                        meta,
5101                        index_mtime,
5102                        mode_policy,
5103                        stat_policy,
5104                        &file_attrs_for,
5105                        &conv,
5106                        materialize_dirty_blobs,
5107                        hash_blobs_without_write,
5108                        options.for_status,
5109                        filter_fp,
5110                        &mut result,
5111                    )?;
5112                }
5113            }
5114        }
5115        Ok(())
5116    })?;
5117    if parallel_blob_work && !staging_hash_jobs.is_empty() {
5118        parallel_process_blob_worktree_jobs(
5119            odb,
5120            &staging_hash_jobs,
5121            &thread_policy,
5122            &config,
5123            materialize_dirty_blobs,
5124            hash_blobs_without_write,
5125            &conv,
5126            filter_fp,
5127            &mut result,
5128        )?;
5129    }
5130
5131    for (path, (_, base_entry)) in unmerged_base {
5132        let file_path = index_entry_worktree_abs(work_tree, &path, precompose_unicode, ignorecase);
5133        let wt_meta = match fs::symlink_metadata(&file_path) {
5134            Ok(meta) => Some(meta),
5135            Err(e)
5136                if e.kind() == std::io::ErrorKind::NotFound
5137                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5138            {
5139                None
5140            }
5141            Err(e) => return Err(Error::Io(e)),
5142        };
5143
5144        let new_mode = wt_meta.as_ref().map_or_else(
5145            || "000000".to_owned(),
5146            |meta| format_mode(mode_from_metadata(meta)),
5147        );
5148        result.push(DiffEntry {
5149            status: DiffStatus::Unmerged,
5150            old_path: Some(path.clone()),
5151            new_path: Some(path.clone()),
5152            old_mode: "000000".to_owned(),
5153            new_mode,
5154            old_oid: zero_oid(),
5155            new_oid: zero_oid(),
5156            score: None,
5157        });
5158
5159        if !options.for_status {
5160            if let Some(meta) = wt_meta {
5161                let file_attrs = file_attrs_for(&path);
5162                let wt_oid = worktree_file_oid(
5163                    odb,
5164                    &file_path,
5165                    &meta,
5166                    &conv,
5167                    &file_attrs,
5168                    &path,
5169                    Some(&base_entry),
5170                    materialize_dirty_blobs,
5171                    filter_fp,
5172                )?;
5173                let wt_mode = mode_from_metadata(&meta);
5174                if wt_oid != base_entry.oid || wt_mode != base_entry.mode {
5175                    result.push(DiffEntry {
5176                        status: DiffStatus::for_content_change(base_entry.mode, wt_mode),
5177                        old_path: Some(path.clone()),
5178                        new_path: Some(path),
5179                        old_mode: format_mode(base_entry.mode),
5180                        new_mode: format_mode(wt_mode),
5181                        old_oid: base_entry.oid,
5182                        new_oid: wt_oid,
5183                        score: None,
5184                    });
5185                }
5186            }
5187        }
5188    }
5189
5190    Ok((result, index_stat_refresh_changed, staging_stat_cache))
5191}
5192
5193/// Memoized cache of which ancestor directories are symlinks, so each directory
5194/// is probed at most once per worktree scan. Shared by parallel stat workers.
5195#[derive(Default)]
5196pub(crate) struct SymlinkDirCache {
5197    /// Repository-relative directory (raw index bytes) to whether it is a symlink.
5198    dirs: std::sync::Mutex<hashbrown::HashMap<Vec<u8>, bool>>,
5199}
5200
5201pub(crate) fn index_entry_worktree_abs(
5202    work_tree: &Path,
5203    index_relpath: &str,
5204    precompose_unicode: bool,
5205    ignorecase: bool,
5206) -> PathBuf {
5207    index_entry_worktree_abs_bytes(
5208        work_tree,
5209        index_relpath.as_bytes(),
5210        precompose_unicode,
5211        ignorecase,
5212    )
5213}
5214
5215pub(crate) fn index_entry_worktree_abs_bytes(
5216    work_tree: &Path,
5217    index_relpath: &[u8],
5218    precompose_unicode: bool,
5219    ignorecase: bool,
5220) -> PathBuf {
5221    if precompose_unicode || ignorecase {
5222        if let Ok(s) = std::str::from_utf8(index_relpath) {
5223            if precompose_unicode {
5224                return crate::unicode_normalization::resolve_worktree_path_for_staging(
5225                    work_tree, s, true,
5226                )
5227                .abs;
5228            }
5229            return crate::path_icase::worktree_path_for_index_entry(work_tree, s, ignorecase);
5230        }
5231    }
5232    crate::index::worktree_path_from_index_rel(work_tree, index_relpath)
5233}
5234
5235/// Effective index-file mtime for racy-git checks.
5236///
5237/// Uses an explicit override when provided, otherwise [`Index::source_mtime`] from the last
5238/// on-disk read.
5239#[must_use]
5240pub fn index_mtime_for_diff(index: &Index, explicit: Option<(u32, u32)>) -> Option<(u32, u32)> {
5241    explicit.or(index.source_mtime)
5242}
5243
5244impl SymlinkDirCache {
5245    /// Whether any parent directory of `rel_path` (raw index path bytes) is a symlink.
5246    ///
5247    /// Probes each ancestor at most once across all callers; the filesystem query runs
5248    /// outside the lock so parallel workers do not serialize on it.
5249    pub(crate) fn has_symlink_in_path_bytes(
5250        &self,
5251        work_tree: &Path,
5252        rel_path: &[u8],
5253        precompose_unicode: bool,
5254        ignorecase: bool,
5255    ) -> bool {
5256        let Some(dir_len) = rel_path.iter().rposition(|&b| b == b'/') else {
5257            return false;
5258        };
5259        let prefix_ends = rel_path[..dir_len]
5260            .iter()
5261            .enumerate()
5262            .filter_map(|(i, &b)| (b == b'/').then_some(i))
5263            .chain(std::iter::once(dir_len));
5264        for end in prefix_ends {
5265            let prefix = &rel_path[..end];
5266            if prefix.is_empty() || prefix.ends_with(b"/") {
5267                continue;
5268            }
5269            let cached = self
5270                .dirs
5271                .lock()
5272                .unwrap_or_else(|e| e.into_inner())
5273                .get(prefix)
5274                .copied();
5275            let is_symlink = cached.unwrap_or_else(|| {
5276                let abs = index_entry_worktree_abs_bytes(
5277                    work_tree,
5278                    prefix,
5279                    precompose_unicode,
5280                    ignorecase,
5281                );
5282                let probed = crate::worktree_scan::path_is_symlink(&abs);
5283                self.dirs
5284                    .lock()
5285                    .unwrap_or_else(|e| e.into_inner())
5286                    .insert(prefix.to_vec(), probed);
5287                probed
5288            });
5289            if is_symlink {
5290                return true;
5291            }
5292        }
5293        false
5294    }
5295}
5296
5297/// Whether an index entry's cached mtime is at or after the on-disk index file's mtime.
5298///
5299/// Git labels such entries racy: stat data can look clean even though the worktree changed
5300/// within the same second the index was written. Callers pass the index file mtime sampled when
5301/// the index was read (before any rewrite). When `index_mtime` is `None`, returns `false`.
5302#[must_use]
5303pub fn entry_is_racy(ie: &IndexEntry, index_mtime: Option<(u32, u32)>) -> bool {
5304    entry_is_racy_with_policy(ie, index_mtime, crate::index::StatMatchPolicy::default())
5305}
5306
5307/// Like [`entry_is_racy`], honoring `core.usenanosec` for sub-second racy detection.
5308#[must_use]
5309pub fn entry_is_racy_with_policy(
5310    ie: &IndexEntry,
5311    index_mtime: Option<(u32, u32)>,
5312    policy: crate::index::StatMatchPolicy,
5313) -> bool {
5314    let Some((index_mtime_sec, index_mtime_nsec)) = index_mtime else {
5315        return false;
5316    };
5317    if index_mtime_sec == 0 {
5318        return false;
5319    }
5320    if policy.use_nanosec {
5321        index_mtime_sec < ie.mtime_sec
5322            || (index_mtime_sec == ie.mtime_sec && index_mtime_nsec <= ie.mtime_nsec)
5323    } else {
5324        index_mtime_sec <= ie.mtime_sec
5325    }
5326}
5327
5328/// Clear racily-clean cache lines on entries whose blob no longer matches the worktree.
5329///
5330/// Before Git writes the index, it walks stage-0 file and symlink entries and sets the cached
5331/// size to zero when stat data still matches the worktree but content does not match the
5332/// recorded OID and the entry timestamp is racy relative to `index_mtime`. That forces the
5333/// next diff or status pass to re-hash the path instead of trusting stat alone.
5334///
5335/// Skip-worktree, assume-unchanged, and intent-to-add entries are left unchanged, and so are
5336/// entries this process just hashed from the worktree to their current OID (Git skips its
5337/// `CE_UPTODATE` entries the same way). When `index_mtime` is `None` (no prior on-disk index),
5338/// nothing is smudged.
5339///
5340/// Content equality uses `git_dir` config and worktree attributes with [`hash_worktree_file`]
5341/// so the repository hash algorithm and clean (CRLF/filter) conversion match index OIDs.
5342///
5343/// Returns `true` when at least one entry was smudged.
5344pub fn smudge_racily_clean_entries(
5345    odb: &Odb,
5346    git_dir: &Path,
5347    index: &mut Index,
5348    work_tree: &Path,
5349    index_mtime: Option<(u32, u32)>,
5350    config: Option<&ConfigSet>,
5351    filter_process: Option<&crate::filter_process::FilterProcessState>,
5352) -> bool {
5353    use crate::crlf;
5354    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
5355
5356    let Some(index_mtime) = index_mtime else {
5357        return false;
5358    };
5359
5360    let config = config.cloned().unwrap_or_else(|| {
5361        ConfigSet::load(
5362            &crate::environment::Environment::empty(),
5363            Some(git_dir),
5364            true,
5365        )
5366        .unwrap_or_default()
5367    });
5368    let conv = crlf::ConversionConfig::from_config(&config);
5369    let attrs = crlf::load_gitattributes(work_tree);
5370    let verified = std::mem::take(&mut index.content_verified);
5371
5372    let mut changed = false;
5373    for ie in &mut index.entries {
5374        if ie.stage() != 0 || ie.skip_worktree() || ie.assume_unchanged() || ie.intent_to_add() {
5375            continue;
5376        }
5377        if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE && ie.mode != MODE_SYMLINK {
5378            continue;
5379        }
5380        if ie.size == 0 {
5381            continue;
5382        }
5383        if !entry_is_racy(ie, Some(index_mtime)) {
5384            continue;
5385        }
5386        if verified.get(&ie.path) == Some(&ie.oid) {
5387            continue;
5388        }
5389        let Ok(path) = std::str::from_utf8(&ie.path) else {
5390            continue;
5391        };
5392        let abs = work_tree.join(path);
5393        let Ok(meta) = fs::symlink_metadata(&abs) else {
5394            continue;
5395        };
5396        if !stat_matches(ie, &meta) {
5397            continue;
5398        }
5399        let file_attrs = crlf::get_file_attrs(&attrs, path, false, &config);
5400        let content_matches = hash_worktree_file(
5401            odb,
5402            &abs,
5403            &meta,
5404            &conv,
5405            &file_attrs,
5406            path,
5407            Some(ie),
5408            filter_process,
5409        )
5410        .map(|oid| oid == ie.oid)
5411        .unwrap_or(false);
5412        if content_matches {
5413            continue;
5414        }
5415        ie.size = 0;
5416        changed = true;
5417    }
5418    changed
5419}
5420/// Quick stat check: does the index entry's cached stat data match the file?
5421/// Returns true when the file at `ie`'s path differs from the index entry (mode or blob).
5422///
5423/// Used by commands such as `git mv` to detect "dirty" paths under sparse checkout.
5424/// Symlinks and submodules are compared in a Git-compatible way.
5425///
5426/// `ignore_submodule_untracked` mirrors [`diff_index_to_worktree`]'s same flag for gitlinks.
5427pub fn worktree_differs_from_index_entry(
5428    odb: &Odb,
5429    repository_git_dir: &Path,
5430    work_tree: &Path,
5431    ie: &IndexEntry,
5432    ignore_submodule_untracked: bool,
5433    config: Option<&ConfigSet>,
5434    filter_process: Option<&crate::filter_process::FilterProcessState>,
5435) -> Result<bool> {
5436    WorktreeEntryComparer::new(repository_git_dir, work_tree, config).differs(
5437        odb,
5438        ie,
5439        ignore_submodule_untracked,
5440        filter_process,
5441    )
5442}
5443
5444/// Repeated [`worktree_differs_from_index_entry`] checks against one worktree.
5445///
5446/// Loads config-derived settings and the root `.gitattributes` once instead of per entry.
5447pub(crate) struct WorktreeEntryComparer<'a> {
5448    work_tree: &'a Path,
5449    config: ConfigSet,
5450    mode_policy: crate::index::WorktreeModePolicy,
5451    precompose_unicode: bool,
5452    ignorecase: bool,
5453    conv: std::cell::OnceCell<crate::crlf::ConversionConfig>,
5454    attrs: std::cell::OnceCell<Vec<crate::crlf::AttrRule>>,
5455}
5456
5457impl<'a> WorktreeEntryComparer<'a> {
5458    /// Capture settings for `work_tree`; `config` falls back to the repository config files.
5459    pub(crate) fn new(
5460        repository_git_dir: &Path,
5461        work_tree: &'a Path,
5462        config: Option<&ConfigSet>,
5463    ) -> Self {
5464        let config = config.cloned().unwrap_or_else(|| {
5465            ConfigSet::load(
5466                &crate::environment::Environment::empty(),
5467                Some(repository_git_dir),
5468                true,
5469            )
5470            .unwrap_or_else(|_| ConfigSet::new())
5471        });
5472        let flag =
5473            |key: &str, default: bool| config.get_bool(key).and_then(|r| r.ok()).unwrap_or(default);
5474        Self {
5475            work_tree,
5476            mode_policy: crate::index::WorktreeModePolicy::from_config(&config),
5477            precompose_unicode: flag("core.precomposeunicode", false),
5478            ignorecase: flag("core.ignorecase", false),
5479            config,
5480            conv: std::cell::OnceCell::new(),
5481            attrs: std::cell::OnceCell::new(),
5482        }
5483    }
5484
5485    /// Same contract as [`worktree_differs_from_index_entry`].
5486    pub(crate) fn differs(
5487        &self,
5488        odb: &Odb,
5489        ie: &IndexEntry,
5490        ignore_submodule_untracked: bool,
5491        filter_process: Option<&crate::filter_process::FilterProcessState>,
5492    ) -> Result<bool> {
5493        use crate::crlf;
5494
5495        let work_tree = self.work_tree;
5496        let path_stored = crate::repo_relpath::storage_string(&ie.path);
5497        let path_str_ref = crate::repo_relpath::as_utf8_for_matching(&path_stored);
5498        let file_path = index_entry_worktree_abs_bytes(
5499            work_tree,
5500            &ie.path,
5501            self.precompose_unicode,
5502            self.ignorecase,
5503        );
5504
5505        if ie.mode == 0o160000 {
5506            let sub_head_oid = read_submodule_head(&file_path);
5507            let ref_matches = match sub_head_oid {
5508                Some(oid) => oid == ie.oid,
5509                None => submodule_worktree_is_unpopulated_placeholder(&file_path),
5510            };
5511            let mut flags = submodule_porcelain_flags(work_tree, path_str_ref.as_ref(), ie.oid);
5512            if ignore_submodule_untracked {
5513                flags.untracked = false;
5514            }
5515            return Ok(!ref_matches || flags.modified || flags.untracked);
5516        }
5517
5518        let meta = match fs::symlink_metadata(&file_path) {
5519            Ok(m) => m,
5520            Err(e)
5521                if e.kind() == std::io::ErrorKind::NotFound
5522                    || e.raw_os_error() == Some(20) /* ENOTDIR */ =>
5523            {
5524                return Ok(true);
5525            }
5526            Err(e) => return Err(Error::Io(e)),
5527        };
5528
5529        if meta.is_dir() {
5530            return Ok(true);
5531        }
5532
5533        if !self
5534            .mode_policy
5535            .worktree_mode_matches(mode_from_metadata(&meta), ie.mode)
5536        {
5537            return Ok(true);
5538        }
5539
5540        let conv = self
5541            .conv
5542            .get_or_init(|| crlf::ConversionConfig::from_config(&self.config));
5543        let attrs = self
5544            .attrs
5545            .get_or_init(|| crlf::load_gitattributes(work_tree));
5546        let file_attrs = crlf::get_file_attrs(attrs, path_str_ref.as_ref(), false, &self.config);
5547        let worktree_oid = hash_worktree_file(
5548            odb,
5549            &file_path,
5550            &meta,
5551            conv,
5552            &file_attrs,
5553            path_str_ref.as_ref(),
5554            Some(ie),
5555            filter_process,
5556        )?;
5557
5558        let mut eff_oid = worktree_oid;
5559        if eff_oid != ie.oid {
5560            if let Ok(raw) = fs::read(&file_path) {
5561                let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
5562                if raw_oid == ie.oid {
5563                    eff_oid = ie.oid;
5564                }
5565            }
5566        }
5567
5568        Ok(eff_oid != ie.oid)
5569    }
5570}
5571
5572/// Inputs for [`classify_worktree_entry_for_add`].
5573pub struct WorktreeAddRefreshParams<'a> {
5574    /// Object database used to verify blob OIDs when stat data is inconclusive.
5575    pub odb: &'a Odb,
5576    /// Index entry being compared.
5577    pub ie: &'a IndexEntry,
5578    /// `lstat` metadata for the worktree path.
5579    pub meta: &'a fs::Metadata,
5580    /// Absolute worktree path to the file.
5581    pub abs_path: &'a Path,
5582    /// Repository-relative path string.
5583    pub rel_path: &'a str,
5584    /// CRLF / filter conversion settings.
5585    pub conv: &'a crate::crlf::ConversionConfig,
5586    /// Per-path attribute state.
5587    pub file_attrs: &'a crate::crlf::FileAttrs,
5588    /// On-disk index mtime when the index was loaded; `None` skips racy checks.
5589    pub index_mtime: Option<(u32, u32)>,
5590    /// Mode that would be written, after [`crate::index::WorktreeModePolicy`] against `ie.mode`
5591    /// and any `--chmod` hint.
5592    pub staged_mode: u32,
5593    /// Repository-scoped filter-process registry (when staging through a [`crate::repo::Repository`]).
5594    pub filter_process: Option<&'a crate::filter_process::FilterProcessState>,
5595}
5596
5597/// Outcome of comparing a tracked index entry to its worktree path for staging refresh.
5598#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5599pub enum WorktreeAddRefresh {
5600    /// Index already reflects the worktree; caller may skip staging.
5601    UpToDate,
5602    /// Executable bit differs while blob content is unchanged.
5603    ModeOnly {
5604        /// Mode to store in the index.
5605        mode: u32,
5606    },
5607    /// Blob OID and mode are unchanged; only cached stat fields should be refreshed.
5608    StatOnly,
5609    /// Content, type, or mode/blob mismatch requires a full restage (read, convert, hash).
5610    NeedsRestage,
5611}
5612
5613/// Whether stat-only fast paths may skip re-hashing during staging.
5614#[must_use]
5615pub(crate) fn index_stat_cache_trustworthy(index_mtime: Option<(u32, u32)>) -> bool {
5616    match index_mtime {
5617        None => false,
5618        Some((0, _)) => false,
5619        Some(_) => true,
5620    }
5621}
5622
5623/// When true, after worktree metadata has been read and matches the index entry, blob content
5624/// hashing may be skipped (Git `diff-files` / `update-index --refresh` style).
5625///
5626/// Does not permit skipping worktree observation; fsmonitor-valid entries use a separate path.
5627/// Independent of `core.filemode`: callers compare the executable bit separately.
5628///
5629/// A zero cached size is trusted only for the empty blob; any other blob with size zero was
5630/// smudged by a racy index write and must be re-hashed (Git `ce_match_stat_basic`).
5631#[must_use]
5632pub(crate) fn index_entry_skips_worktree_content_hash_after_stat_match(
5633    ie: &IndexEntry,
5634    index_mtime: Option<(u32, u32)>,
5635    stat_policy: crate::index::StatMatchPolicy,
5636) -> bool {
5637    if ie.stat_cache_unset() || (ie.size == 0 && !ie.oid.is_empty_blob()) {
5638        return false;
5639    }
5640    if !index_stat_cache_trustworthy(index_mtime) {
5641        return false;
5642    }
5643    !entry_is_racy_with_policy(ie, index_mtime, stat_policy)
5644}
5645
5646/// Decide whether staging must re-read and re-hash a tracked path.
5647///
5648/// Mirrors the fast paths in [`diff_index_to_worktree`] and Git's `refresh_index` /
5649/// `add_file_to_index`: trust matching stat when the entry is not racy and
5650/// `index_stat_cache_trustworthy` allows it; re-hash when stat matches but the entry is racy
5651/// or when stat differs; apply CRLF-aware hashing like staging.
5652///
5653/// Returns [`WorktreeAddRefresh::UpToDate`] when the index entry already matches the worktree and
5654/// no restaging is required; other variants describe mode/stat-only updates or a full restage.
5655///
5656/// # Errors
5657///
5658/// Returns [`Error::Io`] when the worktree file cannot be read for content verification.
5659pub fn classify_worktree_entry_for_add(
5660    params: &WorktreeAddRefreshParams<'_>,
5661) -> Result<WorktreeAddRefresh> {
5662    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
5663
5664    let ie = params.ie;
5665    let meta = params.meta;
5666    let staged_mode = params.staged_mode;
5667
5668    if ie.intent_to_add() {
5669        return Ok(WorktreeAddRefresh::NeedsRestage);
5670    }
5671
5672    let racy = entry_is_racy(ie, params.index_mtime);
5673    let stat_same = stat_matches(ie, meta);
5674    let trust_stat = index_stat_cache_trustworthy(params.index_mtime);
5675
5676    if ie.mode == MODE_SYMLINK {
5677        // With `core.symlinks=false` the policy keeps a plain file holding the link target as
5678        // a symlink entry, so it is compared by content like a real link.
5679        if !meta.file_type().is_symlink() && staged_mode != MODE_SYMLINK {
5680            return Ok(WorktreeAddRefresh::NeedsRestage);
5681        }
5682        if stat_same && !racy && trust_stat && staged_mode == ie.mode {
5683            return Ok(WorktreeAddRefresh::UpToDate);
5684        }
5685        if stat_same && !racy && trust_stat && staged_mode != ie.mode {
5686            return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
5687        }
5688        return worktree_effective_oid_matches_index(params);
5689    }
5690
5691    if ie.mode != MODE_REGULAR && ie.mode != MODE_EXECUTABLE {
5692        return Ok(WorktreeAddRefresh::NeedsRestage);
5693    }
5694
5695    if meta.file_type().is_symlink() || meta.is_dir() {
5696        return Ok(WorktreeAddRefresh::NeedsRestage);
5697    }
5698
5699    if stat_same && !racy && trust_stat {
5700        if staged_mode == ie.mode {
5701            return Ok(WorktreeAddRefresh::UpToDate);
5702        }
5703        return Ok(WorktreeAddRefresh::ModeOnly { mode: staged_mode });
5704    }
5705
5706    worktree_effective_oid_matches_index(params)
5707}
5708
5709fn worktree_effective_oid_matches_index(
5710    params: &WorktreeAddRefreshParams<'_>,
5711) -> Result<WorktreeAddRefresh> {
5712    let WorktreeAddRefreshParams {
5713        odb,
5714        ie,
5715        meta,
5716        abs_path,
5717        rel_path,
5718        conv,
5719        file_attrs,
5720        staged_mode,
5721        ..
5722    } = params;
5723    let worktree_oid = hash_worktree_file(
5724        odb,
5725        abs_path,
5726        meta,
5727        conv,
5728        file_attrs,
5729        rel_path,
5730        Some(ie),
5731        params.filter_process,
5732    )?;
5733    let mut eff_oid = worktree_oid;
5734    if eff_oid != ie.oid {
5735        if let Ok(raw) = fs::read(abs_path) {
5736            let raw_oid = odb.hash(ObjectKind::Blob, &raw)?;
5737            if raw_oid == ie.oid {
5738                eff_oid = ie.oid;
5739            }
5740        }
5741    }
5742    if eff_oid != ie.oid {
5743        return Ok(WorktreeAddRefresh::NeedsRestage);
5744    }
5745    if *staged_mode != ie.mode {
5746        return Ok(WorktreeAddRefresh::ModeOnly { mode: *staged_mode });
5747    }
5748    if stat_matches(ie, meta) {
5749        Ok(WorktreeAddRefresh::UpToDate)
5750    } else {
5751        Ok(WorktreeAddRefresh::StatOnly)
5752    }
5753}
5754
5755/// Whether worktree metadata matches cached index stat data (Git `ie_match_stat` / `match_stat_data`).
5756#[must_use]
5757pub fn stat_matches(ie: &IndexEntry, meta: &fs::Metadata) -> bool {
5758    stat_matches_with_policy(ie, meta, crate::index::StatMatchPolicy::default())
5759}
5760
5761/// Whether compact worktree stat from a directory scan matches the index row.
5762pub(crate) fn stat_matches_blob_stat(
5763    ie: &IndexEntry,
5764    stat: &crate::worktree_scan::BlobDiskStat,
5765    policy: crate::index::StatMatchPolicy,
5766) -> bool {
5767    stat.fields.matches_index_entry(ie, policy)
5768}
5769
5770/// Like [`stat_matches`], honoring `core.trustctime`, `core.checkstat`, and `core.usenanosec`.
5771#[must_use]
5772pub fn stat_matches_with_policy(
5773    ie: &IndexEntry,
5774    meta: &fs::Metadata,
5775    policy: crate::index::StatMatchPolicy,
5776) -> bool {
5777    if crate::index::munge_index_size(meta.len()) != ie.size {
5778        return false;
5779    }
5780    #[cfg(unix)]
5781    {
5782        use std::os::unix::fs::MetadataExt;
5783        if meta.mtime() as u32 != ie.mtime_sec {
5784            return false;
5785        }
5786        if policy.use_nanosec && policy.check_stat && meta.mtime_nsec() as u32 != ie.mtime_nsec {
5787            return false;
5788        }
5789        if policy.trust_ctime && policy.check_stat {
5790            if meta.ctime() as u32 != ie.ctime_sec {
5791                return false;
5792            }
5793            if policy.use_nanosec && meta.ctime_nsec() as u32 != ie.ctime_nsec {
5794                return false;
5795            }
5796        }
5797        if policy.check_stat && meta.ino() as u32 != ie.ino {
5798            return false;
5799        }
5800    }
5801    #[cfg(not(unix))]
5802    {
5803        let ((ctime_sec, ctime_nsec), (mtime_sec, mtime_nsec)) = crate::index::metadata_times(meta);
5804        if mtime_sec != ie.mtime_sec {
5805            return false;
5806        }
5807        if policy.use_nanosec && policy.check_stat && mtime_nsec != ie.mtime_nsec {
5808            return false;
5809        }
5810        if policy.trust_ctime && policy.check_stat {
5811            if ctime_sec != ie.ctime_sec {
5812                return false;
5813            }
5814            if policy.use_nanosec && ctime_nsec != ie.ctime_nsec {
5815                return false;
5816            }
5817        }
5818    }
5819    true
5820}
5821
5822/// Refresh cached stat data for stage-0 file/symlink entries whose worktree content still matches
5823/// the recorded OID but whose on-disk stat went stale.
5824///
5825/// This mirrors Git's `refresh_index` / `refresh_cache_ent`: an entry is only marked clean (stat
5826/// adopted from the worktree) after its content is re-verified against the index OID. A genuinely
5827/// modified entry keeps its stale stat so `diff-files` / `status` continue to report it. Operations
5828/// that rewrite the worktree (`status`, `reset --mixed`, `stash`) call this before writing the
5829/// index so a subsequent `git diff-files` sees refreshed entries as clean.
5830///
5831/// Gitlinks, sparse (`skip_worktree`), `assume_unchanged` and intent-to-add entries are skipped.
5832/// The blob comparison is a raw-content hash, so a CRLF-smudged match is conservatively missed
5833/// (the entry simply stays stat-dirty and is re-hashed next time — never the reverse).
5834///
5835/// `index_mtime` is the on-disk index file's `(mtime_sec, mtime_nsec)` (see
5836/// `entry_is_racy` / Git `is_racy_timestamp`); pass `None` when unknown — racy detection is
5837/// then skipped, which is conservative for tree-built indexes whose zeroed stat never matches.
5838///
5839/// `stat_parallel_threads` overrides parallel stat workers (`Some(1)` forces serial); when `None`,
5840/// honors `core.preloadIndex` and [`crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES`].
5841///
5842/// Returns `Ok(true)` when at least one entry was refreshed or invalidated, so callers can write
5843/// the index opportunistically (Git only persists a refresh that changed something).
5844pub fn refresh_index_stat_content_verified(
5845    odb: &Odb,
5846    git_dir: &Path,
5847    index: &mut Index,
5848    work_tree: &Path,
5849    index_mtime: Option<(u32, u32)>,
5850    config: Option<&ConfigSet>,
5851    _stat_parallel_threads: Option<usize>,
5852) -> Result<bool> {
5853    let _ = _stat_parallel_threads;
5854    refresh_index_stat_content_verified_with_rules(
5855        odb,
5856        git_dir,
5857        index,
5858        work_tree,
5859        index_mtime,
5860        config,
5861        _stat_parallel_threads,
5862        None,
5863    )
5864}
5865
5866/// Like [`refresh_index_stat_content_verified`], resolving attributes and conversion through a
5867/// shared [`crate::worktree_rules::WorktreeRules`] when one is supplied (its config snapshot then
5868/// takes precedence over `config`).
5869///
5870/// # Errors
5871///
5872/// Propagates I/O errors from the directory-grouped worktree scan.
5873#[allow(clippy::too_many_arguments)]
5874pub fn refresh_index_stat_content_verified_with_rules(
5875    odb: &Odb,
5876    git_dir: &Path,
5877    index: &mut Index,
5878    work_tree: &Path,
5879    index_mtime: Option<(u32, u32)>,
5880    config: Option<&ConfigSet>,
5881    stat_parallel_threads: Option<usize>,
5882    worktree_rules: Option<&std::sync::Arc<std::sync::Mutex<crate::worktree_rules::WorktreeRules>>>,
5883) -> Result<bool> {
5884    use crate::config::ConfigSet;
5885    use crate::crlf;
5886    use crate::worktree_scan::{
5887        collect_blob_lookup_batches, group_blob_entries_by_dir, visit_blob_lookup_batches,
5888        BlobDiskLookup, BlobScanSparseMode, WorktreeBlobScanOptions,
5889    };
5890
5891    let index_mtime = index_mtime_for_diff(index, index_mtime);
5892    let rules_lock = worktree_rules
5893        .map(|shared| {
5894            shared
5895                .lock()
5896                .map_err(|e| Error::Message(format!("worktree rules lock poisoned: {e}")))
5897        })
5898        .transpose()?;
5899    let config = rules_lock
5900        .as_ref()
5901        .map(|r| r.config().clone())
5902        .unwrap_or_else(|| {
5903            config.cloned().unwrap_or_else(|| {
5904                ConfigSet::load(
5905                    &crate::environment::Environment::empty(),
5906                    Some(git_dir),
5907                    true,
5908                )
5909                .unwrap_or_default()
5910            })
5911        });
5912    let preload_index = config
5913        .get_bool("core.preloadIndex")
5914        .and_then(|r| r.ok())
5915        .unwrap_or(true);
5916    let blob_scan = WorktreeBlobScanOptions {
5917        preload_index,
5918        stat_parallel_threads,
5919    };
5920    let conv = rules_lock
5921        .as_ref()
5922        .map(|r| r.conversion().clone())
5923        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
5924    let legacy_attrs = if rules_lock.is_none() {
5925        Some(crlf::load_gitattributes_parsed(work_tree))
5926    } else {
5927        None
5928    };
5929    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
5930        if let Some(rules) = rules_lock.as_ref() {
5931            rules.file_attrs(path, false)
5932        } else if let Some(parsed) = legacy_attrs.as_ref() {
5933            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
5934        } else {
5935            crlf::FileAttrs::default()
5936        }
5937    };
5938    let precompose_unicode = config
5939        .get_bool("core.precomposeunicode")
5940        .and_then(|r| r.ok())
5941        .unwrap_or(false);
5942    let ignorecase = config
5943        .get_bool("core.ignorecase")
5944        .and_then(|r| r.ok())
5945        .unwrap_or(false);
5946    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
5947
5948    let blob_dirs = group_blob_entries_by_dir(index, false);
5949    let work_tree_owned = work_tree.to_path_buf();
5950    let dir_abs = move |dir: &[u8]| {
5951        if dir.is_empty() {
5952            work_tree_owned.clone()
5953        } else {
5954            index_entry_worktree_abs_bytes(&work_tree_owned, dir, precompose_unicode, ignorecase)
5955        }
5956    };
5957    let work_tree_for_files = work_tree.to_path_buf();
5958    let file_abs = move |rel: &[u8]| {
5959        index_entry_worktree_abs_bytes(&work_tree_for_files, rel, precompose_unicode, ignorecase)
5960    };
5961    let dir_symlinks = SymlinkDirCache::default();
5962    let mut changed = false;
5963    let filter_fp = rules_lock.as_ref().map(|r| r.filter_process());
5964    let thread_policy = crate::parallel::ThreadPolicy::default();
5965    // Symlink ancestor detection must run even when core.symlinks is false.
5966    let refresh_batches = collect_blob_lookup_batches(
5967        index,
5968        &blob_dirs,
5969        blob_scan,
5970        &thread_policy,
5971        &config,
5972        dir_abs,
5973        file_abs,
5974        |rel_path| {
5975            dir_symlinks.has_symlink_in_path_bytes(
5976                work_tree,
5977                rel_path,
5978                precompose_unicode,
5979                ignorecase,
5980            )
5981        },
5982        BlobScanSparseMode::StatRefresh {
5983            stat_policy,
5984            index_mtime,
5985        },
5986    )?;
5987    let par_opts = refresh_hash_parallel_options(&thread_policy, &config, stat_parallel_threads);
5988    let refresh_checks = refresh_content_checks(
5989        odb,
5990        index,
5991        &refresh_batches,
5992        index_mtime,
5993        stat_policy,
5994        &|_, _| true,
5995        &WorktreeHashContext {
5996            work_tree,
5997            precompose_unicode,
5998            ignorecase,
5999            conv: &conv,
6000            file_attrs_for: &file_attrs_for,
6001            filter_process: filter_fp,
6002        },
6003        par_opts,
6004    );
6005    visit_blob_lookup_batches(refresh_batches, |entry_index, lookup| {
6006        let BlobDiskLookup::Present(stat) = lookup else {
6007            return Ok(());
6008        };
6009        let refresh = refresh_index_blob_stat_from_scan(
6010            &mut index.entries[entry_index],
6011            &stat,
6012            index_mtime,
6013            stat_policy,
6014            refresh_checks[entry_index].unwrap_or(false),
6015        );
6016        if refresh.index_changed {
6017            changed = true;
6018        }
6019        if refresh.content_verified {
6020            let path = index.entries[entry_index].path.clone();
6021            let oid = index.entries[entry_index].oid;
6022            index.mark_content_verified(&path, oid);
6023        }
6024        Ok(())
6025    })?;
6026    Ok(changed)
6027}
6028
6029/// Result of refreshing one index entry's cached stat during a worktree scan.
6030struct BlobStatRefreshOutcome {
6031    /// Whether the in-memory index entry was updated (stat fields or invalidation).
6032    index_changed: bool,
6033    /// Blob content was read and matches the recorded OID (skip a second hash in the same pass).
6034    content_verified: bool,
6035}
6036
6037/// Whether the stat refresh may adopt worktree stat data for `ie` at all.
6038fn refresh_index_entry_eligible(ie: &IndexEntry) -> bool {
6039    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR, MODE_SYMLINK};
6040    ie.stage() == 0
6041        && !ie.skip_worktree()
6042        && !ie.assume_unchanged()
6043        && !ie.intent_to_add()
6044        && matches!(ie.mode, MODE_REGULAR | MODE_EXECUTABLE | MODE_SYMLINK)
6045}
6046
6047/// Whether refreshing `ie` from the scanned `stat` must first prove by content that the
6048/// worktree still holds `ie.oid`: the stat data changed, or it matches but is racy.
6049fn refresh_needs_content_check(
6050    ie: &IndexEntry,
6051    stat: &crate::worktree_scan::BlobDiskStat,
6052    index_mtime: Option<(u32, u32)>,
6053    stat_policy: crate::index::StatMatchPolicy,
6054) -> bool {
6055    refresh_index_entry_eligible(ie)
6056        && !stat.is_dir
6057        && (!stat_matches_blob_stat(ie, stat, stat_policy)
6058            || entry_is_racy_with_policy(ie, index_mtime, stat_policy))
6059}
6060
6061/// Whether a worktree diff with `refresh_index_stat_in_pass` refreshes `ie` from `stat`.
6062///
6063/// Status leaves out entries whose stat data already decides the answer: clean and not racy,
6064/// or a changed size (reported as modified without reading the file).
6065pub(crate) fn worktree_diff_refreshes_entry(
6066    ie: &IndexEntry,
6067    stat: &crate::worktree_scan::BlobDiskStat,
6068    index_mtime: Option<(u32, u32)>,
6069    stat_policy: crate::index::StatMatchPolicy,
6070    for_status: bool,
6071) -> bool {
6072    if !for_status {
6073        return true;
6074    }
6075    if stat_matches_blob_stat(ie, stat, stat_policy) {
6076        return entry_is_racy_with_policy(ie, index_mtime, stat_policy);
6077    }
6078    stat.worktree_size() == ie.size || ie.stat_cache_unset()
6079}
6080
6081/// Pool options for the stat refresh's content checks.
6082///
6083/// `thread_policy` and config pick the worker count; `stat_parallel_threads` caps it the same
6084/// way it caps the stat scan (`Some(0)` or `Some(1)` keeps hashing on the calling thread).
6085/// There is no byte threshold: opening each file, not hashing its bytes, dominates for the
6086/// small files a checkout leaves racy.
6087fn refresh_hash_parallel_options(
6088    thread_policy: &crate::parallel::ThreadPolicy,
6089    config: &ConfigSet,
6090    stat_parallel_threads: Option<usize>,
6091) -> crate::parallel::ParallelOptions {
6092    let policy = crate::parallel::ThreadPolicy {
6093        min_items_override: thread_policy.min_items_override.or(Some(0)),
6094        min_total_bytes_override: thread_policy.min_total_bytes_override.or(Some(0)),
6095        ..*thread_policy
6096    };
6097    let mut par_opts = policy.resolve(crate::parallel::ParallelOp::IndexRefresh, config);
6098    if let Some(cap) = stat_parallel_threads {
6099        par_opts.threads = par_opts
6100            .threads
6101            .min(std::num::NonZeroUsize::new(cap).unwrap_or(std::num::NonZeroUsize::MIN));
6102    }
6103    par_opts.min_items = 0;
6104    par_opts.min_total_bytes = None;
6105    par_opts
6106}
6107
6108/// Worktree layout and attribute context for hashing index entries during a scan.
6109struct WorktreeHashContext<'a> {
6110    work_tree: &'a Path,
6111    precompose_unicode: bool,
6112    ignorecase: bool,
6113    conv: &'a crate::crlf::ConversionConfig,
6114    file_attrs_for: &'a dyn Fn(&str) -> crate::crlf::FileAttrs,
6115    filter_process: Option<&'a crate::filter_process::FilterProcessState>,
6116}
6117
6118/// Run every content check the stat refresh needs before the serial visit of `batches`.
6119///
6120/// Racily clean entries are the common case right after a checkout writes the index: every
6121/// file then shares the index's timestamp second. Hashing them here, in parallel when
6122/// `par_opts` and the total size allow it, keeps that first scan from reading files one by one.
6123///
6124/// Returns one slot per index entry: `Some(matches)` for scanned entries that `include` admits
6125/// and [`refresh_needs_content_check`] selects, `None` elsewhere. Read and filter failures count
6126/// as a mismatch, so those entries stay unverified.
6127#[allow(clippy::too_many_arguments)]
6128fn refresh_content_checks(
6129    odb: &Odb,
6130    index: &Index,
6131    batches: &[Vec<(usize, crate::worktree_scan::BlobDiskLookup)>],
6132    index_mtime: Option<(u32, u32)>,
6133    stat_policy: crate::index::StatMatchPolicy,
6134    include: &dyn Fn(&IndexEntry, &crate::worktree_scan::BlobDiskStat) -> bool,
6135    ctx: &WorktreeHashContext<'_>,
6136    par_opts: crate::parallel::ParallelOptions,
6137) -> Vec<Option<bool>> {
6138    use crate::worktree_scan::BlobDiskLookup;
6139
6140    /// Attributes are resolved on the calling thread: the shared rules are not `Sync`.
6141    struct ContentCheck {
6142        entry_index: usize,
6143        worktree_mode: u32,
6144        file_attrs: crate::crlf::FileAttrs,
6145    }
6146
6147    let mut jobs = Vec::new();
6148    let mut total_bytes = 0usize;
6149    for (entry_index, lookup) in batches.iter().flatten() {
6150        let BlobDiskLookup::Present(stat) = lookup else {
6151            continue;
6152        };
6153        let ie = &index.entries[*entry_index];
6154        if !refresh_needs_content_check(ie, stat, index_mtime, stat_policy) || !include(ie, stat) {
6155            continue;
6156        }
6157        total_bytes = total_bytes.saturating_add(ie.size as usize);
6158        jobs.push(ContentCheck {
6159            entry_index: *entry_index,
6160            worktree_mode: stat.git_mode,
6161            file_attrs: (ctx.file_attrs_for)(&crate::repo_relpath::storage_string(&ie.path)),
6162        });
6163    }
6164    let mut checks = vec![None; index.entries.len()];
6165    if jobs.is_empty() {
6166        return checks;
6167    }
6168    let (work_tree, precompose_unicode, ignorecase) =
6169        (ctx.work_tree, ctx.precompose_unicode, ctx.ignorecase);
6170    let (conv, filter_process) = (ctx.conv, ctx.filter_process);
6171    let entries = index.entries.as_slice();
6172    let matches = if par_opts.threads.get() > 1 && jobs.len() > 1 {
6173        crate::parallel::try_par_map_force::<_, _, std::convert::Infallible, _>(
6174            &jobs,
6175            par_opts,
6176            |job| {
6177                let ie = &entries[job.entry_index];
6178                let file_path = index_entry_worktree_abs_bytes(
6179                    work_tree,
6180                    &ie.path,
6181                    precompose_unicode,
6182                    ignorecase,
6183                );
6184                Ok(hash_worktree_file_with_mode(
6185                    odb,
6186                    &file_path,
6187                    job.worktree_mode,
6188                    conv,
6189                    &job.file_attrs,
6190                    &crate::repo_relpath::storage_string(&ie.path),
6191                    Some(ie),
6192                    filter_process,
6193                )
6194                .is_ok_and(|oid| oid == ie.oid))
6195            },
6196        )
6197        .unwrap_or_else(|e| match e {
6198            crate::parallel::ParallelHashError::Task(infallible) => match infallible {},
6199        })
6200    } else {
6201        jobs.iter()
6202            .map(|job| {
6203                let ie = &entries[job.entry_index];
6204                let file_path = index_entry_worktree_abs_bytes(
6205                    work_tree,
6206                    &ie.path,
6207                    precompose_unicode,
6208                    ignorecase,
6209                );
6210                hash_worktree_file_with_mode(
6211                    odb,
6212                    &file_path,
6213                    job.worktree_mode,
6214                    conv,
6215                    &job.file_attrs,
6216                    &crate::repo_relpath::storage_string(&ie.path),
6217                    Some(ie),
6218                    filter_process,
6219                )
6220                .is_ok_and(|oid| oid == ie.oid)
6221            })
6222            .collect()
6223    };
6224    for (job, matched) in jobs.iter().zip(matches) {
6225        checks[job.entry_index] = Some(matched);
6226    }
6227    checks
6228}
6229
6230/// Adopt the scanned `stat` for `ie` where that cannot hide a worktree change.
6231///
6232/// `content_matches` is the [`refresh_content_checks`] result for this entry; it is only
6233/// consulted when [`refresh_needs_content_check`] holds. A racy entry whose content no longer
6234/// matches has its stat cache invalidated so later scans re-hash it.
6235fn refresh_index_blob_stat_from_scan(
6236    ie: &mut IndexEntry,
6237    stat: &crate::worktree_scan::BlobDiskStat,
6238    index_mtime: Option<(u32, u32)>,
6239    stat_policy: crate::index::StatMatchPolicy,
6240    content_matches: bool,
6241) -> BlobStatRefreshOutcome {
6242    const UNCHANGED: BlobStatRefreshOutcome = BlobStatRefreshOutcome {
6243        index_changed: false,
6244        content_verified: false,
6245    };
6246    if !refresh_index_entry_eligible(ie) || stat.is_dir {
6247        return UNCHANGED;
6248    }
6249    let cached = crate::index::IndexStatFields::of_entry(ie);
6250    let stat_same = cached.equivalent_to(&stat.fields, stat_policy);
6251    let stat_matches = stat_matches_blob_stat(ie, stat, stat_policy);
6252    if !stat_matches || entry_is_racy_with_policy(ie, index_mtime, stat_policy) {
6253        if !content_matches {
6254            if stat_matches {
6255                invalidate_index_stat_cache(ie);
6256                return BlobStatRefreshOutcome {
6257                    index_changed: true,
6258                    content_verified: false,
6259                };
6260            }
6261            return UNCHANGED;
6262        }
6263        if !stat_same {
6264            stat.fields.store_in(ie);
6265        }
6266        return BlobStatRefreshOutcome {
6267            index_changed: !stat_same,
6268            content_verified: true,
6269        };
6270    }
6271    if stat_same {
6272        return BlobStatRefreshOutcome {
6273            index_changed: false,
6274            content_verified: true,
6275        };
6276    }
6277    stat.fields.store_in(ie);
6278    BlobStatRefreshOutcome {
6279        index_changed: true,
6280        content_verified: false,
6281    }
6282}
6283
6284/// Whether path-only checkout can skip writing a blob because the worktree is up to date.
6285///
6286/// Git's `checkout_entry` trusts matching stat data via `ie_match_stat` unless the entry is
6287/// racy relative to the on-disk index mtime, in which case blob content must match the expected
6288/// OID using the repository hash algorithm and clean/filter conversion ([`hash_worktree_file`]).
6289///
6290/// Returns `Ok(true)` when the caller should skip rewriting the path.
6291///
6292/// # Errors
6293///
6294/// Propagates I/O or hashing failures from [`hash_worktree_file`].
6295#[allow(clippy::too_many_arguments)] // mirrors Git `checkout_entry` stat/OID inputs
6296pub fn path_checkout_skip_blob_write_when_up_to_date(
6297    odb: &Odb,
6298    git_dir: &Path,
6299    work_tree: &Path,
6300    index: &Index,
6301    entry: &IndexEntry,
6302    expected_oid: &ObjectId,
6303    expected_mode: u32,
6304    rel_path: &str,
6305    filter_process: Option<&crate::filter_process::FilterProcessState>,
6306) -> Result<bool> {
6307    path_checkout_skip_blob_write_when_up_to_date_with_config(
6308        odb,
6309        git_dir,
6310        work_tree,
6311        index,
6312        entry,
6313        expected_oid,
6314        expected_mode,
6315        rel_path,
6316        filter_process,
6317        None,
6318    )
6319}
6320
6321/// Like [`path_checkout_skip_blob_write_when_up_to_date`], with an optional preloaded config.
6322#[allow(clippy::too_many_arguments)]
6323pub(crate) fn path_checkout_skip_blob_write_when_up_to_date_with_config(
6324    odb: &Odb,
6325    git_dir: &Path,
6326    work_tree: &Path,
6327    index: &Index,
6328    entry: &IndexEntry,
6329    expected_oid: &ObjectId,
6330    expected_mode: u32,
6331    rel_path: &str,
6332    filter_process: Option<&crate::filter_process::FilterProcessState>,
6333    preloaded_config: Option<&crate::config::ConfigSet>,
6334) -> Result<bool> {
6335    use crate::config::ConfigSet;
6336    use crate::crlf;
6337    use crate::index::{MODE_EXECUTABLE, MODE_REGULAR};
6338
6339    if entry.oid != *expected_oid || entry.mode != expected_mode {
6340        return Ok(false);
6341    }
6342    if expected_mode != MODE_REGULAR && expected_mode != MODE_EXECUTABLE {
6343        return Ok(false);
6344    }
6345    let abs_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
6346    let meta = match fs::symlink_metadata(&abs_path) {
6347        Ok(m) => m,
6348        Err(_) => return Ok(false),
6349    };
6350    if !meta.is_file() || !stat_matches(entry, &meta) {
6351        return Ok(false);
6352    }
6353    let owned_config;
6354    let config = match preloaded_config {
6355        Some(cfg) => cfg,
6356        None => {
6357            owned_config = ConfigSet::load(
6358                &crate::environment::Environment::empty(),
6359                Some(git_dir),
6360                true,
6361            )
6362            .unwrap_or_default();
6363            &owned_config
6364        }
6365    };
6366    if !crate::index::WorktreeModePolicy::from_config(config)
6367        .worktree_mode_matches(mode_from_metadata(&meta), expected_mode)
6368    {
6369        return Ok(false);
6370    }
6371    let index_mtime = index_mtime_for_diff(index, None);
6372    if !entry_is_racy(entry, index_mtime) {
6373        return Ok(true);
6374    }
6375    let conv = crlf::ConversionConfig::from_config(config);
6376    let attrs = crlf::load_gitattributes(work_tree);
6377    let file_attrs = crlf::get_file_attrs(&attrs, rel_path, false, config);
6378    let wt_oid = hash_worktree_file(
6379        odb,
6380        &abs_path,
6381        &meta,
6382        &conv,
6383        &file_attrs,
6384        rel_path,
6385        Some(entry),
6386        filter_process,
6387    )?;
6388    Ok(wt_oid == *expected_oid)
6389}
6390
6391/// Clear cached stat fields so the next diff/status pass re-reads the work tree.
6392fn invalidate_index_stat_cache(ie: &mut IndexEntry) {
6393    ie.ctime_sec = 0;
6394    ie.ctime_nsec = 0;
6395    ie.mtime_sec = 0;
6396    ie.mtime_nsec = 0;
6397    ie.dev = 0;
6398    ie.ino = 0;
6399    ie.size = 0;
6400}
6401
6402/// Read worktree content for diffing, honoring Git tree modes.
6403///
6404/// For [`MODE_SYMLINK`] entries backed by a symlink on disk this returns the link target path
6405/// bytes (via [`read_link`](std::fs::read_link)), not the contents of the followed file. A
6406/// symlink entry checked out as a plain file (`core.symlinks=false`) already holds the target,
6407/// so it is read like other modes, with a plain [`read`](std::fs::read) of the path.
6408///
6409/// # Errors
6410///
6411/// Returns [`std::io::Error`] when the path cannot be read.
6412pub fn read_worktree_bytes_for_mode(path: &Path, mode: u32) -> std::io::Result<Vec<u8>> {
6413    if mode == MODE_SYMLINK && fs::symlink_metadata(path)?.file_type().is_symlink() {
6414        let target = fs::read_link(path)?;
6415        return Ok(symlink_target_bytes(&target));
6416    }
6417    fs::read(path)
6418}
6419
6420/// Read worktree file bytes in Git (repository) form for line diffs against index or tree blobs.
6421///
6422/// Applies the same `.gitattributes`, filter, and line-ending rules as
6423/// [`diff_index_to_worktree`]. Pass the stage-0 [`IndexEntry`] when available so
6424/// `autocrlf` index-guard normalization matches status and raw diff collection.
6425///
6426/// # Errors
6427///
6428/// Returns I/O errors from the worktree path or [`Error`] from filter conversion.
6429pub fn read_worktree_side_bytes_for_diff(
6430    odb: &Odb,
6431    work_tree: &Path,
6432    rel_path: &str,
6433    mode: u32,
6434    config: &crate::config::ConfigSet,
6435    index_entry: Option<&IndexEntry>,
6436    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
6437) -> Result<Vec<u8>> {
6438    let file_path = crate::repo_relpath::worktree_abs(work_tree, rel_path);
6439    let rel_match = crate::repo_relpath::as_utf8_for_matching(rel_path);
6440    let meta = fs::symlink_metadata(&file_path).map_err(|source| {
6441        crate::worktree::map_worktree_io(
6442            rel_match.as_ref(),
6443            crate::worktree::WorktreePathOperation::Metadata,
6444            source,
6445        )
6446    })?;
6447    if mode == MODE_SYMLINK && meta.file_type().is_symlink() {
6448        let target = fs::read_link(&file_path).map_err(|source| {
6449            crate::worktree::map_worktree_io(
6450                rel_match.as_ref(),
6451                crate::worktree::WorktreePathOperation::Read,
6452                source,
6453            )
6454        })?;
6455        return Ok(symlink_target_bytes(&target));
6456    }
6457    if let Some(rules) = worktree_rules {
6458        return worktree_blob_bytes(
6459            odb,
6460            &file_path,
6461            &meta,
6462            rules.conversion(),
6463            &rules.file_attrs(rel_match.as_ref(), meta.is_dir()),
6464            rel_match.as_ref(),
6465            index_entry,
6466            Some(rules.filter_process()),
6467        );
6468    }
6469    let conv = crate::crlf::ConversionConfig::from_config(config);
6470    let attrs = crate::crlf::load_gitattributes(work_tree);
6471    let file_attrs = crate::crlf::get_file_attrs(&attrs, rel_match.as_ref(), meta.is_dir(), config);
6472    worktree_blob_bytes(
6473        odb,
6474        &file_path,
6475        &meta,
6476        &conv,
6477        &file_attrs,
6478        rel_match.as_ref(),
6479        index_entry,
6480        None,
6481    )
6482}
6483
6484/// Symlink target as the byte string Git hashes for the blob OID.
6485///
6486/// On Unix the raw `OsStr` bytes are used verbatim. On Windows `OsStr` is WTF-8
6487/// and has no stable byte view, so the lossy UTF-8 form is used (symlink targets
6488/// in Git trees are UTF-8 in practice).
6489pub(crate) fn symlink_target_bytes(target: &Path) -> Vec<u8> {
6490    #[cfg(unix)]
6491    {
6492        use std::os::unix::ffi::OsStrExt as _;
6493        target.as_os_str().as_bytes().to_vec()
6494    }
6495    #[cfg(not(unix))]
6496    {
6497        target.as_os_str().to_string_lossy().as_bytes().to_vec()
6498    }
6499}
6500
6501/// A worktree file's blob content: normalized bytes, or an open file that is hashed or
6502/// stored as a stream.
6503///
6504/// Regular files of at least the caller's size threshold whose bytes can be stored without clean
6505/// conversion ([`crate::crlf::worktree_allows_raw_byte_stream`], including binary files under
6506/// `core.autocrlf`) are streamed so memory use does not depend on the file size.
6507pub(crate) enum WorktreeBlobContent {
6508    /// Content after the clean conversion (filters, encoding, line endings).
6509    Bytes(Vec<u8>),
6510    /// Unconverted big file, positioned at its start.
6511    Stream {
6512        /// Open handle on the worktree file.
6513        file: fs::File,
6514        /// File size when it was opened.
6515        size: u64,
6516    },
6517}
6518
6519impl WorktreeBlobContent {
6520    /// Object id of the content, without storing it.
6521    pub(crate) fn hash(self, odb: &Odb) -> Result<ObjectId> {
6522        match self {
6523            Self::Bytes(data) => odb.hash(ObjectKind::Blob, &data),
6524            Self::Stream { mut file, size } => odb.hash_stream(ObjectKind::Blob, size, &mut file),
6525        }
6526    }
6527
6528    /// Store the content as a blob and return its id.
6529    pub(crate) fn store(self, odb: &Odb, options: crate::odb::WriteOptions) -> Result<ObjectId> {
6530        match self {
6531            Self::Bytes(data) => odb.write_with_options(ObjectKind::Blob, &data, options),
6532            Self::Stream { mut file, size } => {
6533                odb.write_stream(ObjectKind::Blob, size, &mut file, options)
6534            }
6535        }
6536    }
6537
6538    /// [`Self::store`] with `options` when given, otherwise [`Self::hash`].
6539    fn object_id(self, odb: &Odb, store: Option<crate::odb::WriteOptions>) -> Result<ObjectId> {
6540        match store {
6541            Some(options) => self.store(odb, options),
6542            None => self.hash(odb),
6543        }
6544    }
6545}
6546
6547/// Read and clean-convert an open regular worktree file of `size` bytes.
6548#[allow(clippy::too_many_arguments)]
6549fn read_regular_worktree_bytes(
6550    odb: &Odb,
6551    mut file: fs::File,
6552    size: u64,
6553    conv: &crate::crlf::ConversionConfig,
6554    file_attrs: &crate::crlf::FileAttrs,
6555    rel_path: &str,
6556    index_entry: Option<&IndexEntry>,
6557    filter_process: Option<&crate::filter_process::FilterProcessState>,
6558) -> Result<Vec<u8>> {
6559    let needs_index_blob =
6560        index_entry.is_some_and(|_| crate::crlf::clean_uses_autocrlf_index_guard(file_attrs, conv));
6561    let prior_blob: Option<Vec<u8>> = if needs_index_blob {
6562        index_entry
6563            .filter(|e| e.oid != zero_oid())
6564            .and_then(|e| odb.read(&e.oid).ok().map(|o| o.data))
6565    } else {
6566        None
6567    };
6568    let mut raw = Vec::with_capacity(usize::try_from(size).unwrap_or_default());
6569    std::io::Read::read_to_end(&mut file, &mut raw).map_err(|source| {
6570        crate::worktree::map_worktree_io(
6571            rel_path,
6572            crate::worktree::WorktreePathOperation::Read,
6573            source,
6574        )
6575    })?;
6576    #[cfg(test)]
6577    odb.hot_path_metrics().record_blob_content_read();
6578    let opts = crate::crlf::ConvertToGitOpts {
6579        index_blob: prior_blob.as_deref(),
6580        renormalize: false,
6581        check_safecrlf: false,
6582        filter_process,
6583        command_runner: None,
6584    };
6585    Ok(crate::crlf::convert_to_git_owned(
6586        raw, rel_path, conv, file_attrs, opts,
6587    )?)
6588}
6589
6590fn open_with_size(path: &Path, rel_path: &str) -> Result<(fs::File, u64)> {
6591    let file = fs::File::open(path).map_err(|source| {
6592        crate::worktree::map_worktree_io(
6593            rel_path,
6594            crate::worktree::WorktreePathOperation::Read,
6595            source,
6596        )
6597    })?;
6598    let size = file
6599        .metadata()
6600        .map_err(|source| {
6601            crate::worktree::map_worktree_io(
6602                rel_path,
6603                crate::worktree::WorktreePathOperation::Metadata,
6604                source,
6605            )
6606        })?
6607        .len();
6608    Ok((file, size))
6609}
6610
6611/// Open a regular worktree file and clean-convert it, or hand it out as a stream when it is
6612/// at least `stream_from` bytes and its clean conversion is the identity.
6613#[allow(clippy::too_many_arguments)]
6614fn read_regular_worktree_blob(
6615    odb: &Odb,
6616    path: &Path,
6617    conv: &crate::crlf::ConversionConfig,
6618    file_attrs: &crate::crlf::FileAttrs,
6619    rel_path: &str,
6620    index_entry: Option<&IndexEntry>,
6621    filter_process: Option<&crate::filter_process::FilterProcessState>,
6622    stream_from: u64,
6623) -> Result<WorktreeBlobContent> {
6624    let (mut file, size) = open_with_size(path, rel_path)?;
6625    if size >= stream_from
6626        && crate::crlf::worktree_allows_raw_byte_stream(
6627            conv,
6628            file_attrs,
6629            index_entry,
6630            &mut file,
6631            size,
6632        )?
6633    {
6634        #[cfg(test)]
6635        odb.hot_path_metrics().record_blob_content_read();
6636        return Ok(WorktreeBlobContent::Stream { file, size });
6637    }
6638    read_regular_worktree_bytes(
6639        odb,
6640        file,
6641        size,
6642        conv,
6643        file_attrs,
6644        rel_path,
6645        index_entry,
6646        filter_process,
6647    )
6648    .map(WorktreeBlobContent::Bytes)
6649}
6650
6651/// Blob content for a worktree path: symlink target, empty for a directory, or the
6652/// clean-converted (or streamed, see [`WorktreeBlobContent`]) file content.
6653#[allow(clippy::too_many_arguments)]
6654pub(crate) fn worktree_blob_content(
6655    odb: &Odb,
6656    path: &Path,
6657    meta: &fs::Metadata,
6658    conv: &crate::crlf::ConversionConfig,
6659    file_attrs: &crate::crlf::FileAttrs,
6660    rel_path: &str,
6661    index_entry: Option<&IndexEntry>,
6662    filter_process: Option<&crate::filter_process::FilterProcessState>,
6663) -> Result<WorktreeBlobContent> {
6664    worktree_blob_content_from(
6665        odb,
6666        path,
6667        meta,
6668        conv,
6669        file_attrs,
6670        rel_path,
6671        index_entry,
6672        filter_process,
6673        hash_stream_threshold(odb)?,
6674    )
6675}
6676
6677/// [`worktree_blob_content`], streaming unconverted regular files from `stream_from` bytes.
6678#[allow(clippy::too_many_arguments)]
6679fn worktree_blob_content_from(
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    stream_from: u64,
6689) -> Result<WorktreeBlobContent> {
6690    if meta.file_type().is_symlink() {
6691        let target = fs::read_link(path).map_err(|source| {
6692            crate::worktree::map_worktree_io(
6693                rel_path,
6694                crate::worktree::WorktreePathOperation::Read,
6695                source,
6696            )
6697        })?;
6698        return Ok(WorktreeBlobContent::Bytes(symlink_target_bytes(&target)));
6699    }
6700    if meta.is_dir() {
6701        return Ok(WorktreeBlobContent::Bytes(Vec::new()));
6702    }
6703    read_regular_worktree_blob(
6704        odb,
6705        path,
6706        conv,
6707        file_attrs,
6708        rel_path,
6709        index_entry,
6710        filter_process,
6711        stream_from,
6712    )
6713}
6714
6715/// Like [`worktree_blob_content`], always returning the bytes (for callers that need the
6716/// content itself, such as line diffs).
6717#[allow(clippy::too_many_arguments)]
6718pub(crate) fn worktree_blob_bytes(
6719    odb: &Odb,
6720    path: &Path,
6721    meta: &fs::Metadata,
6722    conv: &crate::crlf::ConversionConfig,
6723    file_attrs: &crate::crlf::FileAttrs,
6724    rel_path: &str,
6725    index_entry: Option<&IndexEntry>,
6726    filter_process: Option<&crate::filter_process::FilterProcessState>,
6727) -> Result<Vec<u8>> {
6728    if meta.file_type().is_symlink() {
6729        let target = fs::read_link(path).map_err(|source| {
6730            crate::worktree::map_worktree_io(
6731                rel_path,
6732                crate::worktree::WorktreePathOperation::Read,
6733                source,
6734            )
6735        })?;
6736        return Ok(symlink_target_bytes(&target));
6737    }
6738    if meta.is_dir() {
6739        return Ok(Vec::new());
6740    }
6741    let (file, size) = open_with_size(path, rel_path)?;
6742    read_regular_worktree_bytes(
6743        odb,
6744        file,
6745        size,
6746        conv,
6747        file_attrs,
6748        rel_path,
6749        index_entry,
6750        filter_process,
6751    )
6752}
6753
6754#[allow(clippy::too_many_arguments)]
6755fn worktree_file_oid(
6756    odb: &Odb,
6757    path: &Path,
6758    meta: &fs::Metadata,
6759    conv: &crate::crlf::ConversionConfig,
6760    file_attrs: &crate::crlf::FileAttrs,
6761    rel_path: &str,
6762    index_entry: Option<&IndexEntry>,
6763    materialize: bool,
6764    filter_process: Option<&crate::filter_process::FilterProcessState>,
6765) -> Result<ObjectId> {
6766    let stream_from = if materialize {
6767        odb.big_file_threshold()?
6768    } else {
6769        hash_stream_threshold(odb)?
6770    };
6771    worktree_blob_content_from(
6772        odb,
6773        path,
6774        meta,
6775        conv,
6776        file_attrs,
6777        rel_path,
6778        index_entry,
6779        filter_process,
6780        stream_from,
6781    )?
6782    .object_id(odb, materialize.then_some(crate::odb::BULK_NEW_LOOSE_WRITE))
6783}
6784
6785/// Hash normalized worktree bytes for a path (does not write to the object database).
6786///
6787/// Files that need no clean conversion are hashed as a stream, without reading them into
6788/// memory, from 1 MiB (or `core.bigFileThreshold` when that is smaller).
6789///
6790/// # Errors
6791///
6792/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
6793/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
6794/// stored or hashed unfiltered in that case.
6795#[allow(clippy::too_many_arguments)]
6796pub fn hash_worktree_file(
6797    odb: &Odb,
6798    path: &Path,
6799    meta: &fs::Metadata,
6800    conv: &crate::crlf::ConversionConfig,
6801    file_attrs: &crate::crlf::FileAttrs,
6802    rel_path: &str,
6803    index_entry: Option<&IndexEntry>,
6804    filter_process: Option<&crate::filter_process::FilterProcessState>,
6805) -> Result<ObjectId> {
6806    worktree_file_oid(
6807        odb,
6808        path,
6809        meta,
6810        conv,
6811        file_attrs,
6812        rel_path,
6813        index_entry,
6814        false,
6815        filter_process,
6816    )
6817}
6818
6819/// Work-tree files at least this large are hashed as a stream (when their content goes into
6820/// Git unchanged) even below `core.bigFileThreshold`, which only governs how blobs are stored.
6821const STREAMED_HASH_MIN_SIZE: u64 = 1024 * 1024;
6822
6823/// Size from which an unconverted work-tree file is hashed as a stream rather than read whole.
6824fn hash_stream_threshold(odb: &Odb) -> Result<u64> {
6825    Ok(odb.big_file_threshold()?.min(STREAMED_HASH_MIN_SIZE))
6826}
6827
6828/// Like [`hash_worktree_file`], for callers that already know the worktree file type from a
6829/// directory scan (`worktree_mode` is the Git mode derived from that stat), so no metadata
6830/// probe is needed.
6831#[allow(clippy::too_many_arguments)]
6832pub(crate) fn hash_worktree_file_with_mode(
6833    odb: &Odb,
6834    path: &Path,
6835    worktree_mode: u32,
6836    conv: &crate::crlf::ConversionConfig,
6837    file_attrs: &crate::crlf::FileAttrs,
6838    rel_path: &str,
6839    index_entry: Option<&IndexEntry>,
6840    filter_process: Option<&crate::filter_process::FilterProcessState>,
6841) -> Result<ObjectId> {
6842    if worktree_mode == MODE_SYMLINK {
6843        let data = read_worktree_bytes_for_mode(path, worktree_mode)?;
6844        return odb.hash(ObjectKind::Blob, &data);
6845    }
6846    read_regular_worktree_blob(
6847        odb,
6848        path,
6849        conv,
6850        file_attrs,
6851        rel_path,
6852        index_entry,
6853        filter_process,
6854        hash_stream_threshold(odb)?,
6855    )?
6856    .hash(odb)
6857}
6858
6859/// Read, normalize (CRLF/attributes), and store a worktree blob once.
6860///
6861/// # Errors
6862///
6863/// Returns [`Error::Io`] when the file cannot be read, and [`Error::Filter`] when a
6864/// `filter.<driver>.required` clean filter fails or cannot be started; content is never
6865/// stored or hashed unfiltered in that case.
6866#[allow(clippy::too_many_arguments)]
6867pub fn materialize_worktree_blob(
6868    odb: &Odb,
6869    path: &Path,
6870    meta: &fs::Metadata,
6871    conv: &crate::crlf::ConversionConfig,
6872    file_attrs: &crate::crlf::FileAttrs,
6873    rel_path: &str,
6874    index_entry: Option<&IndexEntry>,
6875    filter_process: Option<&crate::filter_process::FilterProcessState>,
6876) -> Result<ObjectId> {
6877    worktree_file_oid(
6878        odb,
6879        path,
6880        meta,
6881        conv,
6882        file_attrs,
6883        rel_path,
6884        index_entry,
6885        true,
6886        filter_process,
6887    )
6888}
6889
6890/// Derive a Git file mode from filesystem metadata.
6891pub fn mode_from_metadata(meta: &fs::Metadata) -> u32 {
6892    if meta.file_type().is_symlink() {
6893        0o120000
6894    } else {
6895        #[cfg(unix)]
6896        {
6897            if meta.mode() & 0o111 != 0 {
6898                return 0o100755;
6899            }
6900        }
6901        0o100644
6902    }
6903}
6904
6905/// Compare a tree against the working tree.
6906///
6907/// Shows changes from `tree_oid` to the current working directory state.
6908/// Files tracked in the index but not in the tree are shown as Added.
6909/// Files in the tree but missing from the working tree are shown as Deleted.
6910///
6911/// # Parameters
6912///
6913/// - `odb` — object database.
6914/// - `tree_oid` — the tree to compare against (`None` for empty tree).
6915/// - `work_tree` — path to the working tree root.
6916/// - `index` — current index (used to discover new tracked files not in tree).
6917///
6918/// # Errors
6919///
6920/// Returns errors from ODB reads or I/O.
6921pub fn diff_tree_to_worktree(
6922    odb: &Odb,
6923    tree_oid: Option<&ObjectId>,
6924    work_tree: &Path,
6925    index: &Index,
6926) -> Result<Vec<DiffEntry>> {
6927    diff_tree_to_worktree_with_git_dir(
6928        odb,
6929        tree_oid,
6930        work_tree,
6931        &work_tree.join(".git"),
6932        index,
6933        None,
6934    )
6935}
6936
6937/// Like [`diff_tree_to_worktree`], but loads config from `repository_git_dir` (linked worktrees / gitfiles).
6938///
6939/// Pass `config` when the caller already holds a repository config snapshot.
6940pub fn diff_tree_to_worktree_with_git_dir(
6941    odb: &Odb,
6942    tree_oid: Option<&ObjectId>,
6943    work_tree: &Path,
6944    repository_git_dir: &Path,
6945    index: &Index,
6946    config: Option<&ConfigSet>,
6947) -> Result<Vec<DiffEntry>> {
6948    diff_tree_to_worktree_with_git_dir_and_rules(
6949        odb,
6950        tree_oid,
6951        work_tree,
6952        repository_git_dir,
6953        index,
6954        config,
6955        None,
6956    )
6957}
6958
6959/// Like [`diff_tree_to_worktree_with_git_dir`], with optional shared [`crate::worktree_rules::WorktreeRules`].
6960pub fn diff_tree_to_worktree_with_git_dir_and_rules(
6961    odb: &Odb,
6962    tree_oid: Option<&ObjectId>,
6963    work_tree: &Path,
6964    repository_git_dir: &Path,
6965    index: &Index,
6966    config: Option<&ConfigSet>,
6967    worktree_rules: Option<&crate::worktree_rules::WorktreeRules>,
6968) -> Result<Vec<DiffEntry>> {
6969    use crate::config::ConfigSet;
6970    use crate::crlf;
6971
6972    if worktree_rules.is_none() {
6973        if let Some(tree_oid) = tree_oid {
6974            if index.cache_tree_root.as_ref() == Some(tree_oid)
6975                && crate::write_tree::cache_tree_fully_valid(odb, index.cache_tree.as_ref())
6976            {
6977                let mut idx = index.clone();
6978                let options = DiffIndexToWorktreeOptions {
6979                    repository_git_dir: Some(repository_git_dir.to_path_buf()),
6980                    refresh_index_stat_in_pass: false,
6981                    // Tree↔worktree raw lines use a null worktree OID when content differs; the
6982                    // status fast path (`for_status`) may report size-only drift with both sides
6983                    // set to the index blob (see `try_push_status_modified_on_size_mismatch`).
6984                    for_status: false,
6985                    config: config.cloned().map(std::sync::Arc::new),
6986                    ..DiffIndexToWorktreeOptions::default()
6987                };
6988                let (entries, _, _) = diff_index_to_worktree_inner(
6989                    odb,
6990                    &mut idx,
6991                    work_tree,
6992                    repository_git_dir,
6993                    options,
6994                    false,
6995                )?;
6996                return Ok(entries);
6997            }
6998        }
6999    }
7000
7001    let config = worktree_rules
7002        .map(|r| r.config().clone())
7003        .unwrap_or_else(|| {
7004            config.cloned().unwrap_or_else(|| {
7005                ConfigSet::load(
7006                    &crate::environment::Environment::empty(),
7007                    Some(repository_git_dir),
7008                    true,
7009                )
7010                .unwrap_or_else(|_| ConfigSet::new())
7011            })
7012        });
7013    let conv = worktree_rules
7014        .map(|r| r.conversion().clone())
7015        .unwrap_or_else(|| crlf::ConversionConfig::from_config(&config));
7016    let legacy_attrs = if worktree_rules.is_none() {
7017        Some(crlf::load_gitattributes_parsed(work_tree))
7018    } else {
7019        None
7020    };
7021    let file_attrs_for = |path: &str| -> crlf::FileAttrs {
7022        if let Some(rules) = worktree_rules {
7023            rules.file_attrs(path, false)
7024        } else if let Some(parsed) = legacy_attrs.as_ref() {
7025            crlf::file_attrs_from_gitattributes(&parsed.rules, &parsed.macros, path, false, &config)
7026        } else {
7027            crlf::FileAttrs::default()
7028        }
7029    };
7030    let filter_fp = worktree_rules.map(|r| r.filter_process());
7031
7032    // Flatten the tree into a BTreeMap keyed by path
7033    let tree_flat = match tree_oid {
7034        Some(oid) => flatten_tree(odb, oid, "")?,
7035        None => Vec::new(),
7036    };
7037    let tree_map: std::collections::BTreeMap<String, &FlatEntry> =
7038        tree_flat.iter().map(|e| (e.path.clone(), e)).collect();
7039
7040    // Build index lookup: path → &IndexEntry (stage 0 only)
7041    let mut index_entries: std::collections::BTreeMap<&[u8], &IndexEntry> =
7042        std::collections::BTreeMap::new();
7043    let mut index_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
7044    let mut stage0_paths: std::collections::BTreeSet<Vec<u8>> = std::collections::BTreeSet::new();
7045    for ie in &index.entries {
7046        if ie.stage() != 0 {
7047            continue;
7048        }
7049        let path = String::from_utf8_lossy(&ie.path).to_string();
7050        index_entries.insert(&ie.path, ie);
7051        index_paths.insert(path);
7052        stage0_paths.insert(ie.path.clone());
7053    }
7054
7055    // Paths with only unmerged stages (1–3) and no stage 0 — `git diff <rev>` must still list them
7056    // so combined `diff --cc` conflict hunks can be emitted (`t4108-apply-threeway`).
7057    let mut unmerged_only_paths: std::collections::BTreeSet<String> =
7058        std::collections::BTreeSet::new();
7059    for ie in &index.entries {
7060        if !(1..=3).contains(&ie.stage()) {
7061            continue;
7062        }
7063        if stage0_paths.contains(&ie.path) {
7064            continue;
7065        }
7066        unmerged_only_paths.insert(String::from_utf8_lossy(&ie.path).into_owned());
7067    }
7068
7069    // Union of tree paths + index paths
7070    let mut all_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
7071    all_paths.extend(tree_map.keys().cloned());
7072    all_paths.extend(index_paths.iter().cloned());
7073    all_paths.extend(unmerged_only_paths.iter().cloned());
7074
7075    let index_mtime = index_mtime_for_diff(index, None);
7076    let stat_policy = crate::index::StatMatchPolicy::from_config(Some(&config));
7077    let mode_policy = crate::index::WorktreeModePolicy::from_config(&config);
7078    let mut result = Vec::new();
7079
7080    for path in &all_paths {
7081        if index_entries
7082            .get(path.as_bytes())
7083            .is_some_and(|ie| ie.skip_worktree())
7084        {
7085            // Sparse checkout: `git diff <rev>` does not report tree↔worktree drift for
7086            // skip-worktree paths (they are outside the sparse cone). Matches t7012 stash flow.
7087            continue;
7088        }
7089        if index_entries
7090            .get(path.as_bytes())
7091            .is_some_and(|ie| ie.assume_unchanged())
7092        {
7093            continue;
7094        }
7095
7096        let tree_entry = tree_map.get(path.as_str());
7097
7098        // Gitlink entries (submodules) — compare HEAD commit, not file content.
7099        let is_gitlink = tree_entry.is_some_and(|te| te.mode == 0o160000)
7100            || index_entries
7101                .get(path.as_bytes())
7102                .is_some_and(|ie| ie.mode == 0o160000);
7103        if is_gitlink {
7104            let sub_dir = work_tree.join(path);
7105            let index_gitlink_oid = index_entries
7106                .get(path.as_bytes())
7107                .filter(|ie| ie.mode == 0o160000)
7108                .map(|ie| ie.oid);
7109            match (tree_entry, index_gitlink_oid) {
7110                (Some(te), _) => {
7111                    let sub_head = read_submodule_head_oid(&sub_dir);
7112                    // A gitlink whose worktree directory no longer exists is a deleted submodule:
7113                    // Git's `diff-lib.c` reports it as a deletion (status `D`, new mode 000000) and
7114                    // `--submodule` renders the `(submodule deleted)` summary (t4041 #46).
7115                    if sub_head.is_none() && !sub_dir.exists() {
7116                        result.push(DiffEntry {
7117                            status: DiffStatus::Deleted,
7118                            old_path: Some(path.clone()),
7119                            new_path: Some(path.clone()),
7120                            old_mode: format_mode(te.mode),
7121                            new_mode: "000000".to_string(),
7122                            old_oid: te.oid,
7123                            new_oid: zero_oid(),
7124                            score: None,
7125                        });
7126                        continue;
7127                    }
7128                    let ref_matches = if let Some(head) = sub_head {
7129                        head == te.oid
7130                    } else {
7131                        submodule_worktree_is_unpopulated_placeholder(&sub_dir)
7132                    };
7133                    let index_matches_tree = index_gitlink_oid.is_some_and(|oid| oid == te.oid);
7134                    // Default tree↔worktree diff matches Git: untracked-only paths inside a
7135                    // checked-out submodule do not make the gitlink differ (see `git diff <tree>`).
7136                    let sub_flags = submodule_porcelain_flags(work_tree, path, te.oid);
7137                    let dirty_while_aligned =
7138                        index_matches_tree && ref_matches && sub_flags.modified;
7139                    if !ref_matches || dirty_while_aligned {
7140                        // Raw `git diff <tree>` lines use a null OID on the worktree side when the
7141                        // checked-out submodule HEAD differs from the tree's gitlink; patch output
7142                        // still resolves the real commit from the submodule directory.
7143                        let new_oid = if !ref_matches {
7144                            sub_head.unwrap_or_else(zero_oid)
7145                        } else {
7146                            zero_oid()
7147                        };
7148                        result.push(DiffEntry {
7149                            status: DiffStatus::Modified,
7150                            old_path: Some(path.clone()),
7151                            new_path: Some(path.clone()),
7152                            old_mode: format_mode(te.mode),
7153                            new_mode: format_mode(te.mode),
7154                            old_oid: te.oid,
7155                            new_oid,
7156                            score: None,
7157                        });
7158                    }
7159                }
7160                (None, Some(idx_oid)) => {
7161                    // Gitlink staged in the index but absent from the tree: a new submodule. Git
7162                    // reports it as an addition (status `A`) with the index gitlink on the new side,
7163                    // so `--submodule` renders `(new submodule)` (t4041 #46). The patch renderer
7164                    // resolves the real commit from the index OID / submodule HEAD.
7165                    let new_oid = read_submodule_head_oid(&sub_dir).unwrap_or(idx_oid);
7166                    result.push(DiffEntry {
7167                        status: DiffStatus::Added,
7168                        old_path: Some(path.clone()),
7169                        new_path: Some(path.clone()),
7170                        old_mode: "000000".to_string(),
7171                        new_mode: format_mode(0o160000),
7172                        old_oid: zero_oid(),
7173                        new_oid,
7174                        score: None,
7175                    });
7176                }
7177                (None, None) => {}
7178            }
7179            continue;
7180        }
7181
7182        let file_path = work_tree.join(path);
7183
7184        let wt_meta = match fs::symlink_metadata(&file_path) {
7185            Ok(m) => Some(m),
7186            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
7187            Err(e) => return Err(Error::Io(e)),
7188        };
7189
7190        if unmerged_only_paths.contains(path) {
7191            if let (Some(te), Some(meta)) = (tree_entry, wt_meta.as_ref()) {
7192                let file_attrs = file_attrs_for(path);
7193                let wt_oid = hash_worktree_file(
7194                    odb,
7195                    &file_path,
7196                    meta,
7197                    &conv,
7198                    &file_attrs,
7199                    path,
7200                    None,
7201                    filter_fp,
7202                )?;
7203                let wt_mode =
7204                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(te.mode));
7205                if wt_oid != te.oid || wt_mode != te.mode {
7206                    result.push(DiffEntry {
7207                        status: DiffStatus::for_content_change(te.mode, wt_mode),
7208                        old_path: Some(path.clone()),
7209                        new_path: Some(path.clone()),
7210                        old_mode: format_mode(te.mode),
7211                        new_mode: format_mode(wt_mode),
7212                        old_oid: te.oid,
7213                        new_oid: wt_oid,
7214                        score: None,
7215                    });
7216                }
7217            }
7218            continue;
7219        }
7220
7221        match (tree_entry, wt_meta) {
7222            (Some(te), Some(ref meta)) => {
7223                let Some(ie) = index_entries.get(path.as_bytes()) else {
7224                    continue;
7225                };
7226                let wt_mode =
7227                    mode_policy.index_mode_for_worktree(mode_from_metadata(meta), Some(ie.mode));
7228
7229                let index_matches_tree = ie.oid == te.oid && ie.mode == te.mode;
7230
7231                // Fully clean: index matches `HEAD`, worktree matches index, stat cache fresh.
7232                let stat_trusted = stat_matches_with_policy(ie, meta, stat_policy)
7233                    && !entry_is_racy_with_policy(ie, index_mtime, stat_policy);
7234                if index_matches_tree && stat_trusted && wt_mode == te.mode {
7235                    continue;
7236                }
7237
7238                let file_attrs = file_attrs_for(path);
7239                let idx_ent = index_entries.get(path.as_bytes()).copied();
7240
7241                // Staged mode (same blob as `HEAD`, different mode recorded in the index) with
7242                // the worktree still matching the index. `core.filemode=false` does not discard
7243                // an explicit index chmod vs HEAD. Once the worktree drifts from the index, the
7244                // diff must describe the worktree side, so fall through.
7245                if ie.oid == te.oid && ie.mode != te.mode && stat_trusted && wt_mode == ie.mode {
7246                    result.push(DiffEntry {
7247                        status: DiffStatus::for_content_change(te.mode, ie.mode),
7248                        old_path: Some(path.clone()),
7249                        new_path: Some(path.clone()),
7250                        old_mode: format_mode(te.mode),
7251                        new_mode: format_mode(ie.mode),
7252                        old_oid: te.oid,
7253                        new_oid: te.oid,
7254                        score: None,
7255                    });
7256                    continue;
7257                }
7258
7259                // Index still matches `HEAD`: only unstaged worktree drift (content and/or
7260                // worktree-only exec bit when `update-index` was not run — t4049 harness).
7261                if index_matches_tree {
7262                    let wt_oid = hash_worktree_file(
7263                        odb,
7264                        &file_path,
7265                        meta,
7266                        &conv,
7267                        &file_attrs,
7268                        path,
7269                        None,
7270                        filter_fp,
7271                    )?;
7272                    if wt_oid != te.oid {
7273                        result.push(DiffEntry {
7274                            status: DiffStatus::for_content_change(te.mode, wt_mode),
7275                            old_path: Some(path.clone()),
7276                            new_path: Some(path.clone()),
7277                            old_mode: format_mode(te.mode),
7278                            new_mode: format_mode(wt_mode),
7279                            old_oid: te.oid,
7280                            // Raw `git diff <tree>` uses a null OID on the worktree side (patch
7281                            // output resolves content from the working tree).
7282                            new_oid: zero_oid(),
7283                            score: None,
7284                        });
7285                    } else if wt_mode != te.mode {
7286                        result.push(DiffEntry {
7287                            status: DiffStatus::for_content_change(te.mode, wt_mode),
7288                            old_path: Some(path.clone()),
7289                            new_path: Some(path.clone()),
7290                            old_mode: format_mode(te.mode),
7291                            new_mode: format_mode(wt_mode),
7292                            old_oid: te.oid,
7293                            new_oid: te.oid,
7294                            score: None,
7295                        });
7296                    }
7297                    continue;
7298                }
7299
7300                // Staged content (and possibly mode): `git diff <rev>` is tree vs working tree.
7301                let wt_oid = hash_worktree_file(
7302                    odb,
7303                    &file_path,
7304                    meta,
7305                    &conv,
7306                    &file_attrs,
7307                    path,
7308                    idx_ent,
7309                    filter_fp,
7310                )?;
7311                if wt_oid != te.oid || wt_mode != te.mode {
7312                    let new_oid = if wt_oid != te.oid { zero_oid() } else { te.oid };
7313                    result.push(DiffEntry {
7314                        status: DiffStatus::for_content_change(te.mode, wt_mode),
7315                        old_path: Some(path.clone()),
7316                        new_path: Some(path.clone()),
7317                        old_mode: format_mode(te.mode),
7318                        new_mode: format_mode(wt_mode),
7319                        old_oid: te.oid,
7320                        new_oid,
7321                        score: None,
7322                    });
7323                }
7324            }
7325            (Some(te), None) => {
7326                // In tree but missing from worktree
7327                result.push(DiffEntry {
7328                    status: DiffStatus::Deleted,
7329                    old_path: Some(path.clone()),
7330                    new_path: None,
7331                    old_mode: format_mode(te.mode),
7332                    new_mode: "000000".to_owned(),
7333                    old_oid: te.oid,
7334                    new_oid: zero_oid(),
7335                    score: None,
7336                });
7337            }
7338            (None, Some(ref meta)) => {
7339                // In index but not in tree, and exists in worktree
7340                let file_attrs = file_attrs_for(path);
7341                let idx_ent = index_entries.get(path.as_bytes()).copied();
7342                let wt_oid = materialize_worktree_blob(
7343                    odb,
7344                    &file_path,
7345                    meta,
7346                    &conv,
7347                    &file_attrs,
7348                    path,
7349                    idx_ent,
7350                    filter_fp,
7351                )?;
7352                let wt_mode = mode_from_metadata(meta);
7353                result.push(DiffEntry {
7354                    status: DiffStatus::Added,
7355                    old_path: None,
7356                    new_path: Some(path.clone()),
7357                    old_mode: "000000".to_owned(),
7358                    new_mode: format_mode(wt_mode),
7359                    old_oid: zero_oid(),
7360                    new_oid: wt_oid,
7361                    score: None,
7362                });
7363            }
7364            (None, None) => {
7365                // Tracked in index but neither in tree nor worktree — skip
7366            }
7367        }
7368    }
7369
7370    result.sort_by(|a, b| a.path().cmp(b.path()));
7371    Ok(result)
7372}
7373
7374// ── Rename detection ────────────────────────────────────────────────
7375
7376fn read_added_entry_bytes(
7377    odb: &Odb,
7378    entry: &DiffEntry,
7379    work_root: Option<&Path>,
7380) -> Option<Vec<u8>> {
7381    if entry.new_oid != zero_oid() {
7382        if let Ok(obj) = odb.read(&entry.new_oid) {
7383            return Some(obj.data);
7384        }
7385    }
7386    let path = entry.new_path.as_deref()?;
7387    let root = work_root?;
7388    fs::read(root.join(path)).ok()
7389}
7390
7391fn modified_as_copy_from_sources(
7392    odb: &Odb,
7393    work_root: Option<&Path>,
7394    e: &DiffEntry,
7395    threshold: u32,
7396    sources: &[(String, ObjectId, bool)],
7397    source_contents: &[Option<Vec<u8>>],
7398    source_tree_entries: &[(String, String, ObjectId)],
7399) -> Result<Option<DiffEntry>> {
7400    fn regular_file_mode(mode: &str) -> bool {
7401        mode == "100644" || mode == "100755"
7402    }
7403
7404    if e.status != DiffStatus::Modified || !regular_file_mode(&e.new_mode) {
7405        return Ok(None);
7406    }
7407    let Some(new_data) = read_added_entry_bytes(odb, e, work_root) else {
7408        return Ok(None);
7409    };
7410    let new_oid_eff = if e.new_oid != zero_oid() {
7411        e.new_oid
7412    } else {
7413        odb.hash(ObjectKind::Blob, &new_data)?
7414    };
7415
7416    let mut best: Option<(usize, u32)> = None;
7417    for (si, (src_path, src_oid, is_deleted)) in sources.iter().enumerate() {
7418        if *is_deleted {
7419            continue;
7420        }
7421        if e.new_path.as_deref() == Some(src_path.as_str()) {
7422            continue;
7423        }
7424        let src_mode_str = source_tree_entries
7425            .iter()
7426            .find(|(p, _, _)| p == src_path)
7427            .map(|(_, m, _)| m.as_str())
7428            .unwrap_or("100644");
7429        if !regular_file_mode(src_mode_str) {
7430            continue;
7431        }
7432
7433        let score = if *src_oid == new_oid_eff {
7434            100
7435        } else {
7436            match (&source_contents[si], Some(new_data.as_slice())) {
7437                (Some(old_data), Some(nd)) => compute_similarity(old_data, nd),
7438                _ => 0,
7439            }
7440        };
7441        if score >= threshold {
7442            let replace = match best {
7443                None => true,
7444                Some((_, s)) => score > s,
7445            };
7446            if replace {
7447                best = Some((si, score));
7448            }
7449        }
7450    }
7451
7452    let Some((si, score)) = best else {
7453        return Ok(None);
7454    };
7455    let (src_path, src_oid, _) = &sources[si];
7456    let src_mode = source_tree_entries
7457        .iter()
7458        .find(|(p, _, _)| p == src_path)
7459        .map(|(_, m, _)| m.clone())
7460        .unwrap_or_else(|| e.old_mode.clone());
7461
7462    Ok(Some(DiffEntry {
7463        status: DiffStatus::Copied,
7464        old_path: Some(src_path.clone()),
7465        new_path: e.new_path.clone(),
7466        old_mode: src_mode,
7467        new_mode: e.new_mode.clone(),
7468        old_oid: *src_oid,
7469        new_oid: e.new_oid,
7470        score: Some(score),
7471    }))
7472}
7473
7474/// Detect renames by pairing Deleted and Added entries with similar content.
7475///
7476/// `threshold` is the minimum similarity percentage (0–100) for a pair to
7477/// be considered a rename (Git's default is 50%).  The function reads blob
7478/// content from the ODB to compute a line-level similarity score.
7479///
7480/// Exact-OID matches are always 100% similar regardless of content.
7481///
7482/// When `work_root` is set, added entries whose `new_oid` is the zero placeholder (as in
7483/// uncached `diff-index` when the work tree diverged from the index) load content from disk
7484/// under that root instead of the object database.
7485#[allow(clippy::expect_used)]
7486pub fn detect_renames(
7487    odb: &Odb,
7488    work_root: Option<&Path>,
7489    entries: Vec<DiffEntry>,
7490    threshold: u32,
7491) -> Vec<DiffEntry> {
7492    let config = ConfigSet::new();
7493    let policy = ThreadPolicy::default();
7494    detect_renames_with_policy(odb, work_root, entries, threshold, &config, &policy, None)
7495        .expect("rename detection")
7496}
7497
7498#[inline]
7499fn poll_rename_cancel(
7500    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
7501) -> crate::error::Result<()> {
7502    if let Some(watch) = cancel_watch {
7503        crate::progress::poll_watch(watch)?;
7504    }
7505    Ok(())
7506}
7507
7508/// Like [`detect_renames`], but honors `index.threads` / `--threads` via `thread_policy`.
7509pub fn detect_renames_with_policy(
7510    odb: &Odb,
7511    work_root: Option<&Path>,
7512    entries: Vec<DiffEntry>,
7513    threshold: u32,
7514    config: &ConfigSet,
7515    thread_policy: &ThreadPolicy,
7516    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
7517) -> Result<Vec<DiffEntry>> {
7518    poll_rename_cancel(cancel_watch)?;
7519    // Split entries into deleted, added, and others.
7520    let mut deleted: Vec<DiffEntry> = Vec::new();
7521    let mut added: Vec<DiffEntry> = Vec::new();
7522    let mut others: Vec<DiffEntry> = Vec::new();
7523
7524    for entry in entries {
7525        match entry.status {
7526            DiffStatus::Deleted => deleted.push(entry),
7527            DiffStatus::Added => added.push(entry),
7528            _ => others.push(entry),
7529        }
7530    }
7531
7532    if deleted.is_empty() || added.is_empty() {
7533        // Nothing to pair — return original order.
7534        let mut result = others;
7535        result.extend(deleted);
7536        result.extend(added);
7537        if !entries_sorted_by_path(&result) {
7538            result.sort_by(|a, b| a.path().cmp(b.path()));
7539        }
7540        return Ok(result);
7541    }
7542
7543    fn is_regularish_mode(mode: &str) -> bool {
7544        mode == "100644" || mode == "100755"
7545    }
7546
7547    fn same_path_same_blob(del: &DiffEntry, add: &DiffEntry) -> bool {
7548        del.old_path == add.new_path && del.old_oid == add.new_oid && del.old_mode == add.new_mode
7549    }
7550
7551    fn sort_rename_scores(
7552        scores: &mut [(u32, usize, usize)],
7553        deleted: &[DiffEntry],
7554        added: &[DiffEntry],
7555    ) {
7556        scores.sort_by(|a, b| {
7557            let a_noop = same_path_same_blob(&deleted[a.1], &added[a.2]);
7558            let b_noop = same_path_same_blob(&deleted[b.1], &added[b.2]);
7559            let a_same = same_basename(&deleted[a.1], &added[a.2]);
7560            let b_same = same_basename(&deleted[b.1], &added[b.2]);
7561            a_noop
7562                .cmp(&b_noop)
7563                .then_with(|| b_same.cmp(&a_same))
7564                .then_with(|| b.0.cmp(&a.0))
7565        });
7566    }
7567
7568    fn apply_rename_scores(
7569        scores: &[(u32, usize, usize)],
7570        deleted: &[DiffEntry],
7571        added: &[DiffEntry],
7572        used_deleted: &mut [bool],
7573        used_added: &mut [bool],
7574        renames: &mut Vec<DiffEntry>,
7575    ) {
7576        for (score, di, ai) in scores {
7577            if used_deleted[*di] || used_added[*ai] {
7578                continue;
7579            }
7580            used_deleted[*di] = true;
7581            used_added[*ai] = true;
7582
7583            let del = &deleted[*di];
7584            let add = &added[*ai];
7585            if same_path_same_blob(del, add) {
7586                continue;
7587            }
7588
7589            renames.push(DiffEntry {
7590                status: DiffStatus::Renamed,
7591                old_path: del.old_path.clone(),
7592                new_path: add.new_path.clone(),
7593                old_mode: del.old_mode.clone(),
7594                new_mode: add.new_mode.clone(),
7595                old_oid: del.old_oid,
7596                new_oid: add.new_oid,
7597                score: Some(*score),
7598            });
7599        }
7600    }
7601
7602    let mut used_deleted = vec![false; deleted.len()];
7603    let mut used_added = vec![false; added.len()];
7604    let mut renames: Vec<DiffEntry> = Vec::new();
7605
7606    // Git always runs exact rename detection, regardless of renameLimit.
7607    let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
7608    for (di, del) in deleted.iter().enumerate() {
7609        poll_rename_cancel(cancel_watch)?;
7610        for (ai, add) in added.iter().enumerate() {
7611            if del.old_oid == add.new_oid {
7612                exact_scores.push((100, di, ai));
7613            }
7614        }
7615    }
7616    sort_rename_scores(&mut exact_scores, &deleted, &added);
7617    apply_rename_scores(
7618        &exact_scores,
7619        &deleted,
7620        &added,
7621        &mut used_deleted,
7622        &mut used_added,
7623        &mut renames,
7624    );
7625
7626    let remaining_deleted: Vec<usize> = used_deleted
7627        .iter()
7628        .enumerate()
7629        .filter_map(|(i, used)| (!*used).then_some(i))
7630        .collect();
7631    let remaining_added: Vec<usize> = used_added
7632        .iter()
7633        .enumerate()
7634        .filter_map(|(i, used)| (!*used).then_some(i))
7635        .collect();
7636
7637    let rename_limit = parse_rename_limit(config);
7638    if !remaining_deleted.is_empty()
7639        && !remaining_added.is_empty()
7640        && !inexact_matrix_exceeds_limit(
7641            remaining_added.len(),
7642            remaining_deleted.len(),
7643            rename_limit,
7644        )
7645    {
7646        let par_opts = diff_rename_parallel_options(config, thread_policy);
7647
7648        let deleted_sizes: Vec<Option<usize>> = remaining_deleted
7649            .iter()
7650            .map(|&di| blob_payload_size(odb, &deleted[di].old_oid))
7651            .collect();
7652        let added_sizes: Vec<Option<usize>> = remaining_added
7653            .iter()
7654            .map(|&ai| added_entry_payload_size(odb, &added[ai], work_root))
7655            .collect();
7656
7657        let mut load_deleted = vec![false; remaining_deleted.len()];
7658        let mut load_added = vec![false; remaining_added.len()];
7659        let mut candidate_pairs: Vec<(usize, usize)> = Vec::new();
7660        for (row, &di) in remaining_deleted.iter().enumerate() {
7661            poll_rename_cancel(cancel_watch)?;
7662            let del = &deleted[di];
7663            if !is_regularish_mode(&del.old_mode) {
7664                continue;
7665            }
7666            let Some(ds) = deleted_sizes[row] else {
7667                continue;
7668            };
7669            for (col, &ai) in remaining_added.iter().enumerate() {
7670                let add = &added[ai];
7671                if !is_regularish_mode(&add.new_mode) {
7672                    continue;
7673                }
7674                let Some(asz) = added_sizes[col] else {
7675                    continue;
7676                };
7677                if rename_pair_size_reachable(ds, asz, threshold) {
7678                    load_deleted[row] = true;
7679                    load_added[col] = true;
7680                    candidate_pairs.push((row, col));
7681                }
7682            }
7683        }
7684
7685        if !candidate_pairs.is_empty() {
7686            let loads_needed = load_deleted.iter().filter(|&&b| b).count()
7687                + load_added.iter().filter(|&&b| b).count();
7688            let load_deleted_blob = |i: usize| -> Option<Vec<u8>> {
7689                if !load_deleted[i] {
7690                    return None;
7691                }
7692                odb.read(&deleted[remaining_deleted[i]].old_oid)
7693                    .ok()
7694                    .map(|obj| normalize_bytes_for_rename(obj.data))
7695            };
7696            let load_added_blob = |i: usize| -> Option<Vec<u8>> {
7697                if !load_added[i] {
7698                    return None;
7699                }
7700                read_added_entry_bytes(odb, &added[remaining_added[i]], work_root)
7701                    .map(normalize_bytes_for_rename)
7702            };
7703            let (deleted_contents, added_contents) = if loads_needed <= 32 {
7704                (
7705                    (0..remaining_deleted.len())
7706                        .map(load_deleted_blob)
7707                        .collect(),
7708                    (0..remaining_added.len()).map(load_added_blob).collect(),
7709                )
7710            } else {
7711                (
7712                    parallel_load_optional_blobs(
7713                        odb,
7714                        remaining_deleted.len(),
7715                        par_opts,
7716                        0,
7717                        load_deleted_blob,
7718                    ),
7719                    parallel_load_optional_blobs(
7720                        odb,
7721                        remaining_added.len(),
7722                        par_opts,
7723                        0,
7724                        load_added_blob,
7725                    ),
7726                )
7727            };
7728
7729            let est_table_bytes: usize = deleted_contents
7730                .iter()
7731                .chain(added_contents.iter())
7732                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
7733                .sum();
7734            let build_deleted_table = |row: usize| -> std::result::Result<
7735                Option<RenameLineTable>,
7736                std::convert::Infallible,
7737            > {
7738                Ok(deleted_contents[row]
7739                    .as_ref()
7740                    .map(|d| RenameLineTable::from_normalized_blob(d)))
7741            };
7742            let build_added_table = |col: usize| -> std::result::Result<
7743                Option<RenameLineTable>,
7744                std::convert::Infallible,
7745            > {
7746                Ok(added_contents[col]
7747                    .as_ref()
7748                    .map(|d| RenameLineTable::from_normalized_blob(d)))
7749            };
7750            let deleted_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
7751                (0..remaining_deleted.len())
7752                    .map(|row| build_deleted_table(row).unwrap())
7753                    .collect()
7754            } else {
7755                parallel_map_indices(
7756                    odb,
7757                    remaining_deleted.len(),
7758                    par_opts,
7759                    est_table_bytes,
7760                    build_deleted_table,
7761                )
7762                .expect("deleted rename line tables")
7763            };
7764            let added_tables: Vec<Option<RenameLineTable>> = if loads_needed <= 32 {
7765                (0..remaining_added.len())
7766                    .map(|col| build_added_table(col).unwrap())
7767                    .collect()
7768            } else {
7769                parallel_map_indices(
7770                    odb,
7771                    remaining_added.len(),
7772                    par_opts,
7773                    est_table_bytes,
7774                    build_added_table,
7775                )
7776                .expect("added rename line tables")
7777            };
7778
7779            let mut inexact_scores: Vec<(u32, usize, usize)> = Vec::new();
7780            for (row, col) in candidate_pairs {
7781                poll_rename_cancel(cancel_watch)?;
7782                let di = remaining_deleted[row];
7783                let ai = remaining_added[col];
7784                let (Some(old_table), Some(new_table)) = (&deleted_tables[row], &added_tables[col])
7785                else {
7786                    continue;
7787                };
7788                let score = RenameLineTable::similarity_score(old_table, new_table);
7789                if score >= threshold {
7790                    inexact_scores.push((score, di, ai));
7791                }
7792            }
7793            sort_rename_scores(&mut inexact_scores, &deleted, &added);
7794            apply_rename_scores(
7795                &inexact_scores,
7796                &deleted,
7797                &added,
7798                &mut used_deleted,
7799                &mut used_added,
7800                &mut renames,
7801            );
7802        }
7803    }
7804
7805    // Collect unmatched entries.
7806    let mut result = others;
7807    result.extend(renames);
7808    for (i, entry) in deleted.into_iter().enumerate() {
7809        if !used_deleted[i] {
7810            result.push(entry);
7811        }
7812    }
7813    for (i, entry) in added.into_iter().enumerate() {
7814        if !used_added[i] {
7815            result.push(entry);
7816        }
7817    }
7818
7819    result = prune_break_deletions_obviated_by_renames(result);
7820    result.sort_by(|a, b| a.path().cmp(b.path()));
7821    Ok(result)
7822}
7823
7824/// Detect copies among diff entries.
7825///
7826/// This first runs rename detection (pairing Deleted+Added), then for any
7827/// remaining Added entries, looks for copy sources.
7828///
7829/// - `find_copies_harder` = false: only Modified entries are copy source candidates.
7830/// - `find_copies_harder` = true: also examine unmodified files from `source_tree_entries`.
7831///
7832/// `source_tree_entries` should be a list of (path, mode, oid) from the source tree;
7833/// used when `find_copies_harder` is true to consider unmodified files as copy sources.
7834/// # Errors
7835///
7836/// Returns [`Error::HashCollision`] when hashing worktree blob content for copy detection
7837/// would produce a known-bad SHA-1 object id.
7838pub fn detect_copies(
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, String, ObjectId)],
7845) -> Result<Vec<DiffEntry>> {
7846    let config = ConfigSet::new();
7847    let policy = ThreadPolicy::default();
7848    detect_copies_with_policy(
7849        odb,
7850        work_root,
7851        entries,
7852        threshold,
7853        find_copies_harder,
7854        source_tree_entries,
7855        &config,
7856        &policy,
7857    )
7858}
7859
7860/// Like [`detect_copies`], but honors parallel thread policy and rename limits.
7861///
7862/// # Errors
7863///
7864/// Same as [`detect_copies`].
7865#[expect(clippy::too_many_arguments)]
7866pub fn detect_copies_with_policy(
7867    odb: &Odb,
7868    work_root: Option<&Path>,
7869    entries: Vec<DiffEntry>,
7870    threshold: u32,
7871    find_copies_harder: bool,
7872    source_tree_entries: &[(String, String, ObjectId)],
7873    config: &ConfigSet,
7874    thread_policy: &ThreadPolicy,
7875) -> Result<Vec<DiffEntry>> {
7876    use std::collections::{HashMap, HashSet};
7877
7878    // Separate entries by status.
7879    let mut deleted: Vec<DiffEntry> = Vec::new();
7880    let mut added: Vec<DiffEntry> = Vec::new();
7881    let mut others: Vec<DiffEntry> = Vec::new();
7882
7883    for entry in entries {
7884        match entry.status {
7885            DiffStatus::Deleted => deleted.push(entry),
7886            DiffStatus::Added => added.push(entry),
7887            _ => others.push(entry),
7888        }
7889    }
7890
7891    // Build source candidates: deleted files, modified files, and optionally tree entries.
7892    // Track which sources are from deleted files (can become renames).
7893    let mut sources: Vec<(String, ObjectId, bool)> = Vec::new(); // (path, oid, is_deleted)
7894    let mut deleted_source_idx: HashMap<String, usize> = HashMap::new();
7895
7896    for entry in &deleted {
7897        if let Some(ref path) = entry.old_path {
7898            deleted_source_idx.insert(path.clone(), sources.len());
7899            sources.push((path.clone(), entry.old_oid, true));
7900        }
7901    }
7902
7903    // Modified and type-changed files are candidates for `-C` (e.g. symlink rewrite leaves the
7904    // old blob available as a copy source for another path; see `t4008-diff-break-rewrite`).
7905    for entry in &others {
7906        if matches!(entry.status, DiffStatus::Modified | DiffStatus::TypeChanged) {
7907            if let Some(ref old_path) = entry.old_path {
7908                if !sources.iter().any(|(p, _, _)| p == old_path) {
7909                    sources.push((old_path.clone(), entry.old_oid, false));
7910                }
7911            }
7912        }
7913    }
7914
7915    // With find_copies_harder, add all source tree entries.
7916    if find_copies_harder {
7917        for (path, _mode, oid) in source_tree_entries {
7918            if !sources.iter().any(|(p, _, _)| p == path) {
7919                sources.push((path.clone(), *oid, false));
7920            }
7921        }
7922    }
7923
7924    if sources.is_empty() {
7925        let mut result = others;
7926        result.extend(deleted);
7927        result.extend(added);
7928        result.sort_by(|a, b| a.path().cmp(b.path()));
7929        return Ok(result);
7930    }
7931
7932    let par_opts = diff_rename_parallel_options(config, thread_policy);
7933    let rename_limit = parse_rename_limit(config);
7934
7935    fn apply_copy_score_assignments(
7936        scores: &[(u32, usize, usize)],
7937        sources: &[(String, ObjectId, bool)],
7938        added: &[DiffEntry],
7939        source_tree_entries: &[(String, String, ObjectId)],
7940        used_added2: &mut [bool],
7941        renamed_deleted: &mut HashSet<usize>,
7942        result_entries: &mut Vec<DiffEntry>,
7943    ) {
7944        let mut used_added = vec![false; added.len()];
7945        let mut source_to_added: HashMap<usize, Vec<(usize, u32)>> = HashMap::new();
7946        for &(score, si, ai) in scores {
7947            if used_added[ai] {
7948                continue;
7949            }
7950            used_added[ai] = true;
7951            source_to_added.entry(si).or_default().push((ai, score));
7952        }
7953
7954        for (&si, assignments_for_src) in &source_to_added {
7955            let (_, _, is_deleted) = &sources[si];
7956            if *is_deleted && !assignments_for_src.is_empty() {
7957                let rename_ai = assignments_for_src
7958                    .iter()
7959                    .max_by_key(|(ai, _score)| added[*ai].path().to_string())
7960                    .map(|(ai, _)| *ai);
7961
7962                for &(ai, score) in assignments_for_src {
7963                    let (ref src_path, _, _) = sources[si];
7964                    let add = &added[ai];
7965                    let src_mode = source_tree_entries
7966                        .iter()
7967                        .find(|(p, _, _)| p == src_path)
7968                        .map(|(_, m, _)| m.clone())
7969                        .unwrap_or_else(|| add.old_mode.clone());
7970
7971                    let is_rename = Some(ai) == rename_ai;
7972                    result_entries.push(DiffEntry {
7973                        status: if is_rename {
7974                            DiffStatus::Renamed
7975                        } else {
7976                            DiffStatus::Copied
7977                        },
7978                        old_path: Some(src_path.clone()),
7979                        new_path: add.new_path.clone(),
7980                        old_mode: src_mode,
7981                        new_mode: add.new_mode.clone(),
7982                        old_oid: sources[si].1,
7983                        new_oid: add.new_oid,
7984                        score: Some(score),
7985                    });
7986                    used_added2[ai] = true;
7987                }
7988                renamed_deleted.insert(si);
7989            } else {
7990                for &(ai, score) in assignments_for_src {
7991                    let (ref src_path, _, _) = sources[si];
7992                    let add = &added[ai];
7993                    let src_mode = source_tree_entries
7994                        .iter()
7995                        .find(|(p, _, _)| p == src_path)
7996                        .map(|(_, m, _)| m.clone())
7997                        .unwrap_or_else(|| add.old_mode.clone());
7998
7999                    result_entries.push(DiffEntry {
8000                        status: DiffStatus::Copied,
8001                        old_path: Some(src_path.clone()),
8002                        new_path: add.new_path.clone(),
8003                        old_mode: src_mode,
8004                        new_mode: add.new_mode.clone(),
8005                        old_oid: sources[si].1,
8006                        new_oid: add.new_oid,
8007                        score: Some(score),
8008                    });
8009                    used_added2[ai] = true;
8010                }
8011            }
8012        }
8013    }
8014
8015    fn added_entry_oid(
8016        odb: &Odb,
8017        add: &DiffEntry,
8018        added_contents: &[Option<Vec<u8>>],
8019        ai: usize,
8020    ) -> Result<ObjectId> {
8021        if add.new_oid != zero_oid() {
8022            Ok(add.new_oid)
8023        } else if let Some(ref data) = added_contents[ai] {
8024            odb.hash(ObjectKind::Blob, data)
8025        } else {
8026            Ok(zero_oid())
8027        }
8028    }
8029
8030    let mut result_entries: Vec<DiffEntry> = Vec::new();
8031    let mut renamed_deleted: HashSet<usize> = HashSet::new();
8032    let mut used_added2 = vec![false; added.len()];
8033    let mut source_contents = vec![None; sources.len()];
8034
8035    if !added.is_empty() {
8036        let added_contents = parallel_load_optional_blobs(odb, added.len(), par_opts, 0, |ai| {
8037            read_added_entry_bytes(odb, &added[ai], work_root)
8038        });
8039
8040        // Git always scores exact OID copy/rename pairs before applying renameLimit.
8041        let mut exact_scores: Vec<(u32, usize, usize)> = Vec::new();
8042        for (si, (src_path, src_oid, _)) in sources.iter().enumerate() {
8043            for (ai, add) in added.iter().enumerate() {
8044                if add.new_path.as_deref() == Some(src_path.as_str()) {
8045                    continue;
8046                }
8047                let add_oid = added_entry_oid(odb, add, &added_contents, ai)?;
8048                if *src_oid == add_oid && add_oid != zero_oid() {
8049                    exact_scores.push((100, si, ai));
8050                }
8051            }
8052        }
8053        exact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
8054        apply_copy_score_assignments(
8055            &exact_scores,
8056            &sources,
8057            &added,
8058            source_tree_entries,
8059            &mut used_added2,
8060            &mut renamed_deleted,
8061            &mut result_entries,
8062        );
8063
8064        let remaining_added: Vec<usize> = used_added2
8065            .iter()
8066            .enumerate()
8067            .filter_map(|(i, used)| (!*used).then_some(i))
8068            .collect();
8069        let remaining_source_count = sources
8070            .iter()
8071            .enumerate()
8072            .filter(|(si, (_, _, is_deleted))| !*is_deleted || !renamed_deleted.contains(si))
8073            .count();
8074
8075        let skip_inexact = remaining_added.is_empty()
8076            || inexact_matrix_exceeds_limit(
8077                remaining_added.len(),
8078                remaining_source_count,
8079                rename_limit,
8080            );
8081
8082        if !skip_inexact {
8083            source_contents = parallel_load_optional_blobs(odb, sources.len(), par_opts, 0, |si| {
8084                odb.read(&sources[si].1).ok().map(|obj| obj.data)
8085            });
8086
8087            let est_bytes: usize = source_contents
8088                .iter()
8089                .chain(added_contents.iter())
8090                .filter_map(|opt| opt.as_ref().map(|b| b.len()))
8091                .sum();
8092
8093            let source_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
8094                odb,
8095                sources.len(),
8096                par_opts,
8097                est_bytes,
8098                |si| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
8099                    Ok(source_contents[si]
8100                        .as_ref()
8101                        .map(|d| RenameLineTable::from_similarity_blob(d)))
8102                },
8103            )
8104            .expect("copy source line tables");
8105            let added_tables: Vec<Option<RenameLineTable>> = parallel_map_indices(
8106                odb,
8107                added.len(),
8108                par_opts,
8109                est_bytes,
8110                |ai| -> std::result::Result<Option<RenameLineTable>, std::convert::Infallible> {
8111                    Ok(added_contents[ai]
8112                        .as_ref()
8113                        .map(|d| RenameLineTable::from_similarity_blob(d)))
8114                },
8115            )
8116            .expect("copy added line tables");
8117
8118            let score_rows: Vec<Vec<(u32, usize, usize)>> = parallel_map_indices(
8119                odb,
8120                sources.len(),
8121                par_opts,
8122                est_bytes,
8123                |si| -> std::result::Result<Vec<(u32, usize, usize)>, std::convert::Infallible> {
8124                    if renamed_deleted.contains(&si) {
8125                        return Ok(Vec::new());
8126                    }
8127                    let (src_path, _, _) = &sources[si];
8128                    let mut row: Vec<(u32, usize, usize)> = Vec::new();
8129                    for &ai in &remaining_added {
8130                        let add = &added[ai];
8131                        if add.new_path.as_deref() == Some(src_path.as_str()) {
8132                            continue;
8133                        }
8134                        let score = match (&source_tables[si], &added_tables[ai]) {
8135                            (Some(old_table), Some(new_table)) => {
8136                                RenameLineTable::similarity_score(old_table, new_table)
8137                            }
8138                            _ => 0,
8139                        };
8140                        if score >= threshold {
8141                            row.push((score, si, ai));
8142                        }
8143                    }
8144                    Ok(row)
8145                },
8146            )
8147            .expect("copy score rows");
8148            let mut inexact_scores: Vec<(u32, usize, usize)> =
8149                score_rows.into_iter().flatten().collect();
8150            inexact_scores.sort_by_key(|a| std::cmp::Reverse(a.0));
8151            apply_copy_score_assignments(
8152                &inexact_scores,
8153                &sources,
8154                &added,
8155                source_tree_entries,
8156                &mut used_added2,
8157                &mut renamed_deleted,
8158                &mut result_entries,
8159            );
8160        }
8161    }
8162
8163    // Keep deleted entries that weren't consumed by a rename.
8164    for entry in deleted.into_iter() {
8165        if let Some(ref path) = entry.old_path {
8166            if let Some(&si) = deleted_source_idx.get(path) {
8167                if renamed_deleted.contains(&si) {
8168                    // This deletion was consumed by a rename; skip it.
8169                    continue;
8170                }
8171            }
8172        }
8173        result_entries.push(entry);
8174    }
8175
8176    let mut result = others;
8177    result.extend(result_entries);
8178    // Keep unmatched added entries.
8179    for (i, entry) in added.into_iter().enumerate() {
8180        if !used_added2[i] {
8181            result.push(entry);
8182        }
8183    }
8184
8185    let mut final_result = Vec::with_capacity(result.len());
8186    for e in result {
8187        if let Some(c) = modified_as_copy_from_sources(
8188            odb,
8189            work_root,
8190            &e,
8191            threshold,
8192            &sources,
8193            &source_contents,
8194            source_tree_entries,
8195        )? {
8196            final_result.push(c);
8197        } else {
8198            final_result.push(e);
8199        }
8200    }
8201
8202    final_result.sort_by(|a, b| a.path().cmp(b.path()));
8203    Ok(final_result)
8204}
8205
8206/// Options for Git-style rename, copy, and break-rewrite post-processing on tree diffs.
8207#[derive(Debug, Clone, Copy)]
8208pub struct DiffPostprocessOptions {
8209    /// When set, `--break-rewrites` threshold on Git's internal 0–[`GIT_DIFF_MAX_SCORE`] scale
8210    /// (`None` = off).
8211    pub break_score: Option<u64>,
8212    /// `-M` similarity threshold (0–100); `100` disables rename detection.
8213    pub rename_threshold: u32,
8214    /// `-C` copy threshold (0–100); `None` skips copy detection.
8215    pub copy_threshold: Option<u32>,
8216    /// `-C --find-copies-harder`: treat unmodified source-tree paths as copy sources.
8217    pub find_copies_harder: bool,
8218    /// When break-rewrite runs on tree↔worktree diffs, fill `new_oid` from worktree bytes for
8219    /// `--raw` output (matches `git diff <tree> -B`; leave false for index↔worktree / diff-files).
8220    pub materialize_break_worktree_new_oid: bool,
8221}
8222
8223impl Default for DiffPostprocessOptions {
8224    fn default() -> Self {
8225        Self {
8226            break_score: None,
8227            rename_threshold: 100,
8228            copy_threshold: None,
8229            find_copies_harder: false,
8230            materialize_break_worktree_new_oid: false,
8231        }
8232    }
8233}
8234
8235/// Index and git directory used to read canonical (clean) worktree bytes during break-rewrite
8236/// post-processing on tree↔worktree diffs.
8237#[derive(Debug, Clone, Copy)]
8238pub struct DiffPostprocessWorktree<'a> {
8239    /// Git directory for config and attributes (linked worktrees / gitfiles).
8240    pub repository_git_dir: &'a Path,
8241    /// Current index; stage-0 entries supply autocrlf index-guard blobs when needed.
8242    pub index: &'a Index,
8243}
8244
8245fn blob_bytes(odb: &Odb, oid: &ObjectId) -> Result<Vec<u8>> {
8246    Ok(odb.read(oid)?.data)
8247}
8248
8249/// Bytes on the "new" side of a diff entry (ODB blob, or worktree file when `new_oid` is null).
8250///
8251/// When `worktree` is set, worktree paths are normalized with the same attribute/filter rules as
8252/// [`diff_tree_to_worktree`]; otherwise raw filesystem bytes are read.
8253fn read_new_side_bytes(
8254    odb: &Odb,
8255    entry: &DiffEntry,
8256    work_root: Option<&Path>,
8257    worktree: Option<&DiffPostprocessWorktree<'_>>,
8258    config: &ConfigSet,
8259) -> Result<Vec<u8>> {
8260    if entry.new_oid != zero_oid() {
8261        return blob_bytes(odb, &entry.new_oid);
8262    }
8263    let path = entry.new_path.as_deref().ok_or_else(|| {
8264        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
8265    })?;
8266    let root = work_root.ok_or_else(|| {
8267        Error::ObjectNotFound(format!(
8268            "worktree bytes required for {} but no work_root was provided",
8269            path
8270        ))
8271    })?;
8272    if let Some(wctx) = worktree {
8273        let mode = u32::from_str_radix(&entry.new_mode, 8).unwrap_or(crate::index::MODE_REGULAR);
8274        let index_entry = wctx.index.get(path.as_bytes(), 0);
8275        return read_worktree_side_bytes_for_diff(odb, root, path, mode, config, index_entry, None);
8276    }
8277    fs::read(root.join(path)).map_err(Error::Io)
8278}
8279
8280/// True when clean-smudged worktree bytes differ from the raw on-disk file (e.g. `eol=lf` + CRLF).
8281fn worktree_side_differs_after_normalize(
8282    odb: &Odb,
8283    entry: &DiffEntry,
8284    work_root: &Path,
8285    worktree: &DiffPostprocessWorktree<'_>,
8286    config: &ConfigSet,
8287) -> Result<bool> {
8288    let path = entry.new_path.as_deref().ok_or_else(|| {
8289        Error::ObjectNotFound("modified entry missing new_path for worktree read".into())
8290    })?;
8291    let raw = fs::read(work_root.join(path)).map_err(Error::Io)?;
8292    let canonical = read_new_side_bytes(odb, entry, Some(work_root), Some(worktree), config)?;
8293    Ok(raw != canonical)
8294}
8295
8296fn split_broken_modifications(
8297    odb: &Odb,
8298    work_root: Option<&Path>,
8299    entries: Vec<DiffEntry>,
8300    break_score: u64,
8301    worktree: Option<&DiffPostprocessWorktree<'_>>,
8302    config: &ConfigSet,
8303) -> Result<Vec<DiffEntry>> {
8304    let mut out = Vec::with_capacity(entries.len());
8305    for e in entries {
8306        if e.status != DiffStatus::Modified {
8307            out.push(e);
8308            continue;
8309        }
8310        let old = blob_bytes(odb, &e.old_oid)?;
8311        let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
8312        if should_break_rewrite_pair(&old, &new, break_score) {
8313            let path = e.path().to_owned();
8314            out.push(DiffEntry {
8315                status: DiffStatus::Deleted,
8316                old_path: Some(path.clone()),
8317                new_path: None,
8318                old_mode: e.old_mode.clone(),
8319                new_mode: "000000".to_owned(),
8320                old_oid: e.old_oid,
8321                new_oid: zero_oid(),
8322                score: None,
8323            });
8324            out.push(DiffEntry {
8325                status: DiffStatus::Added,
8326                old_path: None,
8327                new_path: Some(path),
8328                old_mode: "000000".to_owned(),
8329                new_mode: e.new_mode.clone(),
8330                old_oid: zero_oid(),
8331                new_oid: e.new_oid,
8332                score: None,
8333            });
8334        } else {
8335            out.push(e);
8336        }
8337    }
8338    Ok(out)
8339}
8340
8341fn annotate_broken_modifications(
8342    odb: &Odb,
8343    work_root: Option<&Path>,
8344    entries: Vec<DiffEntry>,
8345    break_score: u64,
8346    worktree: Option<&DiffPostprocessWorktree<'_>>,
8347    config: &ConfigSet,
8348    materialize_worktree_oid: bool,
8349) -> Result<Vec<DiffEntry>> {
8350    let mut out = Vec::with_capacity(entries.len());
8351    for mut e in entries {
8352        if e.status == DiffStatus::Modified {
8353            let old = blob_bytes(odb, &e.old_oid)?;
8354            let new = read_new_side_bytes(odb, &e, work_root, worktree, config)?;
8355            if should_break_rewrite_pair(&old, &new, break_score) {
8356                // `git diff-files -B` keeps plain `M` when the worktree side is still null.
8357                // `git diff <tree> -B` materializes the clean OID; `M###` appears only when
8358                // attributes/filters changed the bytes used for break scoring (e.g. `eol=lf`).
8359                let show_score = if e.new_oid != zero_oid() {
8360                    true
8361                } else if materialize_worktree_oid {
8362                    match (work_root, worktree) {
8363                        (Some(root), Some(wctx)) => {
8364                            worktree_side_differs_after_normalize(odb, &e, root, wctx, config)?
8365                        }
8366                        _ => false,
8367                    }
8368                } else {
8369                    false
8370                };
8371                if show_score {
8372                    e.score = rewrite_dissimilarity_index_percent(&old, &new);
8373                }
8374            }
8375        }
8376        out.push(e);
8377    }
8378    Ok(out)
8379}
8380
8381fn materialize_break_worktree_new_oids(
8382    odb: &Odb,
8383    work_root: Option<&Path>,
8384    entries: &mut [DiffEntry],
8385    worktree: Option<&DiffPostprocessWorktree<'_>>,
8386    config: &ConfigSet,
8387) -> Result<()> {
8388    for e in entries {
8389        if e.new_oid != zero_oid() {
8390            continue;
8391        }
8392        if !matches!(e.status, DiffStatus::Modified | DiffStatus::Added) {
8393            continue;
8394        }
8395        let bytes = read_new_side_bytes(odb, e, work_root, worktree, config)?;
8396        e.new_oid = odb.hash(ObjectKind::Blob, &bytes)?;
8397    }
8398    Ok(())
8399}
8400
8401/// After break-rewrite splits a modified path into delete+add, rename detection may pair the add
8402/// with another deletion (`g` → `f`). Git drops the unused delete at the rename target path.
8403fn prune_break_deletions_obviated_by_renames(entries: Vec<DiffEntry>) -> Vec<DiffEntry> {
8404    use std::collections::HashSet;
8405    let rename_targets: HashSet<String> = entries
8406        .iter()
8407        .filter(|e| e.status == DiffStatus::Renamed)
8408        .filter_map(|e| e.new_path.clone())
8409        .collect();
8410    if rename_targets.is_empty() {
8411        return entries;
8412    }
8413    entries
8414        .into_iter()
8415        .filter(|e| {
8416            if e.status != DiffStatus::Deleted {
8417                return true;
8418            }
8419            e.old_path
8420                .as_ref()
8421                .is_none_or(|p| !rename_targets.contains(p))
8422        })
8423        .collect()
8424}
8425
8426/// Apply break-rewrite, rename, and copy detection to a flat tree or index diff.
8427///
8428/// `source_tree_oid` supplies `--find-copies-harder` candidates; `work_root` is used when added
8429/// entries carry a zero OID (index↔worktree). Pass `worktree` when break-rewrite runs on a
8430/// tree↔worktree diff so worktree-side bytes honor attributes and filters.
8431///
8432/// # Errors
8433///
8434/// Propagates object read failures while evaluating break scores.
8435#[allow(clippy::too_many_arguments)]
8436pub fn postprocess_diff_entries(
8437    odb: &Odb,
8438    entries: Vec<DiffEntry>,
8439    source_tree_oid: Option<&ObjectId>,
8440    work_root: Option<&Path>,
8441    worktree: Option<&DiffPostprocessWorktree<'_>>,
8442    config: &ConfigSet,
8443    thread_policy: &ThreadPolicy,
8444    opts: &DiffPostprocessOptions,
8445) -> Result<Vec<DiffEntry>> {
8446    let mut entries = entries;
8447    if let Some(break_score) = opts.break_score {
8448        let rename_or_copy = opts.rename_threshold < 100 || opts.copy_threshold.is_some();
8449        entries = if rename_or_copy {
8450            split_broken_modifications(odb, work_root, entries, break_score, worktree, config)?
8451        } else {
8452            annotate_broken_modifications(
8453                odb,
8454                work_root,
8455                entries,
8456                break_score,
8457                worktree,
8458                config,
8459                opts.materialize_break_worktree_new_oid,
8460            )?
8461        };
8462        if opts.materialize_break_worktree_new_oid {
8463            materialize_break_worktree_new_oids(odb, work_root, &mut entries, worktree, config)?;
8464        }
8465    }
8466
8467    let source_tree_entries = source_tree_oid
8468        .map(|t| flatten_tree_entries(odb, t))
8469        .transpose()?
8470        .unwrap_or_default();
8471
8472    if opts.copy_threshold.is_some() || opts.find_copies_harder {
8473        let threshold = opts.copy_threshold.unwrap_or(opts.rename_threshold.min(99));
8474        detect_copies_with_policy(
8475            odb,
8476            work_root,
8477            entries,
8478            threshold,
8479            opts.find_copies_harder,
8480            &source_tree_entries,
8481            config,
8482            thread_policy,
8483        )
8484    } else if opts.rename_threshold < 100 {
8485        detect_renames_with_policy(
8486            odb,
8487            work_root,
8488            entries,
8489            opts.rename_threshold,
8490            config,
8491            thread_policy,
8492            None,
8493        )
8494    } else {
8495        Ok(entries)
8496    }
8497}
8498
8499/// Depth-first list of `(path, mode, oid)` blob entries in a tree (for copy detection).
8500pub fn flatten_tree_entries(
8501    odb: &Odb,
8502    tree_oid: &ObjectId,
8503) -> Result<Vec<(String, String, ObjectId)>> {
8504    fn walk(
8505        odb: &Odb,
8506        tree_oid: &ObjectId,
8507        prefix: &str,
8508        out: &mut Vec<(String, String, ObjectId)>,
8509    ) -> Result<()> {
8510        let obj = odb.read(tree_oid)?;
8511        let oid_len = odb.hash_algo().len();
8512        for e in TreeRefIter::with_oid_len(&obj.data, oid_len) {
8513            let e = e?;
8514            let name = String::from_utf8_lossy(e.name);
8515            let path = if prefix.is_empty() {
8516                name.to_string()
8517            } else {
8518                format!("{prefix}/{name}")
8519            };
8520            if e.mode == 0o040000 {
8521                walk(odb, &e.oid, &path, out)?;
8522            } else {
8523                out.push((path, format!("{:o}", e.mode), e.oid));
8524            }
8525        }
8526        Ok(())
8527    }
8528    let mut out = Vec::new();
8529    walk(odb, tree_oid, "", &mut out)?;
8530    Ok(out)
8531}
8532
8533/// Apply Git-style rename and optional copy detection for index↔worktree diffs.
8534///
8535/// When `copies` is true (Git `diff.renames` / `status.renames` set to `copy`/`copies`),
8536/// runs copy detection, which also pairs deleted sources with one rename and any additional
8537/// destinations as copies.
8538///
8539/// # Errors
8540///
8541/// Propagates errors from reading the `head_tree` object from `odb`.
8542#[allow(clippy::too_many_arguments)]
8543pub fn status_apply_rename_copy_detection(
8544    odb: &Odb,
8545    unstaged_raw: Vec<DiffEntry>,
8546    threshold: u32,
8547    copies: bool,
8548    head_tree: Option<&ObjectId>,
8549    config: &ConfigSet,
8550    thread_policy: &ThreadPolicy,
8551    cancel_watch: Option<&std::sync::atomic::AtomicBool>,
8552) -> Result<Vec<DiffEntry>> {
8553    if !copies {
8554        return detect_renames_with_policy(
8555            odb,
8556            None,
8557            unstaged_raw,
8558            threshold,
8559            config,
8560            thread_policy,
8561            cancel_watch,
8562        );
8563    }
8564    let source_tree_entries: Vec<(String, String, ObjectId)> = match head_tree {
8565        Some(oid) => flatten_tree(odb, oid, "")?
8566            .into_iter()
8567            .map(|e| (e.path, format_mode(e.mode), e.oid))
8568            .collect(),
8569        None => Vec::new(),
8570    };
8571    detect_copies_with_policy(
8572        odb,
8573        None,
8574        unstaged_raw,
8575        threshold,
8576        false,
8577        &source_tree_entries,
8578        config,
8579        thread_policy,
8580    )
8581}
8582
8583/// Format a rename pair using Git's compact path format.
8584///
8585/// Examples:
8586/// - `a/b/c` → `c/b/a` → `a/b/c => c/b/a`
8587/// - `c/b/a` → `c/d/e` → `c/{b/a => d/e}`
8588/// - `c/d/e` → `d/e` → `{c/d => d}/e`
8589/// - `d/e` → `d/f/e` → `d/{ => f}/e`
8590pub fn format_rename_path(old: &str, new: &str) -> String {
8591    let ob = old.as_bytes();
8592    let nb = new.as_bytes();
8593
8594    // Find common prefix length, snapped to '/' boundary.
8595    let pfx = {
8596        let mut last_sep = 0usize;
8597        let min_len = ob.len().min(nb.len());
8598        for i in 0..min_len {
8599            if ob[i] != nb[i] {
8600                break;
8601            }
8602            if ob[i] == b'/' {
8603                last_sep = i + 1;
8604            }
8605        }
8606        last_sep
8607    };
8608
8609    // Find common suffix length, snapped to '/' boundary.
8610    let mut sfx = {
8611        let mut last_sep = 0usize;
8612        let min_len = ob.len().min(nb.len());
8613        for i in 0..min_len {
8614            let oi = ob.len() - 1 - i;
8615            let ni = nb.len() - 1 - i;
8616            if ob[oi] != nb[ni] {
8617                break;
8618            }
8619            if ob[oi] == b'/' {
8620                last_sep = i + 1;
8621            }
8622        }
8623        last_sep
8624    };
8625
8626    // Suffix starts at this position in each string.
8627    let mut sfx_at_old = ob.len() - sfx;
8628    let mut sfx_at_new = nb.len() - sfx;
8629
8630    // If prefix and suffix overlap in both strings (both middles empty),
8631    // reduce the suffix so that at least the longer string has a non-empty middle.
8632    while pfx > sfx_at_old && pfx > sfx_at_new && sfx > 0 {
8633        // Reduce suffix by snapping to the next smaller '/' boundary.
8634        let suffix_bytes = &ob[sfx_at_old..];
8635        let mut new_sfx = 0;
8636        // Find the next '/' after sfx_at_old (i.e., reduce suffix).
8637        for (i, &b) in suffix_bytes.iter().enumerate().skip(1) {
8638            if b == b'/' {
8639                new_sfx = sfx - i;
8640                break;
8641            }
8642        }
8643        if new_sfx == 0 || new_sfx >= sfx {
8644            sfx_at_old = ob.len();
8645            sfx_at_new = nb.len();
8646            break;
8647        }
8648        sfx = new_sfx;
8649        sfx_at_old = ob.len() - sfx;
8650        sfx_at_new = nb.len() - sfx;
8651    }
8652
8653    // When prefix and suffix overlap in the shorter string, they share
8654    // the '/' boundary character. In the output format, the shared '/'
8655    // appears in both positions (e.g. "d/{ => f}/e" for d/e → d/f/e).
8656    // Compute the middle parts. When prefix and suffix overlap in a
8657    // string, the middle for that string is empty. The shared '/' shows
8658    // in both prefix (trailing) and suffix (leading) positions.
8659    let prefix = &old[..pfx];
8660    let suffix = &old[sfx_at_old..];
8661    let old_mid = if pfx <= sfx_at_old {
8662        &old[pfx..sfx_at_old]
8663    } else {
8664        ""
8665    };
8666    let new_mid = if pfx <= sfx_at_new {
8667        &new[pfx..sfx_at_new]
8668    } else {
8669        ""
8670    };
8671
8672    if prefix.is_empty() && suffix.is_empty() {
8673        return format!("{old} => {new}");
8674    }
8675
8676    format!("{prefix}{{{old_mid} => {new_mid}}}{suffix}")
8677}
8678
8679/// Check if two entries share the same filename (basename).
8680fn same_basename(del: &DiffEntry, add: &DiffEntry) -> bool {
8681    let old = del.old_path.as_deref().unwrap_or("");
8682    let new = add.new_path.as_deref().unwrap_or("");
8683    let old_base = old.rsplit('/').next().unwrap_or(old);
8684    let new_base = new.rsplit('/').next().unwrap_or(new);
8685    old_base == new_base && !old_base.is_empty()
8686}
8687
8688const RENAME_SCORE_MAX: u32 = 100;
8689
8690/// Normalize blob bytes for rename similarity (skip allocation when already LF-only).
8691fn normalize_bytes_for_rename(data: Vec<u8>) -> Vec<u8> {
8692    if data.contains(&b'\r') {
8693        crate::crlf::crlf_to_lf(&data)
8694    } else {
8695        data
8696    }
8697}
8698
8699/// Uncompressed blob size without inflating object bodies (for rename pre-filtering).
8700fn blob_payload_size(odb: &Odb, oid: &ObjectId) -> Option<usize> {
8701    if oid.is_zero() {
8702        return None;
8703    }
8704    odb.read_info(oid).ok().map(|info| info.size as usize)
8705}
8706
8707/// Payload size for an added diff side (ODB blob or worktree bytes when not stored yet).
8708fn added_entry_payload_size(odb: &Odb, add: &DiffEntry, work_root: Option<&Path>) -> Option<usize> {
8709    if !add.new_oid.is_zero() {
8710        if let Some(size) = blob_payload_size(odb, &add.new_oid) {
8711            return Some(size);
8712        }
8713    }
8714    read_added_entry_bytes(odb, add, work_root).map(|data| data.len())
8715}
8716
8717/// Git `diffcore-delta` size gate: skip pairs that cannot reach `minimum_score`.
8718fn rename_pair_size_reachable(src_size: usize, dst_size: usize, minimum_score: u32) -> bool {
8719    let max_size = src_size.max(dst_size);
8720    let min_size = src_size.min(dst_size);
8721    if max_size == 0 {
8722        return minimum_score <= RENAME_SCORE_MAX;
8723    }
8724    (max_size - min_size).saturating_mul(RENAME_SCORE_MAX as usize)
8725        <= max_size.saturating_mul((RENAME_SCORE_MAX - minimum_score) as usize)
8726}
8727
8728/// Compute a similarity percentage (0–100) between two byte slices.
8729///
8730/// Matches Git rename detection: line-hash intersection over CRLF-normalized bodies,
8731/// `score = copied_bytes * 100 / max(src_size, dst_size)`.
8732fn compute_similarity(old: &[u8], new: &[u8]) -> u32 {
8733    RenameLineTable::similarity_score(
8734        &RenameLineTable::from_similarity_blob(old),
8735        &RenameLineTable::from_similarity_blob(new),
8736    )
8737}
8738
8739/// Compute rename/copy similarity percentage (0–100) between two byte slices.
8740///
8741/// This uses the same scoring logic as internal rename detection.
8742#[must_use]
8743pub fn rename_similarity_score(old: &[u8], new: &[u8]) -> u32 {
8744    compute_similarity(old, new)
8745}
8746
8747// ── Output formatting ───────────────────────────────────────────────
8748
8749/// Format a diff entry in Git's raw diff format.
8750///
8751/// Example: `:100644 100644 abc1234... def5678... M\tfile.txt`
8752pub fn format_raw(entry: &DiffEntry) -> String {
8753    let path = match entry.status {
8754        DiffStatus::Renamed | DiffStatus::Copied => {
8755            format!(
8756                "{}\t{}",
8757                entry.old_path.as_deref().unwrap_or(""),
8758                entry.new_path.as_deref().unwrap_or("")
8759            )
8760        }
8761        _ => entry.path().to_owned(),
8762    };
8763
8764    let status_str = match (entry.status, entry.score) {
8765        (DiffStatus::Renamed, Some(s)) => format!("R{:03}", s),
8766        (DiffStatus::Copied, Some(s)) => format!("C{:03}", s),
8767        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
8768        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
8769        _ => entry.status.letter().to_string(),
8770    };
8771
8772    let (old_hex, new_hex) = raw_oid_hex_pair(&entry.old_oid, &entry.new_oid);
8773    format!(
8774        ":{} {} {} {} {}\t{}",
8775        entry.old_mode, entry.new_mode, old_hex, new_hex, status_str, path
8776    )
8777}
8778
8779/// Render a diff entry's `(old, new)` OIDs as full hex for `--raw` output,
8780/// widening any null OID to the repository's hash width.
8781///
8782/// Diff entries store null OIDs at SHA-1 width regardless of the repository
8783/// algorithm, but `git diff --raw` prints them at the real hash width (64
8784/// zeros in a SHA-256 repo). A diff entry never has both sides null, so the
8785/// width is taken from whichever side carries a real object.
8786fn raw_oid_hex_pair(old: &ObjectId, new: &ObjectId) -> (String, String) {
8787    let width = if !old.is_zero() {
8788        old.algo().hex_len()
8789    } else if !new.is_zero() {
8790        new.algo().hex_len()
8791    } else {
8792        old.algo().hex_len()
8793    };
8794    let render = |oid: &ObjectId| {
8795        if oid.is_zero() {
8796            "0".repeat(width)
8797        } else {
8798            oid.to_hex()
8799        }
8800    };
8801    (render(old), render(new))
8802}
8803
8804/// Format a diff entry with abbreviated OIDs.
8805pub fn format_raw_abbrev(entry: &DiffEntry, abbrev_len: usize) -> String {
8806    let ellipsis = if crate::environment::Environment::empty()
8807        .var("GIT_PRINT_SHA1_ELLIPSIS")
8808        .as_deref()
8809        == Some("yes")
8810    {
8811        "..."
8812    } else {
8813        ""
8814    };
8815    let old_hex = format!("{}", entry.old_oid);
8816    let new_hex = format!("{}", entry.new_oid);
8817    let old_abbrev = &old_hex[..abbrev_len.min(old_hex.len())];
8818    let new_abbrev = &new_hex[..abbrev_len.min(new_hex.len())];
8819
8820    // Renames/copies carry a similarity score and a `<old>\t<new>` path pair.
8821    let path = match entry.status {
8822        DiffStatus::Renamed | DiffStatus::Copied => format!(
8823            "{}\t{}",
8824            entry.old_path.as_deref().unwrap_or(""),
8825            entry.new_path.as_deref().unwrap_or("")
8826        ),
8827        _ => entry.path().to_owned(),
8828    };
8829    let status_str = match (entry.status, entry.score) {
8830        (DiffStatus::Renamed, Some(s)) => format!("R{s:03}"),
8831        (DiffStatus::Copied, Some(s)) => format!("C{s:03}"),
8832        (DiffStatus::Modified, Some(s)) => format!("M{s:03}"),
8833        (DiffStatus::TypeChanged, Some(s)) => format!("T{s:03}"),
8834        _ => entry.status.letter().to_string(),
8835    };
8836
8837    format!(
8838        ":{} {} {}{} {}{} {}\t{}",
8839        entry.old_mode,
8840        entry.new_mode,
8841        old_abbrev,
8842        ellipsis,
8843        new_abbrev,
8844        ellipsis,
8845        status_str,
8846        path
8847    )
8848}
8849
8850/// Generate a unified diff patch for two blobs.
8851///
8852/// # Parameters
8853///
8854/// - `old_content` — the old file content (empty for added files).
8855/// - `new_content` — the new file content (empty for deleted files).
8856/// - `old_path` — display path for the old side.
8857/// - `new_path` — display path for the new side.
8858/// - `context_lines` — number of context lines around changes (default: 3).
8859/// - Inter-hunk context defaults to `0` (see [`unified_diff_with_prefix`]).
8860///
8861/// # Returns
8862///
8863/// The unified diff as a string.
8864pub fn unified_diff(
8865    old_content: &str,
8866    new_content: &str,
8867    old_path: &str,
8868    new_path: &str,
8869    context_lines: usize,
8870    indent_heuristic: bool,
8871    quote_path_fully: bool,
8872) -> String {
8873    unified_diff_with_prefix(
8874        old_content,
8875        new_content,
8876        old_path,
8877        new_path,
8878        context_lines,
8879        0,
8880        "a/",
8881        "b/",
8882        indent_heuristic,
8883        quote_path_fully,
8884    )
8885}
8886
8887/// Same as `unified_diff` but with configurable source/destination prefixes.
8888///
8889/// `inter_hunk_context` is Git's `--inter-hunk-context`: adjacent hunks merge when
8890/// the unchanged gap between them is at most `2 * context_lines + inter_hunk_context` lines.
8891#[allow(clippy::too_many_arguments)] // Mirrors Git-style unified diff parameters.
8892pub fn unified_diff_with_prefix(
8893    old_content: &str,
8894    new_content: &str,
8895    old_path: &str,
8896    new_path: &str,
8897    context_lines: usize,
8898    inter_hunk_context: usize,
8899    src_prefix: &str,
8900    dst_prefix: &str,
8901    indent_heuristic: bool,
8902    quote_path_fully: bool,
8903) -> String {
8904    unified_diff_with_prefix_and_funcname(
8905        old_content,
8906        new_content,
8907        old_path,
8908        new_path,
8909        context_lines,
8910        inter_hunk_context,
8911        src_prefix,
8912        dst_prefix,
8913        None,
8914        indent_heuristic,
8915        quote_path_fully,
8916    )
8917}
8918
8919/// Same as [`unified_diff_with_prefix`] with optional custom hunk-header
8920/// function-name matching.
8921#[allow(clippy::too_many_arguments)]
8922pub fn unified_diff_with_prefix_and_funcname(
8923    old_content: &str,
8924    new_content: &str,
8925    old_path: &str,
8926    new_path: &str,
8927    context_lines: usize,
8928    inter_hunk_context: usize,
8929    src_prefix: &str,
8930    dst_prefix: &str,
8931    funcname_matcher: Option<&FuncnameMatcher>,
8932    indent_heuristic: bool,
8933    quote_path_fully: bool,
8934) -> String {
8935    unified_diff_with_prefix_and_funcname_and_algorithm(
8936        old_content,
8937        new_content,
8938        old_path,
8939        new_path,
8940        context_lines,
8941        inter_hunk_context,
8942        src_prefix,
8943        dst_prefix,
8944        funcname_matcher,
8945        similar::Algorithm::Myers,
8946        false,
8947        false,
8948        indent_heuristic,
8949        quote_path_fully,
8950    )
8951}
8952
8953/// Same as [`unified_diff_with_prefix_and_funcname_and_algorithm`], streaming to `out`.
8954///
8955/// # Errors
8956///
8957/// Returns I/O errors from `out`.
8958#[allow(clippy::too_many_arguments)]
8959pub fn write_unified_diff_with_prefix_and_funcname_and_algorithm<W: std::io::Write>(
8960    out: &mut W,
8961    old_content: &str,
8962    new_content: &str,
8963    old_path: &str,
8964    new_path: &str,
8965    context_lines: usize,
8966    inter_hunk_context: usize,
8967    src_prefix: &str,
8968    dst_prefix: &str,
8969    funcname_matcher: Option<&FuncnameMatcher>,
8970    algorithm: similar::Algorithm,
8971    function_context: bool,
8972    use_git_histogram: bool,
8973    indent_heuristic: bool,
8974    quote_path_fully: bool,
8975) -> Result<()> {
8976    if function_context {
8977        let patch = unified_diff_with_function_context(
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            algorithm,
8988            indent_heuristic,
8989            quote_path_fully,
8990        );
8991        out.write_all(patch.as_bytes())?;
8992        return Ok(());
8993    }
8994
8995    if use_git_histogram {
8996        let patch = unified_diff_histogram_with_prefix_and_funcname_indent(
8997            old_content,
8998            new_content,
8999            old_path,
9000            new_path,
9001            context_lines,
9002            inter_hunk_context,
9003            src_prefix,
9004            dst_prefix,
9005            funcname_matcher,
9006            indent_heuristic,
9007            quote_path_fully,
9008        );
9009        out.write_all(patch.as_bytes())?;
9010        return Ok(());
9011    }
9012
9013    if algorithm == similar::Algorithm::Myers && !function_context {
9014        let patch = unified_diff_git_myers_with_prefix_and_funcname(
9015            old_content,
9016            new_content,
9017            old_path,
9018            new_path,
9019            context_lines,
9020            inter_hunk_context,
9021            src_prefix,
9022            dst_prefix,
9023            funcname_matcher,
9024            indent_heuristic,
9025            quote_path_fully,
9026        );
9027        out.write_all(patch.as_bytes())?;
9028        return Ok(());
9029    }
9030
9031    if algorithm == similar::Algorithm::Lcs && !function_context {
9032        let patch = unified_diff_git_minimal_with_prefix_and_funcname(
9033            old_content,
9034            new_content,
9035            old_path,
9036            new_path,
9037            context_lines,
9038            inter_hunk_context,
9039            src_prefix,
9040            dst_prefix,
9041            funcname_matcher,
9042            indent_heuristic,
9043            quote_path_fully,
9044        );
9045        out.write_all(patch.as_bytes())?;
9046        return Ok(());
9047    }
9048
9049    if algorithm == similar::Algorithm::Patience && !function_context {
9050        let patch = unified_diff_git_patience_with_prefix_and_funcname(
9051            old_content,
9052            new_content,
9053            old_path,
9054            new_path,
9055            context_lines,
9056            inter_hunk_context,
9057            src_prefix,
9058            dst_prefix,
9059            funcname_matcher,
9060            indent_heuristic,
9061            quote_path_fully,
9062        );
9063        out.write_all(patch.as_bytes())?;
9064        return Ok(());
9065    }
9066
9067    use crate::quote_path::format_diff_path_with_prefix;
9068    use similar::{udiff::UnifiedDiffHunk, TextDiff};
9069
9070    let diff = TextDiff::configure()
9071        .algorithm(algorithm)
9072        .diff_lines(old_content, new_content);
9073    let compacted_ops = diff_indent_heuristic::diff_lines_ops_compacted(
9074        old_content,
9075        new_content,
9076        algorithm,
9077        indent_heuristic,
9078    );
9079
9080    if old_path == "/dev/null" {
9081        out.write_all(b"--- /dev/null\n")?;
9082    } else if src_prefix.is_empty() {
9083        writeln!(out, "--- {old_path}")?;
9084    } else {
9085        out.write_all(b"--- ")?;
9086        out.write_all(
9087            format_diff_path_with_prefix(src_prefix, old_path, quote_path_fully).as_bytes(),
9088        )?;
9089        out.write_all(b"\n")?;
9090    }
9091    if new_path == "/dev/null" {
9092        out.write_all(b"+++ /dev/null\n")?;
9093    } else if dst_prefix.is_empty() {
9094        writeln!(out, "+++ {new_path}")?;
9095    } else {
9096        out.write_all(b"+++ ")?;
9097        out.write_all(
9098            format_diff_path_with_prefix(dst_prefix, new_path, quote_path_fully).as_bytes(),
9099        )?;
9100        out.write_all(b"\n")?;
9101    }
9102
9103    let old_lines: Vec<&str> = old_content.lines().collect();
9104    let max_common_gap = context_lines
9105        .saturating_mul(2)
9106        .saturating_add(inter_hunk_context);
9107    let op_groups = group_diff_ops_gap(compacted_ops, context_lines, max_common_gap);
9108
9109    let mut hunk_buf = String::new();
9110    let mut eof_annot = UnifiedDiffNoNewlineAnnotator::new(old_content, new_content);
9111    for ops in op_groups {
9112        if ops.is_empty() {
9113            continue;
9114        }
9115        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
9116        hunk_buf.clear();
9117        use std::fmt::Write as FmtWrite;
9118        let _ = FmtWrite::write_fmt(&mut hunk_buf, format_args!("{hunk}"));
9119        let mut fragment = hunk_buf.clone();
9120        if let Some(first_newline) = hunk_buf.find('\n') {
9121            let header_line = &hunk_buf[..first_newline];
9122            let rest = &hunk_buf[first_newline..];
9123            if let Some(func_ctx) =
9124                extract_function_context(header_line, &old_lines, funcname_matcher, Some(old_path))
9125            {
9126                fragment.clear();
9127                fragment.push_str(header_line);
9128                fragment.push(' ');
9129                fragment.push_str(&func_ctx);
9130                fragment.push_str(rest);
9131            }
9132        }
9133        eof_annot.write_hunk(out, &fragment)?;
9134    }
9135    Ok(())
9136}
9137
9138/// Same as [`unified_diff_with_prefix_and_funcname`] but allows callers to
9139/// choose the line diff algorithm used for hunk generation.
9140///
9141/// When `function_context` is true (`git diff -W`), hunks are expanded to
9142/// whole logical functions using the same rules as Git's `XDL_EMIT_FUNCCONTEXT`.
9143#[allow(clippy::too_many_arguments)]
9144pub fn unified_diff_with_prefix_and_funcname_and_algorithm(
9145    old_content: &str,
9146    new_content: &str,
9147    old_path: &str,
9148    new_path: &str,
9149    context_lines: usize,
9150    inter_hunk_context: usize,
9151    src_prefix: &str,
9152    dst_prefix: &str,
9153    funcname_matcher: Option<&FuncnameMatcher>,
9154    algorithm: similar::Algorithm,
9155    function_context: bool,
9156    use_git_histogram: bool,
9157    indent_heuristic: bool,
9158    quote_path_fully: bool,
9159) -> String {
9160    let mut output = Vec::new();
9161    if write_unified_diff_with_prefix_and_funcname_and_algorithm(
9162        &mut output,
9163        old_content,
9164        new_content,
9165        old_path,
9166        new_path,
9167        context_lines,
9168        inter_hunk_context,
9169        src_prefix,
9170        dst_prefix,
9171        funcname_matcher,
9172        algorithm,
9173        function_context,
9174        use_git_histogram,
9175        indent_heuristic,
9176        quote_path_fully,
9177    )
9178    .is_err()
9179    {
9180        output.clear();
9181    }
9182    String::from_utf8_lossy(&output).into_owned()
9183}
9184
9185/// Group diff ops into hunks like Git's `xdl_get_hunk`: two changes merge into one hunk while
9186/// the run of unchanged lines between them is at most `max_common_gap`
9187/// (`2 * context + inter_hunk_context`), and each hunk keeps at most `context` unchanged lines
9188/// at its edges. Unlike `similar::group_diff_ops`, the split threshold is decoupled from the
9189/// edge context so odd gap limits group exactly like Git.
9190pub(crate) fn group_diff_ops_gap(
9191    mut ops: Vec<similar::DiffOp>,
9192    context: usize,
9193    max_common_gap: usize,
9194) -> Vec<Vec<similar::DiffOp>> {
9195    use similar::DiffOp;
9196    if ops.is_empty() {
9197        return vec![];
9198    }
9199
9200    let mut pending_group = Vec::new();
9201    let mut rv = Vec::new();
9202
9203    if let Some(DiffOp::Equal {
9204        old_index,
9205        new_index,
9206        len,
9207    }) = ops.first_mut()
9208    {
9209        let offset = (*len).saturating_sub(context);
9210        *old_index += offset;
9211        *new_index += offset;
9212        *len -= offset;
9213    }
9214
9215    if let Some(DiffOp::Equal { len, .. }) = ops.last_mut() {
9216        *len -= (*len).saturating_sub(context);
9217    }
9218
9219    for op in ops.into_iter() {
9220        if let DiffOp::Equal {
9221            old_index,
9222            new_index,
9223            len,
9224        } = op
9225        {
9226            // End the current group and start a new one whenever the unchanged
9227            // run is too long to fuse the surrounding changes.
9228            if len > max_common_gap {
9229                pending_group.push(DiffOp::Equal {
9230                    old_index,
9231                    new_index,
9232                    len: context,
9233                });
9234                rv.push(pending_group);
9235                let offset = len.saturating_sub(context);
9236                pending_group = vec![DiffOp::Equal {
9237                    old_index: old_index + offset,
9238                    new_index: new_index + offset,
9239                    len: len - offset,
9240                }];
9241                continue;
9242            }
9243        }
9244        pending_group.push(op);
9245    }
9246
9247    match &pending_group[..] {
9248        &[] | &[similar::DiffOp::Equal { .. }] => {}
9249        _ => rv.push(pending_group),
9250    }
9251
9252    rv
9253}
9254#[expect(clippy::too_many_arguments)]
9255/// `git diff -W`: expand each hunk to include full function bodies (see Git `xemit.c`).
9256fn unified_diff_with_function_context(
9257    old_content: &str,
9258    new_content: &str,
9259    old_path: &str,
9260    new_path: &str,
9261    context_lines: usize,
9262    inter_hunk_context: usize,
9263    src_prefix: &str,
9264    dst_prefix: &str,
9265    funcname_matcher: Option<&FuncnameMatcher>,
9266    algorithm: similar::Algorithm,
9267    indent_heuristic: bool,
9268    quote_path_fully: bool,
9269) -> String {
9270    use crate::quote_path::format_diff_path_with_prefix;
9271    use similar::{udiff::UnifiedDiffHunk, TextDiff};
9272
9273    let diff = TextDiff::configure()
9274        .algorithm(algorithm)
9275        .diff_lines(old_content, new_content);
9276
9277    let old_lines: Vec<&str> = old_content.lines().collect();
9278    let new_lines: Vec<&str> = new_content.lines().collect();
9279    let n_old = old_lines.len();
9280    let n_new = new_lines.len();
9281
9282    // Group changes the way Git's xdl_get_hunk does: merge while the unchanged
9283    // gap is at most `2*context + inter_hunk_context`. `similar::group_diff_ops`
9284    // splits only at gap > `2*radius`, which over-merges changes in *different*
9285    // functions (e.g. a leading insertion and a body change) into one hunk and
9286    // then over-expands the function context (t4015 #136). Use the gap-correct
9287    // grouping (same as the non-function-context path).
9288    let max_common_gap = context_lines
9289        .saturating_mul(2)
9290        .saturating_add(inter_hunk_context);
9291    let all_ops = diff.ops().to_vec();
9292    let op_groups = group_diff_ops_gap(all_ops.clone(), context_lines, max_common_gap);
9293
9294    let mut ranges: Vec<(usize, usize, usize, usize)> = Vec::new();
9295
9296    for ops in op_groups {
9297        if ops.is_empty() {
9298            continue;
9299        }
9300        let i1_anchor = func_context_old_anchor(&ops, n_old);
9301        let i1_end = hunk_old_change_end_exclusive(&ops);
9302        let skip_preimage_pull =
9303            append_with_whole_function_added(&ops, n_old, n_new, &new_lines, funcname_matcher);
9304        let hunk = UnifiedDiffHunk::new(ops, &diff, true);
9305        let hunk_str = format!("{hunk}");
9306        let header_line = hunk_str
9307            .lines()
9308            .next()
9309            .unwrap_or("")
9310            .trim_end_matches(['\r', '\n']);
9311        let Some((base_s1, _base_e1, _base_s2, _base_e2)) =
9312            parse_unified_hunk_header_ranges(header_line)
9313        else {
9314            continue;
9315        };
9316
9317        let ctx = context_lines;
9318        let (s1, e1, s2, e2) = if skip_preimage_pull {
9319            let s = n_old.saturating_sub(ctx);
9320            let s2 = map_old_line_to_new(&all_ops, s, n_new).min(n_new);
9321            (s, n_old, s2, n_new)
9322        } else {
9323            let mut s1 = base_s1.saturating_sub(ctx);
9324            let mut s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
9325
9326            let base_pre_s1 = i1_anchor.saturating_sub(ctx);
9327            if base_pre_s1 < s1 {
9328                s1 = base_pre_s1;
9329                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
9330            }
9331
9332            let fs1 = expand_func_pre_start(s1, i1_anchor, n_old, &old_lines, funcname_matcher);
9333            if fs1 < s1 {
9334                s1 = fs1;
9335                s2 = map_old_line_to_new(&all_ops, s1, n_new).min(n_new);
9336            }
9337
9338            // `i1_end` is the exclusive end of the changed region; its post-image
9339            // context is `ctx` lines (Git's `xche->i1 + xche->chg1 + lctx`). The
9340            // hunk's `base_e1` already includes the group's trailing context, so
9341            // use the change end + ctx directly to avoid double-counting context
9342            // (which over-extended the hunk and merged separate functions — t4015 #136).
9343            let mut e1 = (i1_end + ctx).min(n_old);
9344            let mut e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
9345            let fe1 = expand_func_post_end(e1, i1_end, n_old, &old_lines, funcname_matcher);
9346            if fe1 > e1 {
9347                e1 = fe1;
9348                e2 = map_old_line_to_new(&all_ops, e1, n_new).min(n_new);
9349            }
9350            (s1, e1, s2, e2)
9351        };
9352
9353        ranges.push((s1, e1, s2, e2));
9354    }
9355
9356    // Merge ranges whose function-context expansion made them overlap on the old
9357    // side (Git emits a single hunk in that case).
9358    ranges.sort_by_key(|r| (r.0, r.2));
9359    let mut merged: Vec<(usize, usize, usize, usize)> = Vec::with_capacity(ranges.len());
9360    for (s1, e1, s2, e2) in ranges {
9361        if let Some(last) = merged.last_mut() {
9362            if s1 < last.1 {
9363                last.1 = last.1.max(e1);
9364                last.3 = last.3.max(e2);
9365                continue;
9366            }
9367        }
9368        merged.push((s1, e1, s2, e2));
9369    }
9370    let ranges = merged;
9371
9372    let mut output = String::new();
9373    if old_path == "/dev/null" {
9374        output.push_str("--- /dev/null\n");
9375    } else if src_prefix.is_empty() {
9376        output.push_str(&format!("--- {old_path}\n"));
9377    } else {
9378        output.push_str("--- ");
9379        output.push_str(&format_diff_path_with_prefix(
9380            src_prefix,
9381            old_path,
9382            quote_path_fully,
9383        ));
9384        output.push('\n');
9385    }
9386    if new_path == "/dev/null" {
9387        output.push_str("+++ /dev/null\n");
9388    } else if dst_prefix.is_empty() {
9389        output.push_str(&format!("+++ {new_path}\n"));
9390    } else {
9391        output.push_str("+++ ");
9392        output.push_str(&format_diff_path_with_prefix(
9393            dst_prefix,
9394            new_path,
9395            quote_path_fully,
9396        ));
9397        output.push('\n');
9398    }
9399
9400    for (s1, e1, s2, e2) in ranges {
9401        if s1 >= e1 && s2 >= e2 {
9402            continue;
9403        }
9404        let old_seg =
9405            line_slice_for_diff_with_eof_nl(&old_lines, s1, e1, old_content.ends_with('\n'));
9406        let new_seg =
9407            line_slice_for_diff_with_eof_nl(&new_lines, s2, e2, new_content.ends_with('\n'));
9408        let inner_ctx = old_seg.lines().count().max(new_seg.lines().count()).max(1);
9409        let piece = unified_diff_with_prefix_and_funcname_and_algorithm(
9410            &old_seg,
9411            &new_seg,
9412            old_path,
9413            new_path,
9414            inner_ctx,
9415            0,
9416            src_prefix,
9417            dst_prefix,
9418            funcname_matcher,
9419            algorithm,
9420            false,
9421            false,
9422            indent_heuristic,
9423            quote_path_fully,
9424        );
9425        let shifted = shift_unified_hunk_headers_to_full_file(&piece, s1, s2);
9426        let with_func =
9427            enrich_unified_hunk_headers_funcname(&shifted, &old_lines, funcname_matcher);
9428        for line in with_func.lines() {
9429            if line.starts_with("--- ") || line.starts_with("+++ ") {
9430                continue;
9431            }
9432            output.push_str(line);
9433            output.push('\n');
9434        }
9435    }
9436
9437    output
9438}
9439
9440/// `piece` is a unified diff for a slice of the file; hunk headers use 1-based
9441/// coordinates relative to that slice. Shift them by `delta_old` / `delta_new`
9442/// (0-based offsets of the slice in the full file) so the combined patch applies
9443/// to the whole file.
9444fn shift_unified_hunk_headers_to_full_file(
9445    patch: &str,
9446    delta_old: usize,
9447    delta_new: usize,
9448) -> String {
9449    if delta_old == 0 && delta_new == 0 {
9450        return patch.to_owned();
9451    }
9452    let mut out = String::with_capacity(patch.len());
9453    for line in patch.lines() {
9454        if let Some(shifted) = shift_one_unified_hunk_header(line, delta_old, delta_new) {
9455            out.push_str(&shifted);
9456        } else {
9457            out.push_str(line);
9458        }
9459        out.push('\n');
9460    }
9461    out
9462}
9463
9464fn shift_one_unified_hunk_header(line: &str, delta_old: usize, delta_new: usize) -> Option<String> {
9465    let rest = line.strip_prefix("@@ ")?;
9466    let (old_chunk, after_plus) = rest.split_once(" +")?;
9467    let old_spec = old_chunk.strip_prefix('-')?;
9468    let (new_spec, suffix) = after_plus.split_once(" @@")?;
9469    let shifted_old = shift_unified_range_spec(old_spec, delta_old)?;
9470    let shifted_new = shift_unified_range_spec(new_spec, delta_new)?;
9471    Some(format!("@@ -{shifted_old} +{shifted_new} @@{suffix}"))
9472}
9473
9474fn shift_unified_range_spec(spec: &str, delta: usize) -> Option<String> {
9475    let spec = spec.trim();
9476    if let Some((start_s, count_s)) = spec.split_once(',') {
9477        let start: usize = start_s.parse().ok()?;
9478        let count: usize = count_s.parse().ok()?;
9479        Some(format!("{},{}", start.saturating_add(delta), count))
9480    } else {
9481        let start: usize = spec.parse().ok()?;
9482        Some(format!("{}", start.saturating_add(delta)))
9483    }
9484}
9485
9486/// Re-attach `@@ ... @@ <funcname>` using full-file line indices (inner diffs use slices).
9487fn enrich_unified_hunk_headers_funcname(
9488    patch: &str,
9489    full_old_lines: &[&str],
9490    funcname_matcher: Option<&FuncnameMatcher>,
9491) -> String {
9492    let mut out = String::with_capacity(patch.len());
9493    for line in patch.lines() {
9494        if let Some(fixed) = enrich_one_hunk_header_funcname(line, full_old_lines, funcname_matcher)
9495        {
9496            out.push_str(&fixed);
9497        } else {
9498            out.push_str(line);
9499        }
9500        out.push('\n');
9501    }
9502    out
9503}
9504
9505fn enrich_one_hunk_header_funcname(
9506    line: &str,
9507    full_old_lines: &[&str],
9508    funcname_matcher: Option<&FuncnameMatcher>,
9509) -> Option<String> {
9510    let after_at = line.strip_prefix("@@ ")?;
9511    let idx = after_at.find(" @@")?;
9512    let mid = after_at[..idx].trim();
9513    let tail = after_at[idx + 3..].trim_start();
9514    let header_for_parse = format!("@@ {mid} @@");
9515    let func = extract_function_context(&header_for_parse, full_old_lines, funcname_matcher, None);
9516    Some(if let Some(f) = func {
9517        format!("@@ {mid} @@ {f}")
9518    } else if !tail.is_empty() {
9519        format!("@@ {mid} @@ {tail}")
9520    } else {
9521        format!("@@ {mid} @@")
9522    })
9523}
9524
9525fn line_slice_for_diff_with_eof_nl(
9526    lines: &[&str],
9527    start: usize,
9528    end: usize,
9529    full_file_ends_with_newline: bool,
9530) -> String {
9531    if start >= end {
9532        return String::new();
9533    }
9534    let mut s = lines[start..end].join("\n");
9535    let slice_is_suffix_of_file = end == lines.len();
9536    let need_trailing_nl = if slice_is_suffix_of_file {
9537        full_file_ends_with_newline
9538    } else {
9539        true
9540    };
9541    if need_trailing_nl && !s.ends_with('\n') {
9542        s.push('\n');
9543    }
9544    s
9545}
9546
9547/// Map a 0-based old line index to the corresponding 0-based new line index using the full-file
9548/// diff ops (Git aligns context across deletions/insertions).
9549fn map_old_line_to_new(ops: &[similar::DiffOp], old_line: usize, n_new: usize) -> usize {
9550    use similar::DiffOp;
9551    let mut n = 0usize;
9552    for op in ops {
9553        match *op {
9554            DiffOp::Equal {
9555                old_index,
9556                new_index,
9557                len,
9558            } => {
9559                if old_index + len <= old_line {
9560                    n = new_index + len;
9561                    continue;
9562                }
9563                if old_index < old_line {
9564                    let take = old_line - old_index;
9565                    return (new_index + take).min(n_new);
9566                }
9567                return new_index.min(n_new);
9568            }
9569            DiffOp::Delete {
9570                old_index,
9571                old_len,
9572                new_index,
9573            } => {
9574                if old_index + old_len <= old_line {
9575                    n = new_index;
9576                    continue;
9577                }
9578                if old_index < old_line {
9579                    return new_index.min(n_new);
9580                }
9581            }
9582            DiffOp::Insert {
9583                old_index,
9584                new_index,
9585                new_len,
9586            } => {
9587                if old_index < old_line {
9588                    n = new_index + new_len;
9589                    continue;
9590                }
9591                if old_index == old_line {
9592                    // `old_line` is an exclusive end or insertion point aligned with this insert
9593                    // (e.g. EOF append maps to after the inserted block).
9594                    return (new_index + new_len).min(n_new);
9595                }
9596                return new_index.min(n_new);
9597            }
9598            DiffOp::Replace {
9599                old_index,
9600                old_len,
9601                new_index,
9602                new_len,
9603            } => {
9604                if old_index + old_len <= old_line {
9605                    n = new_index + new_len;
9606                    continue;
9607                }
9608                if old_index < old_line {
9609                    let into_old = old_line - old_index;
9610                    let mapped = new_index + into_old.min(new_len);
9611                    return mapped.min(n_new);
9612                }
9613                return new_index.min(n_new);
9614            }
9615        }
9616    }
9617    n.min(n_new)
9618}
9619
9620/// Parse `@@ -old +new @@` into 0-based half-open ranges in each file.
9621fn parse_unified_hunk_header_ranges(header: &str) -> Option<(usize, usize, usize, usize)> {
9622    let rest = header.strip_prefix("@@ ")?;
9623    let (old_tok, rest2) = rest.split_once(" +")?;
9624    let old_tok = old_tok.strip_prefix('-')?;
9625    let new_tok = rest2.split_once(" @@").map(|(a, _)| a)?;
9626
9627    fn parse_side(spec: &str) -> Option<(usize, usize)> {
9628        let spec = spec.trim();
9629        let (start_one_based, count) = if let Some((a, b)) = spec.split_once(',') {
9630            (a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)
9631        } else {
9632            let s = spec.parse::<usize>().ok()?;
9633            (s, 1usize)
9634        };
9635        let s0 = start_one_based.saturating_sub(1);
9636        let e0 = s0.saturating_add(count);
9637        Some((s0, e0))
9638    }
9639
9640    let (os, oe) = parse_side(old_tok)?;
9641    let (ns, ne) = parse_side(new_tok)?;
9642    Some((os, oe, ns, ne))
9643}
9644
9645/// Git `xemit.c`: when a hunk only inserts at EOF (first inserted line is `new_index == n_old`)
9646/// and the added text already contains a funcname line, do not pull extra context from the preimage.
9647fn append_with_whole_function_added(
9648    ops: &[similar::DiffOp],
9649    n_old: usize,
9650    n_new: usize,
9651    new_lines: &[&str],
9652    matcher: Option<&FuncnameMatcher>,
9653) -> bool {
9654    use similar::DiffOp;
9655    if n_old == 0 {
9656        return false;
9657    }
9658    let mut only_ins_or_eq = true;
9659    let mut min_new_ins = usize::MAX;
9660    for op in ops {
9661        match *op {
9662            DiffOp::Equal { .. } => {}
9663            DiffOp::Insert {
9664                new_index, new_len, ..
9665            } => {
9666                min_new_ins = min_new_ins.min(new_index);
9667                if new_len == 0 {
9668                    only_ins_or_eq = false;
9669                }
9670            }
9671            DiffOp::Delete { .. } | DiffOp::Replace { .. } => {
9672                only_ins_or_eq = false;
9673            }
9674        }
9675    }
9676    let mut insert_at_eof = false;
9677    for op in ops {
9678        if let DiffOp::Insert { old_index, .. } = *op {
9679            if old_index == n_old {
9680                insert_at_eof = true;
9681                break;
9682            }
9683        }
9684    }
9685    let append_at_eof = min_new_ins == n_old || insert_at_eof;
9686    if !only_ins_or_eq || !append_at_eof || min_new_ins == usize::MAX {
9687        return false;
9688    }
9689    // Git only skips preimage pull when the inserted block is clearly a new logical
9690    // function (see `xemit.c` walking `xdf2` for `is_func_rec`). A loose "any line
9691    // looks like a function" check would match `return` / `printf` and break `-W`
9692    // hunks that still need preimage context (t4051 `extended`).
9693    let mut j = min_new_ins;
9694    while j < n_new {
9695        let line = new_lines[j];
9696        if line.trim().is_empty() {
9697            j += 1;
9698            continue;
9699        }
9700        if let Some(m) = matcher {
9701            if m.match_line(line).is_some() {
9702                return true;
9703            }
9704        } else if inserted_block_starts_with_c_like_function_definition(line) {
9705            return true;
9706        }
9707        j += 1;
9708    }
9709    false
9710}
9711
9712fn inserted_block_starts_with_c_like_function_definition(line: &str) -> bool {
9713    let t = line.trim_start();
9714    let Some(open_paren) = t.find('(') else {
9715        return false;
9716    };
9717    let head = &t[..open_paren];
9718    let tokens: Vec<&str> = head.split_whitespace().collect();
9719    if tokens.len() < 2 {
9720        // `printf(...)`, `return (`, etc. — not `return_type name(`.
9721        return false;
9722    }
9723    let nameish = tokens.last().copied().unwrap_or("");
9724    let name = nameish.trim_end_matches(['*', '&']);
9725    if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
9726        return false;
9727    }
9728    let type_or_modifier = |tok: &str| {
9729        matches!(
9730            tok,
9731            "static"
9732                | "extern"
9733                | "inline"
9734                | "void"
9735                | "int"
9736                | "char"
9737                | "short"
9738                | "long"
9739                | "float"
9740                | "double"
9741                | "unsigned"
9742                | "signed"
9743                | "struct"
9744                | "enum"
9745                | "union"
9746                | "const"
9747                | "volatile"
9748                | "typedef"
9749        )
9750    };
9751    tokens[..tokens.len() - 1]
9752        .iter()
9753        .any(|tok| type_or_modifier(tok))
9754}
9755
9756fn hunk_old_change_end_exclusive(ops: &[similar::DiffOp]) -> usize {
9757    use similar::DiffOp;
9758    let mut max_o = 0usize;
9759    for op in ops {
9760        match *op {
9761            DiffOp::Delete {
9762                old_index, old_len, ..
9763            } => {
9764                max_o = max_o.max(old_index + old_len);
9765            }
9766            DiffOp::Replace {
9767                old_index, old_len, ..
9768            } => {
9769                max_o = max_o.max(old_index + old_len);
9770            }
9771            DiffOp::Insert { old_index, .. } => {
9772                // Pure insertions do not consume old lines; Git's post-context anchor is the
9773                // insertion point (`old_index`), not 0 (t4051 `extended`).
9774                max_o = max_o.max(old_index);
9775            }
9776            DiffOp::Equal { .. } => {}
9777        }
9778    }
9779    max_o
9780}
9781
9782fn func_context_old_anchor(ops: &[similar::DiffOp], n_old: usize) -> usize {
9783    use similar::DiffOp;
9784    let mut has_delete_or_replace = false;
9785    let mut min_del = usize::MAX;
9786    let mut min_ins_old = usize::MAX;
9787
9788    for op in ops {
9789        match *op {
9790            DiffOp::Delete {
9791                old_index, old_len, ..
9792            } => {
9793                has_delete_or_replace = true;
9794                min_del = min_del.min(old_index);
9795                min_del = min_del.min(old_index + old_len.saturating_sub(1));
9796            }
9797            DiffOp::Replace {
9798                old_index, old_len, ..
9799            } => {
9800                has_delete_or_replace = true;
9801                min_del = min_del.min(old_index);
9802                min_del = min_del.min(old_index + old_len.saturating_sub(1));
9803            }
9804            DiffOp::Insert { old_index, .. } => {
9805                min_ins_old = min_ins_old.min(old_index);
9806            }
9807            DiffOp::Equal { .. } => {}
9808        }
9809    }
9810
9811    let mut i1 = if has_delete_or_replace {
9812        min_del
9813    } else if min_ins_old != usize::MAX {
9814        min_ins_old
9815    } else {
9816        0
9817    };
9818
9819    let pure_insert = ops
9820        .iter()
9821        .all(|op| matches!(op, DiffOp::Insert { .. } | DiffOp::Equal { .. }))
9822        && ops.iter().any(|op| matches!(op, DiffOp::Insert { .. }));
9823
9824    if pure_insert && i1 >= n_old && n_old > 0 {
9825        i1 = n_old - 1;
9826    }
9827
9828    i1.min(n_old.saturating_sub(1))
9829}
9830
9831fn expand_func_pre_start(
9832    s1: usize,
9833    i1: usize,
9834    n_old: usize,
9835    old_lines: &[&str],
9836    matcher: Option<&FuncnameMatcher>,
9837) -> usize {
9838    if n_old == 0 {
9839        return s1;
9840    }
9841    let i1 = i1.min(n_old.saturating_sub(1));
9842    let mut fs1 = get_func_line_backward(old_lines, i1, matcher).unwrap_or(i1);
9843    while fs1 > 0
9844        && !is_line_empty_for_func_context(old_lines[fs1 - 1])
9845        && !is_func_line(old_lines[fs1 - 1], matcher)
9846    {
9847        fs1 -= 1;
9848    }
9849    s1.min(fs1)
9850}
9851
9852fn expand_func_post_end(
9853    e1: usize,
9854    i1_end: usize,
9855    n_old: usize,
9856    old_lines: &[&str],
9857    matcher: Option<&FuncnameMatcher>,
9858) -> usize {
9859    let from = i1_end.min(n_old);
9860    let fe1 = get_func_line_forward(old_lines, from, matcher).unwrap_or(n_old);
9861    let mut fe1_adj = fe1;
9862    while fe1_adj > 0 && is_line_empty_for_func_context(old_lines[fe1_adj - 1]) {
9863        fe1_adj -= 1;
9864    }
9865    e1.max(fe1_adj).min(n_old)
9866}
9867
9868fn is_line_empty_for_func_context(line: &str) -> bool {
9869    line.chars().all(|c| c.is_whitespace())
9870}
9871
9872pub(crate) fn is_func_line(line: &str, matcher: Option<&FuncnameMatcher>) -> bool {
9873    if let Some(m) = matcher {
9874        return m.match_line(line).is_some();
9875    }
9876    let t = line.trim_end_matches(['\n', '\r']);
9877    if t.is_empty() {
9878        return false;
9879    }
9880    let b = t.as_bytes()[0];
9881    if !(b.is_ascii_alphabetic() || b == b'_' || b == b'$') {
9882        return false;
9883    }
9884    true
9885}
9886
9887fn get_func_line_backward(
9888    old_lines: &[&str],
9889    start: usize,
9890    matcher: Option<&FuncnameMatcher>,
9891) -> Option<usize> {
9892    let mut l = start.min(old_lines.len().saturating_sub(1));
9893    if old_lines.is_empty() {
9894        return None;
9895    }
9896    loop {
9897        if is_func_line(old_lines[l], matcher) {
9898            return Some(l);
9899        }
9900        if l == 0 {
9901            break;
9902        }
9903        l -= 1;
9904    }
9905    None
9906}
9907
9908fn get_func_line_forward(
9909    old_lines: &[&str],
9910    start: usize,
9911    matcher: Option<&FuncnameMatcher>,
9912) -> Option<usize> {
9913    let mut l = start;
9914    while l < old_lines.len() {
9915        if is_func_line(old_lines[l], matcher) {
9916            return Some(l);
9917        }
9918        l += 1;
9919    }
9920    None
9921}
9922#[expect(clippy::too_many_arguments)]
9923/// Compute a unified diff with anchored lines.
9924///
9925/// Anchored lines that appear exactly once in both old and new content are
9926/// forced to match, splitting the diff into segments around those anchor points.
9927/// This produces diffs where the anchored text stays as context and surrounding
9928/// lines are shown as additions/removals.
9929///
9930/// Segment diffs use `algorithm`. When `use_git_histogram` is true, histogram uses imara-diff
9931/// (Git-compatible); otherwise `algorithm` is passed to `similar`.
9932pub fn anchored_unified_diff(
9933    old_content: &str,
9934    new_content: &str,
9935    old_path: &str,
9936    new_path: &str,
9937    context_lines: usize,
9938    anchors: &[String],
9939    algorithm: similar::Algorithm,
9940    use_git_histogram: bool,
9941    indent_heuristic: bool,
9942    quote_path_fully: bool,
9943) -> String {
9944    use crate::quote_path::format_diff_path_with_prefix;
9945    use similar::TextDiff;
9946
9947    let old_lines: Vec<&str> = old_content.lines().collect();
9948    let new_lines: Vec<&str> = new_content.lines().collect();
9949
9950    // Find anchored lines that appear exactly once in both old and new
9951    let mut anchor_pairs: Vec<(usize, usize)> = Vec::new(); // (old_idx, new_idx)
9952
9953    for anchor in anchors {
9954        let anchor_str = anchor.as_str();
9955
9956        // Count occurrences in old
9957        let old_positions: Vec<usize> = old_lines
9958            .iter()
9959            .enumerate()
9960            .filter(|(_, l)| l.trim_end() == anchor_str)
9961            .map(|(i, _)| i)
9962            .collect();
9963
9964        // Count occurrences in new
9965        let new_positions: Vec<usize> = new_lines
9966            .iter()
9967            .enumerate()
9968            .filter(|(_, l)| l.trim_end() == anchor_str)
9969            .map(|(i, _)| i)
9970            .collect();
9971
9972        // Only anchor if unique in both
9973        if old_positions.len() == 1 && new_positions.len() == 1 {
9974            anchor_pairs.push((old_positions[0], new_positions[0]));
9975        }
9976    }
9977
9978    // If no valid anchors, fall back to normal diff
9979    if anchor_pairs.is_empty() {
9980        return unified_diff_with_prefix_and_funcname_and_algorithm(
9981            old_content,
9982            new_content,
9983            old_path,
9984            new_path,
9985            context_lines,
9986            0,
9987            "a/",
9988            "b/",
9989            None,
9990            algorithm,
9991            false,
9992            use_git_histogram,
9993            indent_heuristic,
9994            quote_path_fully,
9995        );
9996    }
9997
9998    // Sort anchor pairs by their position in the old file
9999    anchor_pairs.sort_by_key(|&(old_idx, _)| old_idx);
10000
10001    // Filter to only keep pairs where new positions are also increasing
10002    // (longest increasing subsequence of new positions)
10003    let mut filtered: Vec<(usize, usize)> = Vec::new();
10004    for &pair in &anchor_pairs {
10005        if filtered.is_empty() || filtered.last().is_some_and(|last| pair.1 > last.1) {
10006            filtered.push(pair);
10007        }
10008    }
10009    let anchor_pairs = filtered;
10010
10011    // Build a modified version of old/new where we diff segments between anchors.
10012    // We'll construct the diff by processing segments:
10013    // - Before first anchor
10014    // - Between consecutive anchors
10015    // - After last anchor
10016    // Each anchor line itself is a fixed context match.
10017
10018    // Collect all diff operations
10019    struct LineDiffOp {
10020        tag: char, // ' ', '+', '-'
10021        line: String,
10022    }
10023
10024    let append_segment_diff =
10025        |ops: &mut Vec<LineDiffOp>, old_seg_input: &str, new_seg_input: &str| {
10026            use similar::ChangeTag;
10027            let old_ls: Vec<&str> = old_seg_input.lines().collect();
10028            let new_ls: Vec<&str> = new_seg_input.lines().collect();
10029            if old_ls.is_empty() && new_ls.is_empty() {
10030                return;
10031            }
10032            let seg_diff = TextDiff::configure()
10033                .algorithm(algorithm)
10034                .diff_slices(&old_ls, &new_ls);
10035            let raw = seg_diff.ops().to_vec();
10036            let compacted = diff_indent_heuristic::apply_change_compact_to_ops(
10037                &raw,
10038                &old_ls,
10039                &new_ls,
10040                indent_heuristic,
10041            );
10042            for op in &compacted {
10043                for ch in op.iter_changes(&old_ls, &new_ls) {
10044                    let t = match ch.tag() {
10045                        ChangeTag::Equal => ' ',
10046                        ChangeTag::Delete => '-',
10047                        ChangeTag::Insert => '+',
10048                    };
10049                    ops.push(LineDiffOp {
10050                        tag: t,
10051                        line: ch.value().to_string(),
10052                    });
10053                }
10054            }
10055        };
10056
10057    let mut ops: Vec<LineDiffOp> = Vec::new();
10058    let mut old_pos = 0usize;
10059    let mut new_pos = 0usize;
10060
10061    for &(old_anchor, new_anchor) in &anchor_pairs {
10062        // Diff the segment before this anchor
10063        let old_segment: Vec<&str> = old_lines[old_pos..old_anchor].to_vec();
10064        let new_segment: Vec<&str> = new_lines[new_pos..new_anchor].to_vec();
10065
10066        let old_seg_text = old_segment.join("\n");
10067        let new_seg_text = new_segment.join("\n");
10068
10069        if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
10070            let old_seg_input = if old_seg_text.is_empty() {
10071                String::new()
10072            } else {
10073                format!("{}\n", old_seg_text)
10074            };
10075            let new_seg_input = if new_seg_text.is_empty() {
10076                String::new()
10077            } else {
10078                format!("{}\n", new_seg_text)
10079            };
10080            append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
10081        }
10082
10083        // The anchor line itself is always context
10084        ops.push(LineDiffOp {
10085            tag: ' ',
10086            line: old_lines[old_anchor].to_string(),
10087        });
10088
10089        old_pos = old_anchor + 1;
10090        new_pos = new_anchor + 1;
10091    }
10092
10093    // Diff the remaining segment after the last anchor
10094    let old_segment: Vec<&str> = old_lines[old_pos..].to_vec();
10095    let new_segment: Vec<&str> = new_lines[new_pos..].to_vec();
10096    let old_seg_text = old_segment.join("\n");
10097    let new_seg_text = new_segment.join("\n");
10098
10099    if !old_seg_text.is_empty() || !new_seg_text.is_empty() {
10100        let old_seg_input = if old_seg_text.is_empty() {
10101            String::new()
10102        } else {
10103            format!("{}\n", old_seg_text)
10104        };
10105        let new_seg_input = if new_seg_text.is_empty() {
10106            String::new()
10107        } else {
10108            format!("{}\n", new_seg_text)
10109        };
10110        append_segment_diff(&mut ops, &old_seg_input, &new_seg_input);
10111    }
10112
10113    // Now format as unified diff with hunks
10114    let mut output = String::new();
10115    if old_path == "/dev/null" {
10116        output.push_str("--- /dev/null\n");
10117    } else {
10118        output.push_str("--- ");
10119        output.push_str(&format_diff_path_with_prefix(
10120            "a/",
10121            old_path,
10122            quote_path_fully,
10123        ));
10124        output.push('\n');
10125    }
10126    if new_path == "/dev/null" {
10127        output.push_str("+++ /dev/null\n");
10128    } else {
10129        output.push_str("+++ ");
10130        output.push_str(&format_diff_path_with_prefix(
10131            "b/",
10132            new_path,
10133            quote_path_fully,
10134        ));
10135        output.push('\n');
10136    }
10137
10138    // Group ops into hunks with context
10139    let total_ops = ops.len();
10140    if total_ops == 0 {
10141        return output;
10142    }
10143
10144    // Find ranges of changes
10145    let mut hunks: Vec<(usize, usize)> = Vec::new(); // (start, end) indices into ops
10146    let mut i = 0;
10147    while i < total_ops {
10148        if ops[i].tag != ' ' {
10149            let start = i.saturating_sub(context_lines);
10150            let mut end = i;
10151            // Extend to include consecutive changes and their context
10152            while end < total_ops {
10153                if ops[end].tag != ' ' {
10154                    end += 1;
10155                    continue;
10156                }
10157                // Check if there's another change within context_lines
10158                let mut next_change = end;
10159                while next_change < total_ops && ops[next_change].tag == ' ' {
10160                    next_change += 1;
10161                }
10162                if next_change < total_ops && next_change - end <= context_lines * 2 {
10163                    end = next_change + 1;
10164                } else {
10165                    end = (end + context_lines).min(total_ops);
10166                    break;
10167                }
10168            }
10169            // Merge with previous hunk if overlapping
10170            if let Some(last) = hunks.last_mut() {
10171                if start <= last.1 {
10172                    last.1 = end;
10173                } else {
10174                    hunks.push((start, end));
10175                }
10176            } else {
10177                hunks.push((start, end));
10178            }
10179            i = end;
10180        } else {
10181            i += 1;
10182        }
10183    }
10184
10185    // Output each hunk
10186    for (start, end) in hunks {
10187        // Count old/new lines in this hunk
10188        let mut old_start = 1usize;
10189        let mut new_start = 1usize;
10190        // Calculate line numbers by counting ops before this hunk
10191        for op in &ops[..start] {
10192            match op.tag {
10193                ' ' => {
10194                    old_start += 1;
10195                    new_start += 1;
10196                }
10197                '-' => {
10198                    old_start += 1;
10199                }
10200                '+' => {
10201                    new_start += 1;
10202                }
10203                _ => {}
10204            }
10205        }
10206        let mut old_count = 0usize;
10207        let mut new_count = 0usize;
10208        for op in &ops[start..end] {
10209            match op.tag {
10210                ' ' => {
10211                    old_count += 1;
10212                    new_count += 1;
10213                }
10214                '-' => {
10215                    old_count += 1;
10216                }
10217                '+' => {
10218                    new_count += 1;
10219                }
10220                _ => {}
10221            }
10222        }
10223
10224        output.push_str(&format!(
10225            "@@ -{},{} +{},{} @@\n",
10226            old_start, old_count, new_start, new_count
10227        ));
10228        for op in &ops[start..end] {
10229            output.push(op.tag);
10230            output.push_str(&op.line);
10231            output.push('\n');
10232        }
10233    }
10234
10235    output
10236}
10237
10238/// Extract function context for a hunk header.
10239///
10240/// Given a hunk header like `@@ -8,7 +8,7 @@`, find the last line
10241/// before line 8 in the old content that looks like a function header
10242/// (starts with a non-whitespace character, like Git's default).
10243fn func_context_line_matches(line: &str, funcname_matcher: Option<&FuncnameMatcher>) -> bool {
10244    if let Some(m) = funcname_matcher {
10245        return m.match_line(line).is_some();
10246    }
10247    is_func_line(line, None)
10248}
10249
10250fn extract_function_context(
10251    header: &str,
10252    old_lines: &[&str],
10253    funcname_matcher: Option<&FuncnameMatcher>,
10254    _source_path: Option<&str>,
10255) -> Option<String> {
10256    // Parse the old start line number from "@@ -<start>,<count> ..."
10257    let at_pos = header.find("-")?;
10258    let rest = &header[at_pos + 1..];
10259    let comma_or_space = rest.find([',', ' '])?;
10260    let start_str = &rest[..comma_or_space];
10261    let start_line: usize = start_str.parse().ok()?;
10262
10263    // Parse the old line count; "@@ -<start>,<count> ..." (no comma means count 1).
10264    // Only look for the comma inside the old-range token itself — searching the
10265    // whole remainder would pick up the comma from the new side (e.g. "+0,0").
10266    let old_token_end = rest.find([' ', '\t']).unwrap_or(rest.len());
10267    let old_token = &rest[..old_token_end];
10268    let old_count: usize = if let Some(comma) = old_token.find(',') {
10269        old_token[comma + 1..].parse().unwrap_or(1)
10270    } else {
10271        1
10272    };
10273
10274    if start_line == 0 {
10275        return None;
10276    }
10277
10278    // Look backwards for a line that matches the funcname pattern. start_line is
10279    // 1-indexed. For a normal hunk the first changed pre-image line is
10280    // old_lines[start_line-1], so we search lines strictly before it
10281    // (old_lines[0..start_line-1]). For a pure insertion (old count 0) the
10282    // content is inserted *after* old line start_line, so Git's function search
10283    // begins at that line itself: search old_lines[0..start_line].
10284    if start_line == 0 {
10285        return None;
10286    }
10287    let search_end = if old_count == 0 {
10288        start_line.min(old_lines.len())
10289    } else {
10290        if start_line <= 1 {
10291            return None;
10292        }
10293        (start_line - 1).min(old_lines.len())
10294    };
10295    let truncate = |line: &str| {
10296        let mut end = line.len().min(80);
10297        while end > 0 && !line.is_char_boundary(end) {
10298            end -= 1;
10299        }
10300        let mut out = line[..end].to_owned();
10301        let tail = line[end..].trim_start();
10302        // Git drops trailing whitespace on truncated funcname lines when the
10303        // remainder of the source line continues with normal text (t2021-style
10304        // shell tests), but keeps it when the next token is a string delimiter
10305        // (gettext `.po` msgstr lines).
10306        if tail.is_empty()
10307            || (!tail.starts_with('"')
10308                && !tail.starts_with('\'')
10309                && !line.starts_with("msgstr")
10310                && !line.starts_with("msgid"))
10311        {
10312            out = out.trim_end().to_owned();
10313        }
10314        out
10315    };
10316
10317    for i in (0..search_end).rev() {
10318        let line = old_lines[i];
10319        if line.is_empty() {
10320            continue;
10321        }
10322        if func_context_line_matches(line, funcname_matcher) {
10323            if let Some(matcher) = funcname_matcher {
10324                if let Some(matched) = matcher.match_line(line) {
10325                    return Some(truncate(&matched));
10326                }
10327                continue;
10328            }
10329            return Some(truncate(line));
10330        }
10331    }
10332    None
10333}
10334
10335/// Generate diff stat output (file name + insertions/deletions).
10336///
10337/// Returns a single line like: ` file.txt | 5 ++---`
10338pub fn format_stat_line(
10339    path: &str,
10340    insertions: usize,
10341    deletions: usize,
10342    max_path_len: usize,
10343) -> String {
10344    format_stat_line_width(path, insertions, deletions, max_path_len, 0)
10345}
10346
10347/// Format one `--stat` line with explicit path and count column widths.
10348pub fn format_stat_line_width(
10349    path: &str,
10350    insertions: usize,
10351    deletions: usize,
10352    max_path_len: usize,
10353    count_width: usize,
10354) -> String {
10355    let mut line =
10356        crate::diffstat::format_single_stat_line(path, insertions, deletions, max_path_len)
10357            .unwrap_or_else(|_| String::new());
10358    if count_width > 0 && line.contains(" | ") {
10359        let total = insertions + deletions;
10360        let cw = count_width;
10361        if let Some((left, rest)) = line.split_once(" | ") {
10362            if let Some((_, bar)) = rest.split_once(' ') {
10363                line = format!(" {left} | {total:>cw$} {bar}", cw = cw);
10364            } else {
10365                line = format!(" {left} | {total:>cw$}", cw = cw);
10366            }
10367        }
10368    }
10369    line
10370}
10371
10372fn last_line_index_without_trailing_newline(text: &str) -> Option<usize> {
10373    LineSide::from_text(text).incomplete_last_line_index()
10374}
10375
10376fn parse_unified_hunk_starts(header: &str) -> Option<(usize, usize)> {
10377    let rest = header.strip_prefix("@@ ")?;
10378    let (old_part, new_part) = rest.split_once(" +")?;
10379    let old_start = parse_unified_range_start(old_part.strip_prefix('-')?)?;
10380    let new_start = parse_unified_range_start(new_part.split(" @@").next()?)?;
10381    Some((old_start, new_start))
10382}
10383
10384fn parse_unified_range_start(spec: &str) -> Option<usize> {
10385    let spec = spec.trim();
10386    if let Some((start, _)) = spec.split_once(',') {
10387        start.parse().ok()
10388    } else {
10389        spec.parse().ok()
10390    }
10391}
10392
10393/// Tracks hunk line numbers while streaming `\ No newline at end of file` markers.
10394struct UnifiedDiffNoNewlineAnnotator {
10395    old_last: Option<usize>,
10396    new_last: Option<usize>,
10397    old_line: usize,
10398    new_line: usize,
10399    passthrough: bool,
10400}
10401
10402impl UnifiedDiffNoNewlineAnnotator {
10403    fn new(old: &str, new: &str) -> Self {
10404        let old_last = last_line_index_without_trailing_newline(old);
10405        let new_last = last_line_index_without_trailing_newline(new);
10406        Self {
10407            old_last,
10408            new_last,
10409            old_line: 1,
10410            new_line: 1,
10411            passthrough: old_last.is_none() && new_last.is_none(),
10412        }
10413    }
10414
10415    fn write_hunk<W: std::io::Write>(&mut self, out: &mut W, patch: &str) -> Result<()> {
10416        if self.passthrough {
10417            out.write_all(patch.as_bytes())?;
10418            return Ok(());
10419        }
10420        let mut lines = patch.lines().peekable();
10421        while let Some(line) = lines.next() {
10422            self.write_annotated_line(out, line, &mut lines)?;
10423        }
10424        Ok(())
10425    }
10426
10427    fn write_annotated_line<W: std::io::Write>(
10428        &mut self,
10429        out: &mut W,
10430        line: &str,
10431        lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
10432    ) -> Result<()> {
10433        if let Some((o, n)) = line
10434            .strip_prefix("@@ ")
10435            .and_then(|_| parse_unified_hunk_starts(line))
10436        {
10437            self.old_line = o;
10438            self.new_line = n;
10439            writeln!(out, "{line}")?;
10440            return Ok(());
10441        }
10442        if line.starts_with("--- ")
10443            || line.starts_with("+++ ")
10444            || line.starts_with("diff --git ")
10445            || line.starts_with("diff --cc ")
10446            || line.starts_with("diff --combined ")
10447        {
10448            writeln!(out, "{line}")?;
10449            return Ok(());
10450        }
10451        if line.starts_with('-') {
10452            writeln!(out, "{line}")?;
10453            let already_marked = lines
10454                .peek()
10455                .is_some_and(|next| *next == "\\ No newline at end of file");
10456            if !already_marked && self.old_last == Some(self.old_line.saturating_sub(1)) {
10457                writeln!(out, "\\ No newline at end of file")?;
10458            }
10459            self.old_line += 1;
10460            return Ok(());
10461        }
10462        if line.starts_with('+') {
10463            writeln!(out, "{line}")?;
10464            let already_marked = lines
10465                .peek()
10466                .is_some_and(|next| *next == "\\ No newline at end of file");
10467            if !already_marked && self.new_last == Some(self.new_line.saturating_sub(1)) {
10468                writeln!(out, "\\ No newline at end of file")?;
10469            }
10470            self.new_line += 1;
10471            return Ok(());
10472        }
10473        if line.starts_with(' ') {
10474            writeln!(out, "{line}")?;
10475            let already_marked = lines
10476                .peek()
10477                .is_some_and(|next| *next == "\\ No newline at end of file");
10478            if !already_marked {
10479                let old_idx = self.old_line.saturating_sub(1);
10480                let new_idx = self.new_line.saturating_sub(1);
10481                if self.old_last == Some(old_idx) || self.new_last == Some(new_idx) {
10482                    writeln!(out, "\\ No newline at end of file")?;
10483                }
10484            }
10485            self.old_line += 1;
10486            self.new_line += 1;
10487            return Ok(());
10488        }
10489        writeln!(out, "{line}")?;
10490        Ok(())
10491    }
10492}
10493
10494/// Insert Git `\ No newline at end of file` after `-`/`+` hunk lines when that side of the
10495/// file lacks a trailing newline on the last line.
10496#[must_use]
10497pub fn annotate_unified_diff_no_newline_at_eof(patch: &str, old: &str, new: &str) -> String {
10498    let mut annotator = UnifiedDiffNoNewlineAnnotator::new(old, new);
10499    if annotator.passthrough {
10500        return patch.to_string();
10501    }
10502    let mut out = Vec::with_capacity(patch.len() + 64);
10503    let mut lines = patch.lines().peekable();
10504    while let Some(line) = lines.next() {
10505        if annotator
10506            .write_annotated_line(&mut out, line, &mut lines)
10507            .is_err()
10508        {
10509            return patch.to_string();
10510        }
10511    }
10512    String::from_utf8(out).unwrap_or_else(|_| patch.to_string())
10513}
10514
10515/// Normalise one line like Git's `-b` / `--ignore-space-change`.
10516#[must_use]
10517pub fn normalize_ignore_space_change_line(line: &str) -> String {
10518    let mut result = String::with_capacity(line.len());
10519    let mut in_space = false;
10520    for c in line.chars() {
10521        if c.is_whitespace() {
10522            if !in_space {
10523                result.push(' ');
10524                in_space = true;
10525            }
10526        } else {
10527            result.push(c);
10528            in_space = false;
10529        }
10530    }
10531    while result.ends_with(' ') {
10532        result.pop();
10533    }
10534    result
10535}
10536
10537/// Normalise text like Git's `-b` / `--ignore-space-change`: on each line, collapse runs of
10538/// whitespace to a single ASCII space and trim trailing spaces.
10539///
10540/// Line breaks are preserved by splitting on [`str::lines`] and rejoining with `\n` (same approach
10541/// as the porcelain `diff` whitespace handling in `grit`).
10542#[must_use]
10543pub fn normalize_ignore_space_change(content: &str) -> String {
10544    content
10545        .lines()
10546        .map(normalize_ignore_space_change_line)
10547        .collect::<Vec<_>>()
10548        .join("\n")
10549}
10550
10551/// Count insertions and deletions between two strings.
10552///
10553/// Returns `(insertions, deletions)`.
10554pub fn count_changes(old_content: &str, new_content: &str) -> (usize, usize) {
10555    count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, false)
10556}
10557
10558/// Count insertions and deletions using the given line-diff algorithm.
10559///
10560/// Git's `--stat` / `--numstat` follow the configured diff algorithm; this mirrors that by
10561/// running [`similar::TextDiff`] with an explicit [`similar::Algorithm`].
10562#[must_use]
10563pub fn count_changes_with_algorithm(
10564    old_content: &str,
10565    new_content: &str,
10566    algorithm: similar::Algorithm,
10567    use_git_histogram: bool,
10568) -> (usize, usize) {
10569    if use_git_histogram {
10570        use imara_diff::{Algorithm, Diff, InternedInput};
10571        let input = InternedInput::new(old_content, new_content);
10572        let mut d = Diff::compute(Algorithm::Histogram, &input);
10573        d.postprocess_lines(&input);
10574        return (d.count_additions() as usize, d.count_removals() as usize);
10575    }
10576
10577    use similar::{ChangeTag, TextDiff};
10578
10579    let diff = TextDiff::configure()
10580        .algorithm(algorithm)
10581        .diff_lines(old_content, new_content);
10582    let mut ins = 0;
10583    let mut del = 0;
10584
10585    for change in diff.iter_all_changes() {
10586        match change.tag() {
10587            ChangeTag::Insert => ins += 1,
10588            ChangeTag::Delete => del += 1,
10589            ChangeTag::Equal => {}
10590        }
10591    }
10592
10593    (ins, del)
10594}
10595
10596/// Count insertions and deletions using Git's histogram line-diff engine.
10597#[must_use]
10598pub fn count_changes_git_histogram(old_content: &str, new_content: &str) -> (usize, usize) {
10599    count_changes_with_algorithm(old_content, new_content, similar::Algorithm::Myers, true)
10600}
10601
10602/// Line count for diffstat/`--numstat`, matching Git's `count_lines()` in `diff.c`.
10603///
10604/// Counts newline-terminated lines; a final line without trailing newline still counts as one line.
10605/// An empty buffer yields `0`.
10606#[must_use]
10607pub fn count_git_lines(data: &[u8]) -> usize {
10608    if data.is_empty() {
10609        return 0;
10610    }
10611    let mut count = 0usize;
10612    let mut nl_just_seen = false;
10613    for &ch in data {
10614        if ch == b'\n' {
10615            count += 1;
10616            nl_just_seen = true;
10617        } else {
10618            nl_just_seen = false;
10619        }
10620    }
10621    if !nl_just_seen {
10622        count += 1;
10623    }
10624    count
10625}
10626
10627/// Internal maximum diff score used by Git rename/break heuristics (`MAX_SCORE` in `diffcore.h`).
10628pub const GIT_DIFF_MAX_SCORE: u64 = 60_000;
10629const DIFF_MAX_SCORE: u64 = GIT_DIFF_MAX_SCORE;
10630const DIFF_MINIMUM_BREAK_SIZE: usize = 400;
10631const DIFF_DEFAULT_BREAK_SCORE: u64 = 30_000;
10632/// Default break threshold (`DEFAULT_BREAK_SCORE` in `diffcore.h`), internal 0–[`GIT_DIFF_MAX_SCORE`] scale.
10633pub const GIT_DIFF_DEFAULT_BREAK_SCORE: u64 = DIFF_DEFAULT_BREAK_SCORE;
10634/// Default merge threshold after a break (`DEFAULT_MERGE_SCORE` in `diffcore.h`): pairs broken for
10635/// rename/copy but not consumed are merged back when deletion-weight is below this (60% by default).
10636pub const GIT_DIFF_DEFAULT_MERGE_SCORE_AFTER_BREAK: u64 = 36_000;
10637const DIFF_HASHBASE: u32 = 107_927;
10638
10639#[derive(Clone, Copy, Default)]
10640struct SpanSlot {
10641    hashval: u32,
10642    cnt: u32,
10643}
10644
10645struct SpanHashTop {
10646    alloc_log2: u8,
10647    free_slots: i32,
10648    data: Vec<SpanSlot>,
10649}
10650
10651impl SpanHashTop {
10652    fn new(initial_log2: u8) -> Self {
10653        let cap = 1usize << initial_log2;
10654        Self {
10655            alloc_log2: initial_log2,
10656            free_slots: initial_free(initial_log2),
10657            data: vec![SpanSlot::default(); cap],
10658        }
10659    }
10660
10661    fn len(&self) -> usize {
10662        1usize << self.alloc_log2
10663    }
10664
10665    fn add_span(&mut self, hashval: u32, cnt: u32) {
10666        loop {
10667            let lim = self.len();
10668            let mut bucket = (hashval as usize) & (lim - 1);
10669            loop {
10670                let h = &mut self.data[bucket];
10671                if h.cnt == 0 {
10672                    h.hashval = hashval;
10673                    h.cnt = cnt;
10674                    self.free_slots -= 1;
10675                    if self.free_slots < 0 {
10676                        self.rehash();
10677                        break;
10678                    }
10679                    return;
10680                }
10681                if h.hashval == hashval {
10682                    h.cnt = h.cnt.saturating_add(cnt);
10683                    return;
10684                }
10685                bucket += 1;
10686                if bucket >= lim {
10687                    bucket = 0;
10688                }
10689            }
10690        }
10691    }
10692
10693    fn rehash(&mut self) {
10694        let old = std::mem::take(&mut self.data);
10695        let old_log = self.alloc_log2;
10696        self.alloc_log2 = old_log.saturating_add(1);
10697        let new_len = 1usize << self.alloc_log2;
10698        self.free_slots = initial_free(self.alloc_log2);
10699        self.data = vec![SpanSlot::default(); new_len];
10700        let old_sz = 1usize << old_log;
10701        for o in old.iter().take(old_sz) {
10702            let o = *o;
10703            if o.cnt == 0 {
10704                continue;
10705            }
10706            self.add_span_after_rehash(o.hashval, o.cnt);
10707        }
10708    }
10709
10710    fn add_span_after_rehash(&mut self, hashval: u32, cnt: u32) {
10711        loop {
10712            let lim = self.len();
10713            let mut bucket = (hashval as usize) & (lim - 1);
10714            loop {
10715                let h = &mut self.data[bucket];
10716                if h.cnt == 0 {
10717                    h.hashval = hashval;
10718                    h.cnt = cnt;
10719                    self.free_slots -= 1;
10720                    if self.free_slots < 0 {
10721                        self.rehash();
10722                        break;
10723                    }
10724                    return;
10725                }
10726                if h.hashval == hashval {
10727                    h.cnt = h.cnt.saturating_add(cnt);
10728                    return;
10729                }
10730                bucket += 1;
10731                if bucket >= lim {
10732                    bucket = 0;
10733                }
10734            }
10735        }
10736    }
10737
10738    fn sort_by_hashval(&mut self) {
10739        let sz = self.len();
10740        self.data[..sz].sort_by(|a, b| {
10741            if a.cnt == 0 {
10742                return std::cmp::Ordering::Greater;
10743            }
10744            if b.cnt == 0 {
10745                return std::cmp::Ordering::Less;
10746            }
10747            a.hashval.cmp(&b.hashval)
10748        });
10749    }
10750}
10751
10752fn initial_free(sz_log2: u8) -> i32 {
10753    let sz = sz_log2 as i32;
10754    ((1i32 << sz_log2) * (sz - 3) / sz).max(0)
10755}
10756
10757fn hash_blob_spans(buf: &[u8], is_text: bool) -> SpanHashTop {
10758    let mut hash = SpanHashTop::new(9);
10759    let mut n = 0u32;
10760    let mut accum1: u32 = 0;
10761    let mut accum2: u32 = 0;
10762    let mut i = 0usize;
10763    while i < buf.len() {
10764        let c = buf[i] as u32;
10765        let old_1 = accum1;
10766        i += 1;
10767
10768        if is_text && c == b'\r' as u32 && i < buf.len() && buf[i] == b'\n' {
10769            continue;
10770        }
10771
10772        accum1 = accum1.wrapping_shl(7) ^ accum2.wrapping_shr(25);
10773        accum2 = accum2.wrapping_shl(7) ^ old_1.wrapping_shr(25);
10774        accum1 = accum1.wrapping_add(c);
10775        n += 1;
10776        if n < 64 && c != b'\n' as u32 {
10777            continue;
10778        }
10779        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
10780        hash.add_span(hashval, n);
10781        n = 0;
10782        accum1 = 0;
10783        accum2 = 0;
10784    }
10785    if n > 0 {
10786        let hashval = (accum1.wrapping_add(accum2.wrapping_mul(0x61))) % DIFF_HASHBASE;
10787        hash.add_span(hashval, n);
10788    }
10789    hash.sort_by_hashval();
10790    hash
10791}
10792
10793/// Approximate copied vs added material between two blobs (Git `diffcore_count_changes`).
10794///
10795/// Returns `(copied_bytes_from_src, literal_added_bytes_in_dst)` matching Git's
10796/// `diffcore_count_changes` semantics (used for `--dirstat=changes` damage).
10797#[must_use]
10798pub fn diffcore_count_changes(old: &[u8], new: &[u8]) -> (u64, u64) {
10799    crate::trace::trace_span!("grit.diff.blob");
10800    let src_is_text = !crate::merge_file::is_binary(old);
10801    let dst_is_text = !crate::merge_file::is_binary(new);
10802    let src_count = hash_blob_spans(old, src_is_text);
10803    let dst_count = hash_blob_spans(new, dst_is_text);
10804    let mut sc: u64 = 0;
10805    let mut la: u64 = 0;
10806    let mut si = 0usize;
10807    let mut di = 0usize;
10808    let src_len = src_count.len();
10809    let dst_len = dst_count.len();
10810    loop {
10811        if si >= src_len || src_count.data[si].cnt == 0 {
10812            break;
10813        }
10814        let s_hash = src_count.data[si].hashval;
10815        let s_cnt = u64::from(src_count.data[si].cnt);
10816        while di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval < s_hash {
10817            la += u64::from(dst_count.data[di].cnt);
10818            di += 1;
10819        }
10820        let mut dst_cnt = 0u64;
10821        if di < dst_len && dst_count.data[di].cnt != 0 && dst_count.data[di].hashval == s_hash {
10822            dst_cnt = u64::from(dst_count.data[di].cnt);
10823            di += 1;
10824        }
10825        if s_cnt < dst_cnt {
10826            la += dst_cnt - s_cnt;
10827            sc += s_cnt;
10828        } else {
10829            sc += dst_cnt;
10830        }
10831        si += 1;
10832    }
10833    while di < dst_len && dst_count.data[di].cnt != 0 {
10834        la += u64::from(dst_count.data[di].cnt);
10835        di += 1;
10836    }
10837    (sc, la)
10838}
10839
10840/// Whether this modified blob pair should use Git's "complete rewrite" diffstat path when
10841/// `--break-rewrites` is in effect (`should_break` in `diffcore-break.c`).
10842#[must_use]
10843pub fn should_break_rewrite_for_stat(old: &[u8], new: &[u8]) -> bool {
10844    should_break_rewrite_inner(old, new, DIFF_DEFAULT_BREAK_SCORE)
10845}
10846
10847/// Whether an in-place blob edit should be split into delete+create for rename/copy (`should_break`
10848/// in `diffcore-break.c`). `break_score` is on the internal 0–[`GIT_DIFF_MAX_SCORE`] scale (default
10849/// `DIFF_DEFAULT_BREAK_SCORE`).
10850#[must_use]
10851pub fn should_break_rewrite_pair(old: &[u8], new: &[u8], break_score: u64) -> bool {
10852    should_break_rewrite_inner(old, new, break_score)
10853}
10854
10855/// Drop entries that do not match `filter` (Git `--diff-filter`, after rename detection).
10856#[must_use]
10857pub fn apply_diff_filter(
10858    entries: Vec<DiffEntry>,
10859    filter: Option<super::filter::DiffFilter>,
10860) -> Vec<DiffEntry> {
10861    match filter {
10862        Some(f) => f.filter_entries(entries),
10863        None => entries,
10864    }
10865}
10866
10867/// Parse `-M` / `--find-renames` threshold into Git's 0–100 similarity percent.
10868#[must_use]
10869pub fn parse_diff_rename_threshold_percent(arg: &str) -> Option<u8> {
10870    let token = if arg.is_empty() { "50" } else { arg };
10871    let internal = parse_diff_rename_score_token(token)?;
10872    Some((internal.saturating_mul(100) / GIT_DIFF_MAX_SCORE).min(100) as u8)
10873}
10874
10875/// Parse a single Git `parse_rename_score` token (`50`, `50%`, decimal forms) into internal
10876/// 0–[`GIT_DIFF_MAX_SCORE`] units.
10877pub fn parse_diff_rename_score_token(arg: &str) -> Option<u64> {
10878    let mut num: u64 = 0;
10879    let mut scale: u64 = 1;
10880    let mut dot = false;
10881    let mut saw_digit = false;
10882    for ch in arg.chars() {
10883        if !dot && ch == '.' {
10884            scale = 1;
10885            dot = true;
10886            continue;
10887        }
10888        if ch == '%' {
10889            scale = if dot { scale.saturating_mul(100) } else { 100 };
10890            break;
10891        }
10892        if ch.is_ascii_digit() {
10893            saw_digit = true;
10894            if scale < 100_000 {
10895                scale = scale.saturating_mul(10);
10896                num = num.saturating_mul(10) + u64::from(ch as u8 - b'0');
10897            }
10898        } else {
10899            break;
10900        }
10901    }
10902    if !saw_digit {
10903        return None;
10904    }
10905    Some(if num >= scale {
10906        GIT_DIFF_MAX_SCORE
10907    } else {
10908        GIT_DIFF_MAX_SCORE * num / scale
10909    })
10910}
10911
10912/// Git `merge_score` from `diffcore-break.c` when a pair is considered broken: how much of the
10913/// source blob was removed (0–`DIFF_MAX_SCORE` scale). Used for `dissimilarity index` metadata.
10914#[must_use]
10915pub fn rewrite_merge_score(old: &[u8], new: &[u8]) -> Option<u64> {
10916    if old.is_empty() {
10917        return None;
10918    }
10919    let max_size = old.len().max(new.len());
10920    if max_size < DIFF_MINIMUM_BREAK_SIZE {
10921        return None;
10922    }
10923    let (src_copied, _) = diffcore_count_changes(old, new);
10924    let src_copied = src_copied.min(old.len() as u64);
10925    let src_removed = (old.len() as u64).saturating_sub(src_copied);
10926    Some(src_removed * DIFF_MAX_SCORE / old.len() as u64)
10927}
10928
10929/// Percentage shown in `dissimilarity index N%` for a rewrite (`similarity_index` in Git's diff.c).
10930#[must_use]
10931pub fn rewrite_dissimilarity_index_percent(old: &[u8], new: &[u8]) -> Option<u32> {
10932    let score = rewrite_merge_score(old, new)?;
10933    Some((score * 100 / DIFF_MAX_SCORE).min(100) as u32)
10934}
10935
10936fn should_break_rewrite_inner(src: &[u8], dst: &[u8], break_score: u64) -> bool {
10937    if src.is_empty() {
10938        return false;
10939    }
10940    let max_size = src.len().max(dst.len());
10941    if max_size < DIFF_MINIMUM_BREAK_SIZE {
10942        return false;
10943    }
10944    let (src_copied, literal_added) = diffcore_count_changes(src, dst);
10945    let src_copied = src_copied.min(src.len() as u64);
10946    let mut literal_added = literal_added;
10947    let dst_len = dst.len() as u64;
10948    if src_copied < dst_len && literal_added + src_copied > dst_len {
10949        literal_added = dst_len.saturating_sub(src_copied);
10950    }
10951    let src_removed = (src.len() as u64).saturating_sub(src_copied);
10952    let merge_score = src_removed * DIFF_MAX_SCORE / src.len() as u64;
10953    if merge_score > break_score {
10954        return true;
10955    }
10956    let delta_size = src_removed.saturating_add(literal_added);
10957    if delta_size * DIFF_MAX_SCORE / (max_size as u64) < break_score {
10958        return false;
10959    }
10960    let s = src.len() as u64;
10961    if (s * break_score < src_removed * DIFF_MAX_SCORE)
10962        && (literal_added * 20 < src_removed)
10963        && (literal_added * 20 < src_copied)
10964    {
10965        return false;
10966    }
10967    true
10968}
10969
10970// ── Helpers ─────────────────────────────────────────────────────────
10971
10972/// Flatten a tree object recursively into a sorted list of (path, mode, oid).
10973struct FlatEntry {
10974    path: String,
10975    mode: u32,
10976    oid: ObjectId,
10977}
10978
10979fn flatten_tree(odb: &Odb, tree_oid: &ObjectId, prefix: &str) -> Result<Vec<FlatEntry>> {
10980    let entries = read_tree(odb, tree_oid)?;
10981    let mut result = Vec::new();
10982
10983    for entry in entries {
10984        let name_str = String::from_utf8_lossy(&entry.name);
10985        let path = format_path(prefix, &name_str);
10986        if is_tree_mode(entry.mode) {
10987            let nested = flatten_tree(odb, &entry.oid, &path)?;
10988            result.extend(nested);
10989        } else {
10990            result.push(FlatEntry {
10991                path,
10992                mode: entry.mode,
10993                oid: entry.oid,
10994            });
10995        }
10996    }
10997
10998    Ok(result)
10999}
11000
11001/// Paths present in `HEAD`'s tree with mode and blob/commit OID (for status porcelain v2).
11002pub fn head_path_states(
11003    odb: &Odb,
11004    head_tree: Option<&ObjectId>,
11005) -> Result<std::collections::BTreeMap<String, (u32, ObjectId)>> {
11006    let mut m = std::collections::BTreeMap::new();
11007    let Some(t) = head_tree else {
11008        return Ok(m);
11009    };
11010    for fe in flatten_tree(odb, t, "")? {
11011        m.insert(fe.path, (fe.mode, fe.oid));
11012    }
11013    Ok(m)
11014}
11015
11016/// Whether a mode represents a tree (directory).
11017fn is_tree_mode(mode: u32) -> bool {
11018    mode == 0o040000
11019}
11020
11021/// Build a path with an optional prefix.
11022fn entries_sorted_by_path(entries: &[DiffEntry]) -> bool {
11023    entries
11024        .windows(2)
11025        .all(|pair| pair[0].path().cmp(pair[1].path()) != std::cmp::Ordering::Greater)
11026}
11027
11028fn format_path(prefix: &str, name: &str) -> String {
11029    if prefix.is_empty() {
11030        name.to_owned()
11031    } else {
11032        let mut path = String::with_capacity(prefix.len() + 1 + name.len());
11033        path.push_str(prefix);
11034        path.push('/');
11035        path.push_str(name);
11036        path
11037    }
11038}
11039
11040/// Format a numeric mode as a zero-padded octal string.
11041pub fn format_mode(mode: u32) -> String {
11042    format!("{mode:06o}")
11043}
11044
11045/// Read the HEAD commit OID from a submodule checkout directory.
11046///
11047/// Returns `None` if the path is missing, not a submodule checkout, or has no resolvable HEAD.
11048#[must_use]
11049pub fn read_submodule_head_for_checkout(sub_dir: &Path) -> Option<ObjectId> {
11050    read_submodule_head(sub_dir)
11051}
11052
11053/// First line of a commit's message for `git diff --submodule=log` output.
11054///
11055/// Honors `encoding` in the commit object (Latin-1 vs UTF-8) using the same
11056/// rules as Git's submodule summary.
11057#[must_use]
11058pub fn submodule_commit_subject_line(c: &CommitData) -> String {
11059    let enc = c.encoding.as_deref().unwrap_or("UTF-8");
11060    let is_latin1 = enc.eq_ignore_ascii_case("ISO8859-1")
11061        || enc.eq_ignore_ascii_case("ISO-8859-1")
11062        || enc.eq_ignore_ascii_case("LATIN1")
11063        || enc.eq_ignore_ascii_case("ISO-8859-15");
11064    if let Some(raw) = c.raw_message.as_deref() {
11065        let line = raw.split(|b| *b == b'\n').next().unwrap_or(raw);
11066        if is_latin1 {
11067            return line
11068                .iter()
11069                .map(|&b| b as char)
11070                .collect::<String>()
11071                .trim()
11072                .to_owned();
11073        }
11074        return String::from_utf8_lossy(line).trim().to_string();
11075    }
11076    c.message.lines().next().unwrap_or("").trim().to_owned()
11077}
11078
11079/// True when `sub_dir` is an empty directory (or missing), i.e. the placeholder left by
11080/// `git apply --index` before `git submodule update`.
11081fn submodule_worktree_is_unpopulated_placeholder(sub_dir: &Path) -> bool {
11082    match fs::read_dir(sub_dir) {
11083        Ok(mut it) => it.next().is_none(),
11084        Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
11085        Err(_) => false,
11086    }
11087}
11088
11089fn read_submodule_head(sub_dir: &Path) -> Option<ObjectId> {
11090    read_submodule_head_oid(sub_dir)
11091}
11092
11093/// Resolve the embedded git directory for a submodule work tree (`sub_dir/.git`).
11094#[must_use]
11095pub fn submodule_embedded_git_dir(sub_dir: &Path) -> Option<PathBuf> {
11096    let gitfile = sub_dir.join(".git");
11097    if gitfile.is_file() {
11098        let content = fs::read_to_string(&gitfile).ok()?;
11099        let gitdir = content
11100            .lines()
11101            .find_map(|l| l.strip_prefix("gitdir: "))?
11102            .trim();
11103        Some(if Path::new(gitdir).is_absolute() {
11104            PathBuf::from(gitdir)
11105        } else {
11106            sub_dir.join(gitdir)
11107        })
11108    } else if gitfile.is_dir() {
11109        Some(gitfile)
11110    } else {
11111        None
11112    }
11113}
11114
11115/// Whether `abs` is a nested Git checkout (embedded `.git`) not already indexed as a gitlink.
11116///
11117/// Matches Git's `is_nonbare_repository_dir` / `is_git_directory`: the embedded git dir must look
11118/// like a real repository (valid `HEAD` plus `objects/`, or `objects/` and `refs/`), not a bare
11119/// `.git` directory placeholder.
11120#[must_use]
11121pub(crate) fn is_unregistered_embedded_git_repo(index: &Index, rel: &str, abs: &Path) -> bool {
11122    if rel.is_empty() {
11123        return false;
11124    }
11125    if index
11126        .get(rel.as_bytes(), 0)
11127        .is_some_and(|e| e.mode == crate::index::MODE_GITLINK)
11128    {
11129        return false;
11130    }
11131    let Some(git_dir) = submodule_embedded_git_dir(abs) else {
11132        return false;
11133    };
11134    crate::submodule_gitdir::is_valid_git_dir(&git_dir)
11135}
11136
11137/// Walk upward from `sub_dir` to find the nearest containing Git work tree.
11138fn find_superproject_git(sub_dir: &Path) -> Option<(PathBuf, PathBuf)> {
11139    let mut cur = sub_dir.parent()?;
11140    loop {
11141        let git_path = cur.join(".git");
11142        if git_path.exists() {
11143            let gd = if git_path.is_file() {
11144                let content = fs::read_to_string(&git_path).ok()?;
11145                let line = content
11146                    .lines()
11147                    .find_map(|l| l.strip_prefix("gitdir: "))?
11148                    .trim();
11149                if Path::new(line).is_absolute() {
11150                    PathBuf::from(line)
11151                } else {
11152                    cur.join(line)
11153                }
11154            } else {
11155                git_path
11156            };
11157            return Some((cur.to_path_buf(), gd));
11158        }
11159        cur = cur.parent()?;
11160    }
11161}
11162
11163/// Read the HEAD commit OID from a submodule working tree directory.
11164///
11165/// Handles both embedded `.git` directories and `gitdir:` gitfiles pointing at
11166/// `.git/modules/...` (or other locations). Returns `None` if the path is not
11167/// a checkout or has no resolvable HEAD.
11168pub fn read_submodule_head_oid(sub_dir: &Path) -> Option<ObjectId> {
11169    // Submodule `.git` may be a gitfile pointing at `.git/modules/<name>` in another superproject
11170    // after `cp -R`. Prefer the current superproject's module dir when present.
11171    let mut git_dir = submodule_embedded_git_dir(sub_dir)?;
11172    if let Some((super_wt, super_git_dir)) = find_superproject_git(sub_dir) {
11173        let rel = sub_dir.strip_prefix(&super_wt).ok()?;
11174        let rel_str = rel.to_string_lossy().replace('\\', "/");
11175        let local_mod = super_git_dir
11176            .join("modules")
11177            .join(rel_str.trim_start_matches('/'));
11178        if local_mod.join("HEAD").exists() {
11179            let sg = super_git_dir.canonicalize().unwrap_or(super_git_dir);
11180            let cur = git_dir.canonicalize().unwrap_or_else(|_| git_dir.clone());
11181            if !cur.starts_with(&sg) {
11182                git_dir = local_mod;
11183            }
11184        }
11185    }
11186    let head_content = fs::read_to_string(git_dir.join("HEAD")).ok()?;
11187    let head_trimmed = head_content.trim();
11188    if head_trimmed.starts_with("ref: ") {
11189        // Use the full ref resolver so packed-refs and worktrees match Git. If `HEAD` is a stale
11190        // symref (e.g. still `refs/heads/master` while only `main` exists), fall back like
11191        // `resolve_gitlink_ref` / `git add` on embedded repos (`t6437-submodule-merge`).
11192        match crate::refs::resolve_ref(&git_dir, "HEAD") {
11193            Ok(oid) => Some(oid),
11194            Err(_) => {
11195                let mut found = None;
11196                for branch in ["main", "master"] {
11197                    let p = git_dir.join("refs/heads").join(branch);
11198                    if let Ok(s) = fs::read_to_string(&p) {
11199                        if let Ok(o) = ObjectId::from_hex(s.trim()) {
11200                            found = Some(o);
11201                            break;
11202                        }
11203                    }
11204                }
11205                found
11206            }
11207        }
11208    } else {
11209        ObjectId::from_hex(head_trimmed).ok()
11210    }
11211}
11212
11213/// True when a populated submodule checkout is *broken*: its `HEAD` resolves to a commit OID, but
11214/// that commit object cannot be read from the submodule's own object database.
11215///
11216/// This mirrors Git's `is_submodule_modified`, which shells out to `git status --porcelain=2`
11217/// inside the submodule; when the submodule's object store is corrupt (e.g. `rm -r .git/objects`),
11218/// that inner status fails and Git aborts the surrounding `status`/`diff`/`fetch`. We detect the
11219/// same condition in-process so the superproject operation can return a fatal error rather than
11220/// silently treating the submodule as clean (t5526 "fetching submodule into a broken repository").
11221///
11222/// Returns `false` when the submodule is not checked out, has no embedded git dir, or has an
11223/// unresolvable HEAD (those are handled separately by the unpopulated/placeholder logic).
11224#[must_use]
11225pub fn submodule_head_object_broken(sub_dir: &Path) -> bool {
11226    let Some(sub_git_dir) = submodule_embedded_git_dir(sub_dir) else {
11227        return false;
11228    };
11229    let Some(head_oid) = read_submodule_head_oid(sub_dir) else {
11230        return false;
11231    };
11232    let odb = Odb::new(&sub_git_dir.join("objects"));
11233    match odb.read(&head_oid) {
11234        // HEAD object present but not a commit would be a different kind of corruption; only the
11235        // missing-object case is the broken-repo scenario Git guards against here.
11236        Ok(obj) => parse_commit(&obj.data).is_err(),
11237        Err(_) => true,
11238    }
11239}
11240
11241/// Submodule dirty bits aligned with Git's `DIRTY_SUBMODULE_*` / porcelain v2 `S???` token.
11242#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
11243pub struct SubmodulePorcelainFlags {
11244    /// Submodule checkout HEAD differs from the gitlink OID recorded in the parent index.
11245    pub new_commits: bool,
11246    /// The submodule has its own staged or unstaged changes (`DIRTY_SUBMODULE_MODIFIED`).
11247    pub modified: bool,
11248    /// The submodule work tree contains paths not in its index (`DIRTY_SUBMODULE_UNTRACKED`).
11249    pub untracked: bool,
11250}
11251
11252/// Inspect a checked-out submodule at `rel_path` (relative to `super_worktree`) and return
11253/// flags used for `git status --porcelain=v2` submodule tokens.
11254///
11255/// `recorded_oid` is the gitlink OID stored in the **parent** index (stage 0). When the
11256/// submodule is not checked out or cannot be opened, returns [`Default::default()`].
11257pub fn submodule_porcelain_flags(
11258    super_worktree: &Path,
11259    rel_path: &str,
11260    recorded_oid: ObjectId,
11261) -> SubmodulePorcelainFlags {
11262    let sub_dir = super_worktree.join(rel_path);
11263    let Some(sub_git_dir) = submodule_embedded_git_dir(&sub_dir) else {
11264        return SubmodulePorcelainFlags::default();
11265    };
11266    let Some(sub_head) = read_submodule_head_oid(&sub_dir) else {
11267        return SubmodulePorcelainFlags::default();
11268    };
11269
11270    let new_commits = sub_head != recorded_oid;
11271
11272    let index_path = sub_git_dir.join("index");
11273    let mut sub_index = match crate::index::Index::load(&index_path) {
11274        Ok(ix) => ix,
11275        Err(_) => {
11276            return SubmodulePorcelainFlags {
11277                new_commits,
11278                ..Default::default()
11279            }
11280        }
11281    };
11282
11283    let tracked: std::collections::BTreeSet<String> = sub_index
11284        .entries
11285        .iter()
11286        .filter(|e| e.stage() == 0)
11287        .map(|e| String::from_utf8_lossy(&e.path).into_owned())
11288        .collect();
11289    let untracked = submodule_dir_has_untracked_inner(&sub_dir, &sub_dir, &tracked, &sub_index);
11290
11291    let objects_dir = sub_git_dir.join("objects");
11292    let odb = Odb::new(&objects_dir);
11293
11294    let sub_head_tree = (|| -> Option<ObjectId> {
11295        let h = fs::read_to_string(sub_git_dir.join("HEAD")).ok()?;
11296        let h_str = h.trim();
11297        let commit_oid = if let Some(r) = h_str.strip_prefix("ref: ") {
11298            let oid_hex = fs::read_to_string(sub_git_dir.join(r)).ok()?;
11299            ObjectId::from_hex(oid_hex.trim()).ok()?
11300        } else {
11301            ObjectId::from_hex(h_str).ok()?
11302        };
11303        let obj = odb.read(&commit_oid).ok()?;
11304        let commit = parse_commit(&obj.data).ok()?;
11305        Some(commit.tree)
11306    })();
11307
11308    let staged_dirty = sub_head_tree
11309        .as_ref()
11310        .map(|t| diff_index_to_tree(&odb, &sub_index, Some(t), false).map(|v| !v.is_empty()))
11311        .unwrap_or(Ok(false));
11312    let staged_dirty = staged_dirty.unwrap_or(false);
11313
11314    let unstaged_dirty = diff_index_to_worktree(&odb, &mut sub_index, &sub_dir, false, true)
11315        .map(|v| !v.is_empty())
11316        .unwrap_or(false);
11317
11318    let mut modified = staged_dirty || unstaged_dirty;
11319
11320    // Nested submodule has its own index: OR `modified` from immediate gitlink children so a
11321    // dirty nested checkout (e.g. staged `file` under `sub1/sub2`) marks the parent gitlink as
11322    // modified in the superproject (t7506). Do **not** OR `untracked` — untracked-only inside a
11323    // nested submodule must stay `S..U` on the parent, not `S.U` / `S.M.`.
11324    for e in &sub_index.entries {
11325        if e.stage() != 0 || e.mode != 0o160000 {
11326            continue;
11327        }
11328        let child = String::from_utf8_lossy(&e.path).into_owned();
11329        let full_rel = if rel_path.is_empty() {
11330            child
11331        } else {
11332            format!("{rel_path}/{child}")
11333        };
11334        let nested = submodule_porcelain_flags(super_worktree, &full_rel, e.oid);
11335        modified |= nested.modified;
11336    }
11337
11338    SubmodulePorcelainFlags {
11339        new_commits,
11340        modified,
11341        untracked,
11342    }
11343}
11344
11345fn submodule_dir_has_untracked_inner(
11346    dir: &Path,
11347    root: &Path,
11348    tracked: &std::collections::BTreeSet<String>,
11349    owning_index: &Index,
11350) -> bool {
11351    let entries = match fs::read_dir(dir) {
11352        Ok(e) => e,
11353        Err(_) => return false,
11354    };
11355    let mut sorted: Vec<_> = entries.filter_map(|e| e.ok()).collect();
11356    sorted.sort_by_key(|e| e.file_name());
11357
11358    for entry in sorted {
11359        let name = entry.file_name().to_string_lossy().to_string();
11360        if name == ".git" {
11361            continue;
11362        }
11363        let path = entry.path();
11364        let rel = path
11365            .strip_prefix(root)
11366            .map(|p| p.to_string_lossy().to_string())
11367            .unwrap_or_else(|_| name.clone());
11368
11369        let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
11370        if is_dir {
11371            let is_gitlink = owning_index
11372                .get(rel.as_bytes(), 0)
11373                .is_some_and(|e| e.mode == 0o160000);
11374            if is_gitlink {
11375                let Some(nested_git) = submodule_embedded_git_dir(&path) else {
11376                    continue;
11377                };
11378                let nested_index_path = nested_git.join("index");
11379                let Ok(nested_ix) = crate::index::Index::load(&nested_index_path) else {
11380                    continue;
11381                };
11382                let nested_tracked: std::collections::BTreeSet<String> = nested_ix
11383                    .entries
11384                    .iter()
11385                    .filter(|e| e.stage() == 0)
11386                    .map(|e| String::from_utf8_lossy(&e.path).into_owned())
11387                    .collect();
11388                if submodule_dir_has_untracked_inner(&path, &path, &nested_tracked, &nested_ix) {
11389                    return true;
11390                }
11391            } else if submodule_dir_has_untracked_inner(&path, root, tracked, owning_index) {
11392                return true;
11393            }
11394        } else if !tracked.contains(&rel) {
11395            return true;
11396        }
11397    }
11398    false
11399}
11400
11401/// Reorder diff entries by a `-O<orderfile>` (`diff.orderFile`): entries are sorted by the index of
11402/// the first orderfile glob pattern that matches their path; unmatched entries sort last (stable).
11403pub fn apply_orderfile_entries(
11404    entries: Vec<DiffEntry>,
11405    order_path: &str,
11406    cwd: &Path,
11407) -> Result<Vec<DiffEntry>> {
11408    apply_orderfile(entries, order_path, cwd)
11409}
11410
11411fn apply_orderfile(
11412    mut entries: Vec<DiffEntry>,
11413    order_path: &str,
11414    cwd: &Path,
11415) -> Result<Vec<DiffEntry>> {
11416    let patterns = read_orderfile_patterns(order_path, cwd)?;
11417    let sort_key = |entry: &DiffEntry| -> usize {
11418        let path = entry
11419            .new_path
11420            .as_ref()
11421            .or(entry.old_path.as_ref())
11422            .cloned()
11423            .unwrap_or_default();
11424        for (i, pat) in patterns.iter().enumerate() {
11425            if orderfile_pattern_matches(pat, &path) {
11426                return i;
11427            }
11428        }
11429        patterns.len()
11430    };
11431    entries.sort_by_key(|e| sort_key(e));
11432    Ok(entries)
11433}
11434
11435/// Read non-empty, non-comment glob patterns (one per line) from a `-O<orderfile>` file.
11436pub fn read_orderfile_patterns(order_path: &str, cwd: &Path) -> Result<Vec<String>> {
11437    let path = Path::new(order_path);
11438    let resolved = if path.is_absolute() {
11439        path.to_path_buf()
11440    } else {
11441        cwd.join(path)
11442    };
11443    let _meta = std::fs::metadata(&resolved)
11444        .map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
11445    let mut f = std::fs::File::open(&resolved)
11446        .map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
11447    let mut content = String::new();
11448    std::io::Read::read_to_string(&mut f, &mut content)
11449        .map_err(|e| Error::Message(format!("could not read orderfile {order_path}: {e}")))?;
11450    Ok(content
11451        .lines()
11452        .map(|l| l.trim().to_string())
11453        .filter(|l| !l.is_empty() && !l.starts_with('#'))
11454        .collect())
11455}
11456
11457/// Reorder diff entries for `git diff` `--rotate-to` / `--skip-to` (changed paths only).
11458pub fn apply_rotate_skip_entries(
11459    mut entries: Vec<DiffEntry>,
11460    rotate_to: Option<&str>,
11461    skip_to: Option<&str>,
11462) -> Result<Vec<DiffEntry>> {
11463    let Some(needle) = rotate_to.or(skip_to) else {
11464        return Ok(entries);
11465    };
11466    let needle = needle.trim();
11467    if needle.is_empty() {
11468        return Ok(entries);
11469    }
11470    let idx = entries
11471        .iter()
11472        .position(|e| e.path() == needle)
11473        .ok_or_else(|| {
11474            Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11475                path: needle.to_owned(),
11476            })
11477        })?;
11478    if rotate_to.is_some() {
11479        entries.rotate_left(idx);
11480    }
11481    if let Some(skip) = skip_to.filter(|s| !s.trim().is_empty()) {
11482        let pos = entries
11483            .iter()
11484            .position(|e| e.path() == skip)
11485            .ok_or_else(|| {
11486                Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11487                    path: skip.to_owned(),
11488                })
11489            })?;
11490        entries.drain(..pos);
11491    }
11492    Ok(entries)
11493}
11494
11495/// `git log` rotate/skip: reorder using the **commit tree** path order (all blobs), then keep only
11496/// paths present in `entries` — matches Git's `diff --rotate-to` with history walks.
11497pub fn apply_rotate_skip_log_entries(
11498    odb: &Odb,
11499    commit_tree: &ObjectId,
11500    entries: Vec<DiffEntry>,
11501    rotate_to: Option<&str>,
11502    skip_to: Option<&str>,
11503) -> Result<Vec<DiffEntry>> {
11504    // Without --rotate-to/--skip-to this is a no-op (the ordered-paths helper
11505    // returns the entries untouched), so skip the full-tree walk that
11506    // `all_blob_paths_in_tree_order` performs — per displayed commit it read
11507    // every subtree of the commit's tree just to discard the result.
11508    fn trimmed(s: Option<&str>) -> Option<&str> {
11509        s.map(str::trim).filter(|t| !t.is_empty())
11510    }
11511    if trimmed(rotate_to).is_none() && trimmed(skip_to).is_none() {
11512        return Ok(entries);
11513    }
11514    let tree_paths = crate::merge_diff::all_blob_paths_in_tree_order(odb, commit_tree);
11515    apply_rotate_skip_ordered_paths(&tree_paths, entries, rotate_to, skip_to)
11516}
11517
11518fn apply_rotate_skip_ordered_paths(
11519    tree_paths: &[String],
11520    entries: Vec<DiffEntry>,
11521    rotate_to: Option<&str>,
11522    skip_to: Option<&str>,
11523) -> Result<Vec<DiffEntry>> {
11524    let rotate = rotate_to.and_then(|s| {
11525        let t = s.trim();
11526        (!t.is_empty()).then_some(t)
11527    });
11528    let skip = skip_to.and_then(|s| {
11529        let t = s.trim();
11530        (!t.is_empty()).then_some(t)
11531    });
11532    if rotate.is_none() && skip.is_none() {
11533        return Ok(entries);
11534    }
11535
11536    use std::collections::HashMap;
11537    let mut by_path: HashMap<String, DiffEntry> = HashMap::new();
11538    for e in entries {
11539        by_path.insert(e.path().to_string(), e);
11540    }
11541
11542    // `git log --skip-to`: only list changed paths from the skip point onward (unmodified paths
11543    // in the tree-order suffix are omitted). `--rotate-to` still lists every changed file in order.
11544    if rotate.is_none() {
11545        let Some(skip_path) = skip else {
11546            return Ok(by_path.into_values().collect());
11547        };
11548        let idx = tree_paths
11549            .iter()
11550            .position(|p| p == skip_path)
11551            .ok_or_else(|| {
11552                Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11553                    path: skip_path.to_owned(),
11554                })
11555            })?;
11556        let mut out = Vec::new();
11557        for p in tree_paths.iter().skip(idx) {
11558            if let Some(e) = by_path.remove(p) {
11559                out.push(e);
11560            }
11561        }
11562        return Ok(out);
11563    }
11564
11565    let Some(needle) = rotate else {
11566        return Ok(by_path.into_values().collect());
11567    };
11568    let idx = tree_paths.iter().position(|p| p == needle).ok_or_else(|| {
11569        Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11570            path: needle.to_owned(),
11571        })
11572    })?;
11573    let mut order: Vec<String> = tree_paths.to_vec();
11574    order.rotate_left(idx);
11575    if let Some(skip_path) = skip {
11576        let pos = order.iter().position(|p| p == skip_path).ok_or_else(|| {
11577            Error::DiffPath(crate::error::DiffPathError::NoSuchPathInDiff {
11578                path: skip_path.to_owned(),
11579            })
11580        })?;
11581        order.drain(..pos);
11582    }
11583    let mut out = Vec::new();
11584    for p in order {
11585        if let Some(e) = by_path.remove(&p) {
11586            out.push(e);
11587        }
11588    }
11589    Ok(out)
11590}
11591
11592/// Check if an orderfile pattern matches a path (matches the basename or the full path).
11593/// Supports basic glob patterns: `*` matches any sequence, `?` matches one char.
11594pub fn orderfile_pattern_matches(pattern: &str, path: &str) -> bool {
11595    let name = path.rsplit('/').next().unwrap_or(path);
11596    orderfile_glob_match(pattern, name) || orderfile_glob_match(pattern, path)
11597}
11598
11599/// Basic glob matching (supports `*` and `?`).
11600fn orderfile_glob_match(pattern: &str, text: &str) -> bool {
11601    let mut pi = 0;
11602    let mut ti = 0;
11603    let pb = pattern.as_bytes();
11604    let tb = text.as_bytes();
11605    let mut star_pi = usize::MAX;
11606    let mut star_ti = 0;
11607
11608    while ti < tb.len() {
11609        if pi < pb.len() && (pb[pi] == b'?' || pb[pi] == tb[ti]) {
11610            pi += 1;
11611            ti += 1;
11612        } else if pi < pb.len() && pb[pi] == b'*' {
11613            star_pi = pi;
11614            star_ti = ti;
11615            pi += 1;
11616        } else if star_pi != usize::MAX {
11617            pi = star_pi + 1;
11618            star_ti += 1;
11619            ti = star_ti;
11620        } else {
11621            return false;
11622        }
11623    }
11624    while pi < pb.len() && pb[pi] == b'*' {
11625        pi += 1;
11626    }
11627    pi == pb.len()
11628}
11629
11630#[cfg(test)]
11631mod line_compare_key_tests {
11632    use super::*;
11633    use crate::diff::unified_emit::line_diff_token;
11634    use crate::diff::WhitespaceMode;
11635
11636    #[test]
11637    fn borrowed_keys_match_owned_line_tokens() {
11638        let texts = [
11639            "",
11640            "\n",
11641            "a",
11642            "a\n",
11643            "a\nb",
11644            "a\r\nb\r\n",
11645            "a\r\nb\r",
11646            "\n\n\nlast",
11647            "x\n\r\n\ry\n",
11648        ];
11649        for text in texts {
11650            let side = LineSide::from_text(text);
11651            let mut normalized = Vec::new();
11652            let keys = line_compare_keys(text, &side, WhitespaceMode::default(), &mut normalized);
11653            let expected: Vec<String> = side.lines.iter().map(line_diff_token).collect();
11654            assert_eq!(keys, expected, "text {text:?}");
11655            let source = text.as_bytes().as_ptr_range();
11656            assert!(
11657                keys.iter()
11658                    .all(|key| source.contains(&key.as_ptr()) || key.is_empty()),
11659                "text {text:?}"
11660            );
11661        }
11662    }
11663
11664    #[test]
11665    fn whitespace_modes_compare_normalized_bodies() {
11666        let text = "a  b\nab \n";
11667        let side = LineSide::from_text(text);
11668        let ws = WhitespaceMode {
11669            ignore_all_space: true,
11670            ..WhitespaceMode::default()
11671        };
11672        let mut normalized = Vec::new();
11673        let keys = line_compare_keys(text, &side, ws, &mut normalized);
11674        assert_eq!(keys, vec!["ab", "ab"]);
11675    }
11676
11677    #[test]
11678    fn xdiff_marks_only_edited_lines_in_large_file() {
11679        let old: String = (0..5_000).map(|i| format!("line{i}\n")).collect();
11680        let new: String = (0..5_000)
11681            .map(|i| {
11682                if i % 100 == 0 {
11683                    format!("LINE{i}\n")
11684                } else {
11685                    format!("line{i}\n")
11686                }
11687            })
11688            .collect();
11689        let body =
11690            git_xdiff_unified_body_raw(&old, &new, 3, 0, true, false, WhitespaceMode::default());
11691        assert_eq!(body.matches("\n@@ ").count() + 1, 50);
11692        assert_eq!(body.lines().filter(|l| l.starts_with('-')).count(), 50);
11693        assert_eq!(body.lines().filter(|l| l.starts_with('+')).count(), 50);
11694        assert!(body.starts_with("@@ -1,4 +1,4 @@\n-line0\n+LINE0\n line1\n"));
11695    }
11696}
11697
11698#[cfg(test)]
11699mod diff_index_to_tree_cache_tree_tests {
11700    use std::rc::Rc;
11701    use std::sync::atomic::{AtomicUsize, Ordering};
11702
11703    use super::*;
11704    use crate::index::{Index, IndexEntry, MODE_EXECUTABLE, MODE_GITLINK, MODE_REGULAR};
11705    use crate::objects::ObjectKind;
11706    use crate::write_tree::{build_cache_tree_from_index, write_tree_from_index};
11707    use tempfile::TempDir;
11708
11709    struct TreeReadCounter {
11710        counter: Rc<AtomicUsize>,
11711    }
11712
11713    impl TreeReadCounter {
11714        fn start() -> Self {
11715            let counter = Rc::new(AtomicUsize::new(0));
11716            set_diff_tree_read_hook_for_tests(Some(counter.clone()));
11717            Self { counter }
11718        }
11719
11720        fn get(&self) -> usize {
11721            self.counter.load(Ordering::Relaxed)
11722        }
11723    }
11724
11725    impl Drop for TreeReadCounter {
11726        fn drop(&mut self) {
11727            set_diff_tree_read_hook_for_tests(None);
11728        }
11729    }
11730
11731    fn diff_index_to_tree_flatten_reference(
11732        odb: &Odb,
11733        index: &Index,
11734        head_tree: &ObjectId,
11735        ignore_submodules: bool,
11736    ) -> Result<Vec<DiffEntry>> {
11737        let mut flat_index = index.clone();
11738        flat_index.clear_cache_tree();
11739        diff_index_to_tree(odb, &flat_index, Some(head_tree), ignore_submodules)
11740    }
11741
11742    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
11743        IndexEntry {
11744            ctime_sec: 0,
11745            ctime_nsec: 0,
11746            mtime_sec: 0,
11747            mtime_nsec: 0,
11748            dev: 0,
11749            ino: 0,
11750            mode,
11751            uid: 0,
11752            gid: 0,
11753            size: 0,
11754            oid,
11755            flags: path.len().min(0xfff) as u16,
11756            flags_extended: None,
11757            path: path.as_bytes().to_vec(),
11758            base_index_pos: 0,
11759        }
11760    }
11761
11762    fn repo_with_tree(depth: usize, width: usize) -> (TempDir, Odb, ObjectId, Index) {
11763        let temp = TempDir::new().expect("tempdir");
11764        let odb = Odb::new(temp.path());
11765        let mut index = Index::new();
11766        for d in 0..depth {
11767            for w in 0..width {
11768                let path = format!("unchanged/d{d}/f{w}.txt");
11769                let oid = odb
11770                    .write(ObjectKind::Blob, format!("body-{d}-{w}").as_bytes())
11771                    .expect("blob");
11772                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
11773            }
11774        }
11775        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
11776        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
11777        index.set_cache_tree(cache);
11778        index.cache_tree_root = Some(head_tree);
11779        (temp, odb, head_tree, index)
11780    }
11781
11782    #[test]
11783    fn diff_index_to_tree_cache_tree_fast_path_matches_slow() {
11784        let (_temp, odb, head_tree, mut index) = repo_with_tree(4, 5);
11785        assert!(index.cache_tree.as_ref().is_some_and(|c| c.is_valid()));
11786
11787        let mut state: u64 = 0xDEAD_BEEF;
11788        for _ in 0..24 {
11789            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
11790            let d = (state % 4) as usize;
11791            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
11792            let w = (state % 5) as usize;
11793            let path = format!("unchanged/d{d}/f{w}.txt");
11794            index.invalidate_cache_tree_for_path(path.as_bytes());
11795            assert!(
11796                index.cache_tree_root.is_none(),
11797                "partial invalidation clears cache_tree_root like Index::add"
11798            );
11799            assert!(
11800                index.cache_tree.as_ref().is_some_and(|r| !r.is_valid()),
11801                "root cache node must be invalid after partial invalidation"
11802            );
11803            if let Some(e) = index.get_mut(path.as_bytes(), 0) {
11804                let new_oid = odb
11805                    .write(ObjectKind::Blob, format!("new-{d}-{w}").as_bytes())
11806                    .expect("blob");
11807                e.oid = new_oid;
11808            }
11809            let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
11810            let flat = diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false)
11811                .expect("flat");
11812            assert_eq!(fast, flat, "mismatch after mutating {path}");
11813        }
11814    }
11815
11816    #[test]
11817    fn diff_index_to_tree_skips_unchanged_subtrees() {
11818        let (_temp, odb, head_tree, index) = repo_with_tree(5, 6);
11819        let reads = TreeReadCounter::start();
11820        diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("cached diff");
11821        let cached_reads = reads.get();
11822
11823        let mut no_cache = index.clone();
11824        no_cache.clear_cache_tree();
11825        let reads = TreeReadCounter::start();
11826        diff_index_to_tree(&odb, &no_cache, Some(&head_tree), false).expect("flat diff");
11827        let flat_reads = reads.get();
11828
11829        assert!(
11830            cached_reads < flat_reads,
11831            "expected fewer tree reads with cache-tree (cached={cached_reads}, flat={flat_reads})"
11832        );
11833        assert_eq!(
11834            cached_reads, 1,
11835            "unchanged repo should only read the root tree"
11836        );
11837    }
11838
11839    #[test]
11840    fn diff_index_to_tree_partial_invalidation_skips_sibling_subtree() {
11841        let (_temp, odb, head_tree, mut index) = repo_with_tree(5, 6);
11842        let path = "unchanged/d1/f2.txt";
11843        index.invalidate_cache_tree_for_path(path.as_bytes());
11844        let new_oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
11845        index.get_mut(path.as_bytes(), 0).expect("entry").oid = new_oid;
11846
11847        let flat =
11848            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
11849        let reads = TreeReadCounter::start();
11850        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
11851        let cached_reads = reads.get();
11852        assert_eq!(fast, flat);
11853
11854        let reads = TreeReadCounter::start();
11855        diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
11856        let flat_reads = reads.get();
11857
11858        assert!(
11859            cached_reads < flat_reads,
11860            "partial cache-tree should skip sibling dirs (cached={cached_reads}, flat={flat_reads})"
11861        );
11862    }
11863
11864    fn cache_leaf(name: &str) -> CacheTreeNode {
11865        CacheTreeNode::valid(name.as_bytes().to_vec(), 1, ObjectId::zero(), Vec::new())
11866    }
11867
11868    #[test]
11869    fn cache_children_lookup_matches_linear_scan_on_wide_unsorted_nodes() {
11870        let mut children: Vec<CacheTreeNode> = (0..CACHE_CHILD_LINEAR_SCAN_MAX * 3)
11871            .rev()
11872            .map(|i| cache_leaf(&format!("d{i:03}")))
11873            .collect();
11874        let mut first_dup = cache_leaf("d007");
11875        first_dup.entry_count = 7;
11876        children.insert(3, first_dup);
11877        let root = CacheTreeNode::valid(Vec::new(), 0, ObjectId::zero(), children);
11878
11879        let lookup = CacheChildren::new(Some(&root));
11880        assert!(
11881            !lookup.sorted.is_empty(),
11882            "wide nodes must use the sorted lookup"
11883        );
11884        for name in ["d000", "d007", "d020", "d047", "zzz", "d", ""] {
11885            let linear = root.children.iter().find(|c| c.name == name.as_bytes());
11886            let fast = lookup.get(name.as_bytes());
11887            assert_eq!(
11888                fast.map(|c| (&c.name, c.entry_count)),
11889                linear.map(|c| (&c.name, c.entry_count)),
11890                "lookup of {name:?}"
11891            );
11892        }
11893        assert_eq!(lookup.get(b"d007").map(|c| c.entry_count), Some(7));
11894
11895        let narrow = CacheTreeNode::valid(
11896            Vec::new(),
11897            0,
11898            ObjectId::zero(),
11899            vec![cache_leaf("b"), cache_leaf("a")],
11900        );
11901        let lookup = CacheChildren::new(Some(&narrow));
11902        assert!(lookup.sorted.is_empty());
11903        assert_eq!(lookup.get(b"a").map(|c| c.name.as_slice()), Some(&b"a"[..]));
11904        assert!(lookup.get(b"c").is_none());
11905        assert!(CacheChildren::new(None).get(b"a").is_none());
11906    }
11907
11908    #[test]
11909    fn index_relative_in_dir_requires_a_component_boundary() {
11910        assert_eq!(index_relative_in_dir(b"a/b", b""), Some(&b"a/b"[..]));
11911        assert_eq!(index_relative_in_dir(b"a/b", b"a"), Some(&b"b"[..]));
11912        assert_eq!(index_relative_in_dir(b"a", b"a"), Some(&b""[..]));
11913        assert_eq!(index_relative_in_dir(b"ab/c", b"a"), None);
11914        assert_eq!(index_relative_in_dir(b"a.c", b"a"), None);
11915        assert_eq!(index_relative_in_dir(b"b", b"a"), None);
11916        assert_eq!(join_index_path(b"", b"x"), b"x".to_vec());
11917        assert_eq!(join_index_path(b"a/b", b"x"), b"a/b/x".to_vec());
11918    }
11919
11920    /// Wide root (more children than the linear-scan cutoff), cache-tree children stored in
11921    /// reverse order, a few changed paths, plus additions and deletions at both levels.
11922    #[test]
11923    fn diff_index_to_tree_wide_unsorted_cache_tree_matches_flatten() {
11924        let temp = TempDir::new().expect("tempdir");
11925        let odb = Odb::new(temp.path());
11926        let mut index = Index::new();
11927        let width = CACHE_CHILD_LINEAR_SCAN_MAX * 4;
11928        for d in 0..width {
11929            for f in 0..2 {
11930                let path = format!("dir{d:03}/sub/f{f}.txt");
11931                let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
11932                index.add_or_replace(index_entry(&path, MODE_REGULAR, oid));
11933            }
11934        }
11935        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
11936        let mut cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
11937        cache.children.reverse();
11938        index.set_cache_tree(cache);
11939        index.cache_tree_root = Some(head_tree);
11940
11941        let changed = [
11942            "dir003/sub/f1.txt",
11943            "dir031/sub/f0.txt",
11944            "dir063/sub/f1.txt",
11945        ];
11946        for path in changed {
11947            index.invalidate_cache_tree_for_path(path.as_bytes());
11948            let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
11949            index.get_mut(path.as_bytes(), 0).expect("entry").oid = oid;
11950        }
11951        for path in ["dir010/sub/new.txt", "dir999/new.txt", "top.txt"] {
11952            index.invalidate_cache_tree_for_path(path.as_bytes());
11953            let oid = odb.write(ObjectKind::Blob, path.as_bytes()).expect("blob");
11954            index.add_or_replace(index_entry(path, MODE_REGULAR, oid));
11955        }
11956        for path in [
11957            "dir020/sub/f0.txt",
11958            "dir020/sub/f1.txt",
11959            "dir021/sub/f0.txt",
11960        ] {
11961            index.invalidate_cache_tree_for_path(path.as_bytes());
11962            index.remove(path.as_bytes());
11963        }
11964
11965        let flat =
11966            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, false).expect("flat");
11967        let reads = TreeReadCounter::start();
11968        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
11969        let cached_reads = reads.get();
11970        assert_eq!(fast, flat);
11971        assert_eq!(fast.len(), changed.len() + 3 + 3);
11972        assert!(
11973            cached_reads <= 1 + 2 * 6,
11974            "unchanged siblings must be skipped through the cache-tree (reads={cached_reads})"
11975        );
11976    }
11977
11978    #[test]
11979    fn diff_index_to_tree_cache_tree_handles_non_utf8_directory_names() {
11980        let temp = TempDir::new().expect("tempdir");
11981        let odb = Odb::new(temp.path());
11982        let mut index = Index::new();
11983        let raw_dirs: [&[u8]; 3] = [b"caf\xe9", b"caf\xef\xbf\xbd", b"plain"];
11984        for dir in raw_dirs {
11985            for f in [&b"a.txt"[..], b"b.txt"] {
11986                let path = [dir, b"/", f].concat();
11987                let oid = odb.write(ObjectKind::Blob, &path).expect("blob");
11988                let mut entry = index_entry("x", MODE_REGULAR, oid);
11989                entry.flags = path.len().min(0xfff) as u16;
11990                entry.path = path;
11991                index.add_or_replace(entry);
11992            }
11993        }
11994        let head_tree = write_tree_from_index(&odb, &index, "").expect("write tree");
11995        let cache = build_cache_tree_from_index(&odb, &index).expect("cache-tree");
11996        index.set_cache_tree(cache);
11997        index.cache_tree_root = Some(head_tree);
11998
11999        let changed: &[u8] = b"caf\xe9/b.txt";
12000        index.invalidate_cache_tree_for_path(changed);
12001        let oid = odb.write(ObjectKind::Blob, b"changed").expect("blob");
12002        index.get_mut(changed, 0).expect("entry").oid = oid;
12003
12004        // Directory prefixes are compared as raw bytes, so `caf\xe9` and a directory literally
12005        // named `caf\u{fffd}` stay distinct even though they print the same.
12006        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("fast");
12007        assert_eq!(fast.len(), 1, "{fast:?}");
12008        assert_eq!(fast[0].status, DiffStatus::Modified);
12009        assert_eq!(
12010            fast[0].old_oid,
12011            odb.write(ObjectKind::Blob, changed).expect("blob")
12012        );
12013        assert_eq!(fast[0].new_oid, oid);
12014    }
12015
12016    #[test]
12017    fn diff_index_to_tree_reports_mode_gitlink_and_intent_to_add() {
12018        let temp = TempDir::new().expect("tempdir");
12019        let odb = Odb::new(temp.path());
12020        let blob = odb.write(ObjectKind::Blob, b"x").expect("blob");
12021        let mut index = Index::new();
12022        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, blob));
12023        let head_tree = write_tree_from_index(&odb, &index, "").expect("tree");
12024        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
12025        index.set_cache_tree(cache);
12026
12027        let mode_entry = index_entry("tracked.txt", MODE_EXECUTABLE, blob);
12028        index.add_or_replace(mode_entry);
12029        index.invalidate_cache_tree_for_path(b"tracked.txt");
12030
12031        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
12032        assert_eq!(diffs.len(), 1);
12033        assert_eq!(diffs[0].status, DiffStatus::Modified);
12034        assert_eq!(diffs[0].old_mode, format_mode(MODE_REGULAR));
12035        assert_eq!(diffs[0].new_mode, format_mode(MODE_EXECUTABLE));
12036
12037        // Intent-to-add is omitted from staged diff.
12038        let mut ita = index_entry("new.txt", MODE_REGULAR, blob);
12039        ita.set_intent_to_add(true);
12040        index.add_or_replace(ita);
12041        let diffs = diff_index_to_tree(&odb, &index, Some(&head_tree), false).expect("diff");
12042        assert!(
12043            diffs.iter().all(|d| d.path() != "new.txt"),
12044            "intent-to-add must not appear in staged diff"
12045        );
12046
12047        // Gitlink with ignore_submodules skips diff entry but still matches tree.
12048        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
12049            .parse()
12050            .expect("oid");
12051        let mut sub_index = Index::new();
12052        sub_index.add_or_replace(index_entry("sub", MODE_GITLINK, gitlink));
12053        let sub_tree = write_tree_from_index(&odb, &sub_index, "").expect("tree");
12054        let sub_cache = build_cache_tree_from_index(&odb, &sub_index).expect("cache");
12055        sub_index.set_cache_tree(sub_cache);
12056        let diffs =
12057            diff_index_to_tree(&odb, &sub_index, Some(&sub_tree), true).expect("gitlink diff");
12058        assert!(
12059            diffs.is_empty(),
12060            "ignored gitlink should not diff: {diffs:?}"
12061        );
12062    }
12063
12064    #[test]
12065    fn ignore_submodules_skips_gitlink_in_added_directory_cache_walk() {
12066        let temp = TempDir::new().expect("tempdir");
12067        let odb = Odb::new(temp.path());
12068        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
12069            .parse()
12070            .expect("oid");
12071        let head_tree = write_tree_from_index(&odb, &Index::new(), "").expect("empty tree");
12072        let mut index = Index::new();
12073        index.add_or_replace(index_entry("newdir/sub", MODE_GITLINK, gitlink));
12074        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
12075        index.set_cache_tree(cache);
12076        index.invalidate_cache_tree_for_path(b"newdir/sub");
12077
12078        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
12079        let flat =
12080            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
12081        assert_eq!(fast, flat);
12082        assert!(
12083            fast.is_empty(),
12084            "nested gitlinks in added dirs must be ignored: {fast:?}"
12085        );
12086    }
12087
12088    #[test]
12089    fn ignore_submodules_skips_gitlink_in_deleted_directory_cache_walk() {
12090        let temp = TempDir::new().expect("tempdir");
12091        let odb = Odb::new(temp.path());
12092        let blob = odb.write(ObjectKind::Blob, b"keep").expect("blob");
12093        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
12094            .parse()
12095            .expect("oid");
12096        let mut index = Index::new();
12097        index.add_or_replace(index_entry("keep.txt", MODE_REGULAR, blob));
12098        index.add_or_replace(index_entry("olddir/sub", MODE_GITLINK, gitlink));
12099        let head_tree = write_tree_from_index(&odb, &index, "").expect("head");
12100        let cache = build_cache_tree_from_index(&odb, &index).expect("cache");
12101        index.set_cache_tree(cache);
12102        index
12103            .entries
12104            .retain(|e| !String::from_utf8_lossy(&e.path).starts_with("olddir/"));
12105        index.invalidate_cache_tree_for_path(b"olddir/sub");
12106
12107        let fast = diff_index_to_tree(&odb, &index, Some(&head_tree), true).expect("fast");
12108        let flat =
12109            diff_index_to_tree_flatten_reference(&odb, &index, &head_tree, true).expect("flat");
12110        assert_eq!(fast, flat);
12111        assert!(
12112            fast.is_empty(),
12113            "nested gitlinks in deleted dirs must be ignored: {fast:?}"
12114        );
12115    }
12116
12117    #[test]
12118    fn diff_trees_skips_identical_subtree_oids() {
12119        let temp = TempDir::new().expect("tempdir");
12120        let odb = Odb::new(temp.path());
12121        let leaf = odb.write(ObjectKind::Blob, b"leaf").expect("blob");
12122        let mut deep = Index::new();
12123        for i in 0..20 {
12124            deep.add_or_replace(index_entry(&format!("deep/n{i}.txt"), MODE_REGULAR, leaf));
12125        }
12126        let tree = write_tree_from_index(&odb, &deep, "").expect("tree");
12127        let reads = TreeReadCounter::start();
12128        let entries = diff_trees(&odb, Some(&tree), Some(&tree), "").expect("diff");
12129        assert!(entries.is_empty());
12130        assert_eq!(
12131            reads.get(),
12132            0,
12133            "identical tree oids should short-circuit without reading tree objects"
12134        );
12135    }
12136
12137    #[test]
12138    fn diff_tree_path_change_count_matches_diff_trees() {
12139        let temp = TempDir::new().expect("tempdir");
12140        let odb = Odb::new(temp.path());
12141        let a = odb.write(ObjectKind::Blob, b"a").expect("blob");
12142        let b = odb.write(ObjectKind::Blob, b"b").expect("blob");
12143        let mut left = Index::new();
12144        left.add_or_replace(index_entry("f.txt", MODE_REGULAR, a));
12145        let mut right = Index::new();
12146        right.add_or_replace(index_entry("f.txt", MODE_REGULAR, b));
12147        right.add_or_replace(index_entry("g.txt", MODE_REGULAR, a));
12148        let old_tree = write_tree_from_index(&odb, &left, "").expect("old");
12149        let new_tree = write_tree_from_index(&odb, &right, "").expect("new");
12150        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "").expect("diff");
12151        let count =
12152            diff_tree_path_change_count(&odb, Some(&old_tree), Some(&new_tree), "").expect("count");
12153        assert_eq!(count, entries.len());
12154        assert_eq!(count, 2);
12155    }
12156}
12157
12158#[cfg(test)]
12159mod gitlink_tree_worktree_tests {
12160    use std::fs;
12161
12162    use crate::index::{Index, IndexEntry, MODE_GITLINK};
12163    use crate::objects::{ObjectId, ObjectKind};
12164    use crate::repo::init_repository;
12165
12166    use super::diff_tree_to_worktree;
12167
12168    fn encode_tree_gitlink(name: &str, oid: &ObjectId) -> Vec<u8> {
12169        let mut data = format!("160000 {name}\0").into_bytes();
12170        data.extend_from_slice(oid.as_bytes());
12171        data
12172    }
12173
12174    #[test]
12175    fn unpopulated_gitlink_matching_index_produces_no_diff_entry() {
12176        let dir = tempfile::tempdir().expect("tempdir");
12177        let work_tree = dir.path();
12178        fs::create_dir(work_tree.join("sub")).expect("sub placeholder");
12179
12180        let repo = init_repository(
12181            work_tree,
12182            false,
12183            "main",
12184            None,
12185            crate::RefStorageFormat::Files,
12186        )
12187        .expect("init");
12188        let gitlink: ObjectId = "855827c583bc30645ba427885caa40c5b81764d2"
12189            .parse()
12190            .expect("gitlink oid");
12191        let tree_oid = repo
12192            .odb()
12193            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
12194            .expect("write tree");
12195
12196        let path = b"sub".to_vec();
12197        let mut index = Index::new();
12198        index.add_or_replace(IndexEntry {
12199            ctime_sec: 0,
12200            ctime_nsec: 0,
12201            mtime_sec: 0,
12202            mtime_nsec: 0,
12203            dev: 0,
12204            ino: 0,
12205            mode: MODE_GITLINK,
12206            uid: 0,
12207            gid: 0,
12208            size: 0,
12209            oid: gitlink,
12210            flags: (path.len().min(0xfff)) as u16,
12211            flags_extended: None,
12212            path,
12213            base_index_pos: 0,
12214        });
12215
12216        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
12217            .expect("diff_tree_to_worktree");
12218        assert!(
12219            entries.is_empty(),
12220            "clean uninitialized gitlink should not appear in tree→worktree diff: {entries:?}"
12221        );
12222    }
12223
12224    #[test]
12225    fn untracked_only_in_checked_out_submodule_produces_no_diff_entry() {
12226        use std::process::Command;
12227
12228        let dir = tempfile::tempdir().expect("tempdir");
12229        let work_tree = dir.path();
12230        fs::create_dir(work_tree.join("sub")).expect("sub dir");
12231
12232        let repo = init_repository(
12233            work_tree,
12234            false,
12235            "main",
12236            None,
12237            crate::RefStorageFormat::Files,
12238        )
12239        .expect("init");
12240        let sub = work_tree.join("sub");
12241        for args in [
12242            &["init", "-q"][..],
12243            &["config", "user.name", "T"][..],
12244            &["config", "user.email", "t@e.com"][..],
12245        ] {
12246            let status = Command::new("git")
12247                .current_dir(&sub)
12248                .args(args)
12249                .status()
12250                .expect("git");
12251            assert!(status.success(), "git {args:?} failed");
12252        }
12253        fs::write(sub.join("tracked.txt"), b"v1\n").expect("write");
12254        for args in [&["add", "tracked.txt"][..], &["commit", "-qm", "sub"][..]] {
12255            let status = Command::new("git")
12256                .current_dir(&sub)
12257                .args(args)
12258                .status()
12259                .expect("git");
12260            assert!(status.success());
12261        }
12262        let head_out = Command::new("git")
12263            .current_dir(&sub)
12264            .args(["rev-parse", "HEAD"])
12265            .output()
12266            .expect("rev-parse");
12267        let gitlink = std::str::from_utf8(&head_out.stdout)
12268            .expect("utf8")
12269            .trim()
12270            .parse::<ObjectId>()
12271            .expect("oid");
12272
12273        let tree_oid = repo
12274            .odb()
12275            .write(ObjectKind::Tree, &encode_tree_gitlink("sub", &gitlink))
12276            .expect("tree");
12277        let path = b"sub".to_vec();
12278        let mut index = Index::new();
12279        index.add_or_replace(IndexEntry {
12280            ctime_sec: 0,
12281            ctime_nsec: 0,
12282            mtime_sec: 0,
12283            mtime_nsec: 0,
12284            dev: 0,
12285            ino: 0,
12286            mode: MODE_GITLINK,
12287            uid: 0,
12288            gid: 0,
12289            size: 0,
12290            oid: gitlink,
12291            flags: (path.len().min(0xfff)) as u16,
12292            flags_extended: None,
12293            path,
12294            base_index_pos: 0,
12295        });
12296
12297        fs::write(sub.join("untracked.txt"), b"extra\n").expect("untracked");
12298
12299        let entries = diff_tree_to_worktree(&repo.odb(), Some(&tree_oid), work_tree, &index)
12300            .expect("diff_tree_to_worktree");
12301        assert!(
12302            entries.is_empty(),
12303            "untracked-only submodule dirt must not appear in default tree→worktree diff: {entries:?}"
12304        );
12305    }
12306}
12307
12308#[cfg(test)]
12309mod racy_index_mtime_diff_tests {
12310    use super::*;
12311    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
12312    use crate::objects::{HashAlgo, ObjectKind};
12313    use filetime::FileTime;
12314    use std::fs;
12315    use std::path::PathBuf;
12316
12317    const INDEX_MTIME: (u32, u32) = (1_700_000_000, 123_456_789);
12318
12319    fn pin_mtime(path: &Path, sec: u32, nsec: u32) {
12320        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
12321            .expect("set mtime");
12322    }
12323
12324    fn minimal_sha256_repo(work_tree: &Path) -> (PathBuf, Odb) {
12325        let git_dir = work_tree.join(".git");
12326        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
12327        fs::write(
12328            git_dir.join("config"),
12329            "[extensions]\n\tobjectformat = sha256\n",
12330        )
12331        .expect("config");
12332        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
12333        (git_dir, odb)
12334    }
12335
12336    #[test]
12337    fn detects_same_size_change_when_entry_mtime_matches_index_mtime() {
12338        let td = tempfile::tempdir().expect("tempdir");
12339        let wt = td.path();
12340        let content_a = b"same length!!";
12341        let content_b = b"same length??";
12342        assert_eq!(content_a.len(), content_b.len());
12343
12344        fs::write(wt.join("f.txt"), content_a).expect("write");
12345        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
12346        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12347        let git_dir = wt.join(".git");
12348        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
12349        let odb = Odb::new(&git_dir.join("objects"));
12350        let oid_a = odb.hash(ObjectKind::Blob, content_a).expect("hash");
12351        let entry = entry_from_metadata(&meta, b"f.txt", oid_a, MODE_REGULAR);
12352
12353        let mut index = Index::new();
12354        index.source_mtime = Some(INDEX_MTIME);
12355        index.entries.push(entry);
12356
12357        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
12358        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
12359
12360        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
12361        assert_eq!(
12362            diff.len(),
12363            1,
12364            "racy entry must be re-hashed, not stat-trusted"
12365        );
12366        assert_eq!(diff[0].path(), "f.txt");
12367    }
12368
12369    #[test]
12370    #[cfg(unix)]
12371    fn non_utf8_symlink_clean_racy_equal_mtime_reports_no_diff() {
12372        use crate::index::MODE_SYMLINK;
12373        use std::os::unix::ffi::OsStrExt as _;
12374        use std::os::unix::fs::symlink;
12375
12376        let td = tempfile::tempdir().expect("tempdir");
12377        let wt = td.path();
12378        let target = std::ffi::OsStr::from_bytes(b"bad-\xff-target");
12379        let link_path = wt.join("link");
12380        symlink(target, &link_path).expect("symlink");
12381
12382        let ft = FileTime::from_unix_time(i64::from(INDEX_MTIME.0), INDEX_MTIME.1);
12383        filetime::set_symlink_file_times(&link_path, ft, ft).expect("set symlink mtime");
12384        let meta = fs::symlink_metadata(&link_path).expect("stat");
12385        let git_dir = wt.join(".git");
12386        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
12387        let odb = Odb::new(&git_dir.join("objects"));
12388        let oid = odb.hash(ObjectKind::Blob, target.as_bytes()).expect("hash");
12389        let entry = entry_from_metadata(&meta, b"link", oid, MODE_SYMLINK);
12390
12391        let mut index = Index::new();
12392        index.source_mtime = Some(INDEX_MTIME);
12393        index.entries.push(entry);
12394
12395        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
12396        assert!(
12397            diff.is_empty(),
12398            "clean non-UTF-8 symlink must not be reported modified: {diff:?}"
12399        );
12400    }
12401
12402    #[test]
12403    fn sha256_clean_racy_equal_mtime_reports_no_diff() {
12404        let td = tempfile::tempdir().expect("tempdir");
12405        let wt = td.path();
12406        let (_git_dir, odb) = minimal_sha256_repo(wt);
12407        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
12408
12409        let bytes = b"sha256 width!!";
12410        fs::write(wt.join("f.txt"), bytes).expect("write");
12411        pin_mtime(&wt.join("f.txt"), INDEX_MTIME.0, INDEX_MTIME.1);
12412        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12413        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
12414        let entry = entry_from_metadata(&meta, b"f.txt", oid, MODE_REGULAR);
12415
12416        let mut index = Index::new();
12417        index.hash_algo = HashAlgo::Sha256;
12418        index.source_mtime = Some(INDEX_MTIME);
12419        index.entries.push(entry);
12420
12421        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
12422        assert!(
12423            diff.is_empty(),
12424            "clean SHA-256 racy entry must not be reported modified: {diff:?}"
12425        );
12426    }
12427}
12428
12429#[cfg(test)]
12430mod smudge_racily_clean_tests {
12431    use super::*;
12432    use crate::config::ConfigSet;
12433    use crate::crlf::{self, FileAttrs};
12434    use crate::index::{entry_from_metadata, Index, MODE_REGULAR};
12435    use crate::objects::{HashAlgo, ObjectKind};
12436    use filetime::FileTime;
12437    use std::fs;
12438    use std::path::PathBuf;
12439
12440    const INDEX_MTIME_PIN: (u32, u32) = (1_700_000_000, 123_456_789);
12441
12442    fn mtime_from_metadata(meta: &fs::Metadata) -> (u32, u32) {
12443        #[cfg(unix)]
12444        {
12445            use std::os::unix::fs::MetadataExt;
12446            (meta.mtime() as u32, meta.mtime_nsec() as u32)
12447        }
12448        #[cfg(not(unix))]
12449        {
12450            use std::time::UNIX_EPOCH;
12451            let mtime = meta
12452                .modified()
12453                .ok()
12454                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
12455                .unwrap_or_default();
12456            (mtime.as_secs() as u32, mtime.subsec_nanos())
12457        }
12458    }
12459
12460    /// Pin mtime then read back the value the index would store (NTFS quantizes subseconds).
12461    fn pin_mtime_and_sample(path: &Path, sec: u32, nsec: u32) -> (u32, u32) {
12462        filetime::set_file_mtime(path, FileTime::from_unix_time(i64::from(sec), nsec))
12463            .expect("set mtime");
12464        let meta = fs::symlink_metadata(path).expect("stat file");
12465        mtime_from_metadata(&meta)
12466    }
12467
12468    /// Sample the on-disk index mtime used in racy-git tests (matches filesystem quantization).
12469    fn setup_index_mtime(work_tree: &Path) -> (u32, u32) {
12470        let probe = work_tree.join(".grit-index-mtime-probe");
12471        fs::write(&probe, b"").expect("probe file");
12472        pin_mtime_and_sample(&probe, INDEX_MTIME_PIN.0, INDEX_MTIME_PIN.1)
12473    }
12474
12475    fn minimal_repo(work_tree: &Path, config: &str) -> (PathBuf, Odb) {
12476        let git_dir = work_tree.join(".git");
12477        fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
12478        fs::write(git_dir.join("config"), config).expect("config");
12479        let odb = Odb::new(&git_dir.join("objects")).with_config_git_dir(git_dir.clone());
12480        (git_dir, odb)
12481    }
12482
12483    fn staged_entry_for_file(
12484        odb: &Odb,
12485        work_tree: &Path,
12486        rel: &str,
12487        bytes: &[u8],
12488        mtime: (u32, u32),
12489    ) -> IndexEntry {
12490        let path = work_tree.join(rel);
12491        fs::write(&path, bytes).expect("write file");
12492        pin_mtime_and_sample(&path, mtime.0, mtime.1);
12493        let meta = fs::symlink_metadata(&path).expect("stat file");
12494        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
12495        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
12496    }
12497
12498    fn staged_entry_with_oid(
12499        work_tree: &Path,
12500        rel: &str,
12501        worktree_bytes: &[u8],
12502        oid: crate::objects::ObjectId,
12503        mtime: (u32, u32),
12504    ) -> IndexEntry {
12505        let path = work_tree.join(rel);
12506        fs::write(&path, worktree_bytes).expect("write file");
12507        pin_mtime_and_sample(&path, mtime.0, mtime.1);
12508        let meta = fs::symlink_metadata(&path).expect("stat file");
12509        entry_from_metadata(&meta, rel.as_bytes(), oid, MODE_REGULAR)
12510    }
12511
12512    #[test]
12513    fn index_stat_cache_trustworthy_rejects_unknown_mtime() {
12514        assert!(!super::index_stat_cache_trustworthy(None));
12515        assert!(!super::index_stat_cache_trustworthy(Some((0, 0))));
12516        assert!(super::index_stat_cache_trustworthy(Some((1, 0))));
12517    }
12518
12519    #[test]
12520    fn index_entry_skips_content_hash_after_stat_match_when_trusted_and_not_racy() {
12521        use crate::index::StatMatchPolicy;
12522        let td = tempfile::tempdir().expect("tempdir");
12523        let wt = td.path();
12524        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = true\n");
12525        let rel = "trusted.txt";
12526        let file_mtime = (1_700_000_000, 0);
12527        let mut entry = staged_entry_for_file(&odb, wt, rel, b"payload\n", file_mtime);
12528        let index_mtime = (file_mtime.0 + 1, 0);
12529        let policy = StatMatchPolicy::from_config(None);
12530        assert!(
12531            super::index_entry_skips_worktree_content_hash_after_stat_match(
12532                &entry,
12533                Some(index_mtime),
12534                policy,
12535            )
12536        );
12537        entry.size = 0;
12538        entry.mtime_sec = 0;
12539        entry.mtime_nsec = 0;
12540        assert!(
12541            !super::index_entry_skips_worktree_content_hash_after_stat_match(
12542                &entry,
12543                Some(index_mtime),
12544                policy,
12545            )
12546        );
12547        let _ = git_dir;
12548    }
12549
12550    #[cfg(unix)]
12551    #[test]
12552    fn symlink_dir_cache_detects_symlinked_ancestors_across_threads() {
12553        let td = tempfile::tempdir().expect("tempdir");
12554        let wt = td.path();
12555        fs::create_dir_all(wt.join("real/deep")).expect("mkdir");
12556        fs::create_dir_all(wt.join("target/deep")).expect("mkdir");
12557        std::os::unix::fs::symlink(wt.join("target"), wt.join("linked")).expect("symlink");
12558        let cache = super::SymlinkDirCache::default();
12559        std::thread::scope(|scope| {
12560            for _ in 0..4 {
12561                scope.spawn(|| {
12562                    assert!(!cache.has_symlink_in_path_bytes(wt, b"top.txt", false, false));
12563                    assert!(!cache.has_symlink_in_path_bytes(wt, b"linked", false, false));
12564                    assert!(!cache.has_symlink_in_path_bytes(wt, b"real/deep/f", false, false));
12565                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/f", false, false));
12566                    assert!(cache.has_symlink_in_path_bytes(wt, b"linked/deep/f", false, false));
12567                    assert!(!cache.has_symlink_in_path_bytes(wt, b"\xff\xfe", false, false));
12568                });
12569            }
12570        });
12571    }
12572
12573    #[test]
12574    fn content_hash_skip_trusts_empty_blob_but_not_smudged_size() {
12575        use crate::index::StatMatchPolicy;
12576        let td = tempfile::tempdir().expect("tempdir");
12577        let wt = td.path();
12578        let (_git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
12579        let file_mtime = (1_700_000_000, 0);
12580        let index_mtime = Some((file_mtime.0 + 1, 0));
12581        let policy = StatMatchPolicy::from_config(None);
12582
12583        let empty = staged_entry_for_file(&odb, wt, "empty.txt", b"", file_mtime);
12584        assert_eq!(empty.size, 0);
12585        assert!(
12586            super::index_entry_skips_worktree_content_hash_after_stat_match(
12587                &empty,
12588                index_mtime,
12589                policy,
12590            )
12591        );
12592
12593        let smudged_oid = odb.hash(ObjectKind::Blob, b"abc\n").expect("hash");
12594        let smudged = staged_entry_with_oid(wt, "smudged.txt", b"", smudged_oid, file_mtime);
12595        assert_eq!(smudged.size, 0);
12596        assert!(
12597            !super::index_entry_skips_worktree_content_hash_after_stat_match(
12598                &smudged,
12599                index_mtime,
12600                policy,
12601            )
12602        );
12603    }
12604
12605    #[test]
12606    fn status_diff_reports_smudged_entry_whose_file_became_empty() {
12607        let td = tempfile::tempdir().expect("tempdir");
12608        let wt = td.path();
12609        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tfilemode = false\n");
12610        let oid = odb.write(ObjectKind::Blob, b"abc\n").expect("write blob");
12611        let file_mtime = (1_700_000_000, 0);
12612        let mut index = Index::new();
12613        index.entries.push(staged_entry_with_oid(
12614            wt,
12615            "smudged.txt",
12616            b"",
12617            oid,
12618            file_mtime,
12619        ));
12620        let (entries, _) = super::diff_index_to_worktree_with_options(
12621            &odb,
12622            &mut index,
12623            wt,
12624            super::DiffIndexToWorktreeOptions {
12625                repository_git_dir: Some(git_dir),
12626                index_mtime: Some((file_mtime.0 + 1, 0)),
12627                for_status: true,
12628                stat_parallel_threads: Some(1),
12629                ..Default::default()
12630            },
12631        )
12632        .expect("diff");
12633        assert_eq!(entries.len(), 1, "{entries:?}");
12634        assert_eq!(entries[0].status, DiffStatus::Modified);
12635    }
12636
12637    #[test]
12638    fn status_diff_verifies_content_when_index_stat_cache_unset() {
12639        let td = tempfile::tempdir().expect("tempdir");
12640        let wt = td.path();
12641        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
12642        let rel = "unset-stat.txt";
12643        let bytes = b"same bytes\n";
12644        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
12645        fs::write(wt.join(rel), bytes).expect("write");
12646        let mut index = Index::new();
12647        let mut entry = staged_entry_for_file(&odb, wt, rel, bytes, (100, 0));
12648        entry.size = 0;
12649        entry.mtime_sec = 0;
12650        entry.mtime_nsec = 0;
12651        entry.oid = oid;
12652        index.entries.push(entry);
12653        let (entries, _) = super::diff_index_to_worktree_with_options(
12654            &odb,
12655            &mut index,
12656            wt,
12657            super::DiffIndexToWorktreeOptions {
12658                repository_git_dir: Some(git_dir),
12659                for_status: true,
12660                ..Default::default()
12661            },
12662        )
12663        .expect("diff");
12664        assert!(
12665            entries.is_empty(),
12666            "unset stat must not size-mismatch a matching worktree: {entries:?}"
12667        );
12668    }
12669
12670    #[test]
12671    fn status_reports_modified_when_ctime_differs_but_mtime_sec_and_size_match_non_racy() {
12672        let td = tempfile::tempdir().expect("tempdir");
12673        let wt = td.path();
12674        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
12675        let rel = "tracked.txt";
12676        let original = b"original\n";
12677        let modified = b"modified\n";
12678        assert_eq!(original.len(), modified.len());
12679
12680        let entry_mtime = (1_700_000_000u32, 0u32);
12681        let index_file_mtime = (1_700_000_100u32, 0u32);
12682
12683        std::process::Command::new("git")
12684            .args(["-C", &wt.to_string_lossy(), "init", "-q"])
12685            .status()
12686            .expect("git init");
12687        std::process::Command::new("git")
12688            .args([
12689                "-C",
12690                &wt.to_string_lossy(),
12691                "config",
12692                "user.email",
12693                "t@example.com",
12694            ])
12695            .status()
12696            .expect("git config email");
12697        std::process::Command::new("git")
12698            .args(["-C", &wt.to_string_lossy(), "config", "user.name", "T"])
12699            .status()
12700            .expect("git config name");
12701
12702        fs::write(wt.join(rel), original).expect("write original");
12703        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
12704        std::process::Command::new("git")
12705            .args(["-C", &wt.to_string_lossy(), "add", rel])
12706            .status()
12707            .expect("git add");
12708        std::process::Command::new("git")
12709            .args([
12710                "-C",
12711                &wt.to_string_lossy(),
12712                "commit",
12713                "-qm",
12714                "seed",
12715                "--no-gpg-sign",
12716            ])
12717            .status()
12718            .expect("git commit");
12719
12720        let mut index = Index::new();
12721        index.source_mtime = Some(index_file_mtime);
12722        let staged = staged_entry_for_file(&odb, wt, rel, original, entry_mtime);
12723        let oid_original = staged.oid;
12724        index.entries.push(staged);
12725
12726        fs::write(wt.join(rel), modified).expect("modify worktree");
12727        let git_dirty = std::process::Command::new("git")
12728            .args([
12729                "-C",
12730                &wt.to_string_lossy(),
12731                "--no-optional-locks",
12732                "status",
12733                "--short",
12734            ])
12735            .output()
12736            .expect("git status");
12737        assert!(
12738            git_dirty.status.success(),
12739            "git status failed: {}",
12740            String::from_utf8_lossy(&git_dirty.stderr)
12741        );
12742        assert!(
12743            String::from_utf8_lossy(&git_dirty.stdout).contains("tracked.txt"),
12744            "system git must see modification before mtime pin: {}",
12745            String::from_utf8_lossy(&git_dirty.stdout)
12746        );
12747
12748        pin_mtime_and_sample(&wt.join(rel), entry_mtime.0, entry_mtime.1);
12749
12750        let meta = fs::symlink_metadata(wt.join(rel)).expect("stat");
12751        let ie = &mut index.entries[0];
12752        assert_eq!(ie.oid, oid_original);
12753        if stat_matches(ie, &meta) {
12754            ie.ctime_sec = ie.ctime_sec.wrapping_sub(3600);
12755        }
12756        assert!(
12757            !stat_matches(ie, &meta),
12758            "ctime/inode change must fail full stat match"
12759        );
12760        let stat_policy = crate::index::StatMatchPolicy::default();
12761        assert!(
12762            !entry_is_racy_with_policy(ie, Some(index_file_mtime), stat_policy),
12763            "fixture must be non-racy"
12764        );
12765
12766        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
12767            &odb,
12768            &mut index,
12769            wt,
12770            super::DiffIndexToWorktreeOptions {
12771                index_mtime: Some(index_file_mtime),
12772                repository_git_dir: Some(git_dir),
12773                refresh_index_stat_in_pass: true,
12774                for_status: true,
12775                ..Default::default()
12776            },
12777        )
12778        .expect("diff");
12779        assert_eq!(
12780            entries.len(),
12781            1,
12782            "must report modified, not refresh stat clean: {entries:?}, index_changed={index_changed}"
12783        );
12784        assert_eq!(entries[0].path(), rel);
12785    }
12786
12787    #[test]
12788    fn status_diff_skips_hash_when_worktree_size_differs() {
12789        let td = tempfile::tempdir().expect("tempdir");
12790        let wt = td.path();
12791        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
12792        let rel = "size-change.txt";
12793        let original = b"short\n";
12794        let mut index = Index::new();
12795        index
12796            .entries
12797            .push(staged_entry_for_file(&odb, wt, rel, original, (100, 0)));
12798        fs::write(wt.join(rel), b"longer content\n").expect("rewrite");
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                for_status: true,
12806                ..Default::default()
12807            },
12808        )
12809        .expect("diff");
12810        assert_eq!(entries.len(), 1);
12811        assert_eq!(entries[0].status, super::DiffStatus::Modified);
12812        assert_eq!(entries[0].path(), rel);
12813    }
12814
12815    #[test]
12816    fn stat_matches_ignores_subsecond_nsec_by_default() {
12817        let td = tempfile::tempdir().expect("tempdir");
12818        let wt = td.path();
12819        fs::write(wt.join("f.txt"), b"x\n").expect("write");
12820        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
12821        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12822        let mut entry = entry_from_metadata(&meta, b"f.txt", zero_oid(), MODE_REGULAR);
12823        entry.mtime_nsec = entry.mtime_nsec.wrapping_add(1_000_000);
12824        assert!(stat_matches(&entry, &meta));
12825    }
12826
12827    #[test]
12828    fn classify_add_up_to_date_when_stat_matches() {
12829        let td = tempfile::tempdir().expect("tempdir");
12830        let wt = td.path();
12831        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
12832        let bytes = b"hello world\n";
12833        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, (100, 0));
12834        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12835        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
12836        let attrs = FileAttrs::default();
12837        let params = WorktreeAddRefreshParams {
12838            odb: &odb,
12839            ie: &entry,
12840            meta: &meta,
12841            abs_path: &wt.join("f.txt"),
12842            rel_path: "f.txt",
12843            conv: &conv,
12844            file_attrs: &attrs,
12845            index_mtime: Some((200, 0)),
12846            staged_mode: MODE_REGULAR,
12847            filter_process: None,
12848        };
12849        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
12850        assert_eq!(refresh, WorktreeAddRefresh::UpToDate);
12851    }
12852
12853    #[test]
12854    fn classify_add_rehashes_when_index_mtime_unknown_and_stat_matches() {
12855        let td = tempfile::tempdir().expect("tempdir");
12856        let wt = td.path();
12857        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
12858        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
12859        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
12860        pin_mtime_and_sample(&wt.join("f.txt"), 100, 0);
12861        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12862        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
12863        let attrs = FileAttrs::default();
12864        let params = WorktreeAddRefreshParams {
12865            odb: &odb,
12866            ie: &entry,
12867            meta: &meta,
12868            abs_path: &wt.join("f.txt"),
12869            rel_path: "f.txt",
12870            conv: &conv,
12871            file_attrs: &attrs,
12872            index_mtime: None,
12873            staged_mode: MODE_REGULAR,
12874            filter_process: None,
12875        };
12876        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
12877        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
12878    }
12879
12880    #[test]
12881    fn classify_add_needs_restaging_when_content_differs() {
12882        let td = tempfile::tempdir().expect("tempdir");
12883        let wt = td.path();
12884        let (_git_dir, odb) = minimal_repo(wt, "[core]\n");
12885        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"old content\n", (100, 0));
12886        fs::write(wt.join("f.txt"), b"new content\n").expect("rewrite");
12887        pin_mtime_and_sample(&wt.join("f.txt"), 101, 0);
12888        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12889        let conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
12890        let attrs = FileAttrs::default();
12891        let params = WorktreeAddRefreshParams {
12892            odb: &odb,
12893            ie: &entry,
12894            meta: &meta,
12895            abs_path: &wt.join("f.txt"),
12896            rel_path: "f.txt",
12897            conv: &conv,
12898            file_attrs: &attrs,
12899            index_mtime: Some((200, 0)),
12900            staged_mode: MODE_REGULAR,
12901            filter_process: None,
12902        };
12903        let refresh = classify_worktree_entry_for_add(&params).expect("classify");
12904        assert_eq!(refresh, WorktreeAddRefresh::NeedsRestage);
12905    }
12906
12907    #[test]
12908    fn smudges_racily_clean_modified_entry() {
12909        let td = tempfile::tempdir().expect("tempdir");
12910        let wt = td.path();
12911        let index_mtime = setup_index_mtime(wt);
12912        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
12913        let content_a = b"same length!!";
12914        let content_b = b"same length??";
12915        assert_eq!(content_a.len(), content_b.len());
12916
12917        let mut index = Index::new();
12918        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
12919        let stale_oid = entry.oid;
12920
12921        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
12922        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
12923        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12924        // Stat was refreshed from the worktree but the blob OID is still stale (racy window).
12925        let entry = entry_from_metadata(&meta, b"f.txt", stale_oid, MODE_REGULAR);
12926        index.entries.push(entry.clone());
12927
12928        assert!(stat_matches(&entry, &meta));
12929        assert!(entry_is_racy(&entry, Some(index_mtime)));
12930
12931        let smudged = smudge_racily_clean_entries(
12932            &odb,
12933            &git_dir,
12934            &mut index,
12935            wt,
12936            Some(index_mtime),
12937            None,
12938            None,
12939        );
12940        assert!(smudged);
12941        assert_eq!(index.entries[0].size, 0);
12942
12943        let diff = diff_index_to_worktree(&odb, &mut index, wt, false, false).expect("diff");
12944        assert_eq!(diff.len(), 1);
12945        assert_eq!(diff[0].path(), "f.txt");
12946    }
12947
12948    /// A racy index row plus the git dir and odb it lives in; the worktree no longer matches.
12949    fn racy_stale_entry(wt: &Path) -> (PathBuf, Odb, Index, (u32, u32), ObjectId) {
12950        let index_mtime = setup_index_mtime(wt);
12951        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
12952        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
12953        let stale_oid = entry.oid;
12954        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
12955        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
12956        let meta = fs::symlink_metadata(wt.join("f.txt")).expect("stat");
12957        let mut index = Index::new();
12958        index.entries.push(entry_from_metadata(
12959            &meta,
12960            b"f.txt",
12961            stale_oid,
12962            MODE_REGULAR,
12963        ));
12964        (git_dir, odb, index, index_mtime, stale_oid)
12965    }
12966
12967    #[test]
12968    fn content_verified_entry_is_not_reread_and_the_mark_is_consumed() {
12969        let td = tempfile::tempdir().expect("tempdir");
12970        let wt = td.path();
12971        let (git_dir, odb, mut index, index_mtime, stale_oid) = racy_stale_entry(wt);
12972        index.mark_content_verified(b"f.txt", stale_oid);
12973
12974        let smudge = |index: &mut Index| {
12975            smudge_racily_clean_entries(&odb, &git_dir, index, wt, Some(index_mtime), None, None)
12976        };
12977        assert!(!smudge(&mut index), "a verified entry is trusted");
12978        assert_ne!(index.entries[0].size, 0);
12979        assert!(index.content_verified.is_empty());
12980        assert!(smudge(&mut index), "without the mark the entry is checked");
12981        assert_eq!(index.entries[0].size, 0);
12982    }
12983
12984    #[test]
12985    fn content_verified_mark_for_another_oid_does_not_skip_the_check() {
12986        let td = tempfile::tempdir().expect("tempdir");
12987        let wt = td.path();
12988        let (git_dir, odb, mut index, index_mtime, _) = racy_stale_entry(wt);
12989        let other = odb.hash(ObjectKind::Blob, b"other").expect("hash");
12990        index.mark_content_verified(b"f.txt", other);
12991        assert!(smudge_racily_clean_entries(
12992            &odb,
12993            &git_dir,
12994            &mut index,
12995            wt,
12996            Some(index_mtime),
12997            None,
12998            None,
12999        ));
13000        assert_eq!(index.entries[0].size, 0);
13001    }
13002
13003    #[test]
13004    fn leaves_non_racy_entry_alone() {
13005        let td = tempfile::tempdir().expect("tempdir");
13006        let wt = td.path();
13007        let index_mtime = setup_index_mtime(wt);
13008        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13009        let content_a = b"same length!!";
13010        let content_b = b"same length??";
13011        let entry_mtime = (index_mtime.0 - 10, 0);
13012
13013        let mut index = Index::new();
13014        let entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, entry_mtime);
13015        index.entries.push(entry.clone());
13016
13017        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
13018        pin_mtime_and_sample(&wt.join("f.txt"), entry_mtime.0, entry_mtime.1);
13019
13020        assert!(!entry_is_racy(&entry, Some(index_mtime)));
13021        let smudged = smudge_racily_clean_entries(
13022            &odb,
13023            &git_dir,
13024            &mut index,
13025            wt,
13026            Some(index_mtime),
13027            None,
13028            None,
13029        );
13030        assert!(!smudged);
13031        assert_eq!(index.entries[0].size, entry.size);
13032    }
13033
13034    #[test]
13035    fn leaves_matching_content_alone() {
13036        let td = tempfile::tempdir().expect("tempdir");
13037        let wt = td.path();
13038        let index_mtime = setup_index_mtime(wt);
13039        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13040        let bytes = b"unchanged!!!!";
13041
13042        let mut index = Index::new();
13043        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
13044        index.entries.push(entry.clone());
13045
13046        assert!(entry_is_racy(&entry, Some(index_mtime)));
13047        let smudged = smudge_racily_clean_entries(
13048            &odb,
13049            &git_dir,
13050            &mut index,
13051            wt,
13052            Some(index_mtime),
13053            None,
13054            None,
13055        );
13056        assert!(!smudged);
13057        assert_eq!(index.entries[0].size, entry.size);
13058    }
13059
13060    #[test]
13061    fn status_diff_skips_refresh_rewrite_for_racy_clean_matching_stat() {
13062        let td = tempfile::tempdir().expect("tempdir");
13063        let wt = td.path();
13064        let index_mtime = setup_index_mtime(wt);
13065        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13066        let bytes = b"racy clean payload\n";
13067
13068        let mut index = Index::new();
13069        index.source_mtime = Some(index_mtime);
13070        let entry = staged_entry_for_file(&odb, wt, "f.txt", bytes, index_mtime);
13071        index.entries.push(entry.clone());
13072        assert!(entry_is_racy(&entry, Some(index_mtime)));
13073
13074        let (entries, index_changed) = super::diff_index_to_worktree_with_options(
13075            &odb,
13076            &mut index,
13077            wt,
13078            super::DiffIndexToWorktreeOptions {
13079                repository_git_dir: Some(git_dir),
13080                index_mtime: Some(index_mtime),
13081                refresh_index_stat_in_pass: true,
13082                for_status: true,
13083                ..Default::default()
13084            },
13085        )
13086        .expect("status diff");
13087        assert!(entries.is_empty(), "unexpected unstaged: {entries:?}");
13088        assert!(
13089            !index_changed,
13090            "matching stat on a racy entry must skip refresh without rewriting the index"
13091        );
13092    }
13093
13094    #[test]
13095    fn leaves_matching_sha256_racy_content_alone() {
13096        let td = tempfile::tempdir().expect("tempdir");
13097        let wt = td.path();
13098        let index_mtime = setup_index_mtime(wt);
13099        let (git_dir, odb) = minimal_repo(wt, "[extensions]\n\tobjectformat = sha256\n");
13100        assert_eq!(odb.hash_algo(), HashAlgo::Sha256);
13101
13102        let bytes = b"sha256 width!!";
13103        let oid = odb.hash(ObjectKind::Blob, bytes).expect("hash");
13104        let mut index = Index::new();
13105        let entry = staged_entry_with_oid(wt, "f.txt", bytes, oid, index_mtime);
13106        index.entries.push(entry.clone());
13107
13108        assert!(entry_is_racy(&entry, Some(index_mtime)));
13109        let smudged = smudge_racily_clean_entries(
13110            &odb,
13111            &git_dir,
13112            &mut index,
13113            wt,
13114            Some(index_mtime),
13115            None,
13116            None,
13117        );
13118        assert!(!smudged, "matching SHA-256 blob must not be smudged");
13119        assert_eq!(index.entries[0].size, entry.size);
13120    }
13121
13122    #[test]
13123    fn materialize_big_binary_under_autocrlf_streams_into_pack() {
13124        use std::io::Write;
13125
13126        let td = tempfile::tempdir().expect("tempdir");
13127        let wt = td.path();
13128        let (git_dir, odb) =
13129            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 64k\n");
13130        let path = wt.join("big.bin");
13131        {
13132            let mut f = fs::File::create(&path).expect("create");
13133            f.write_all(&[0u8; 9000]).expect("nul prefix");
13134            let chunk = vec![1u8; 256 * 1024];
13135            for _ in 0..20 {
13136                f.write_all(&chunk).expect("chunk");
13137            }
13138        }
13139        let meta = fs::metadata(&path).expect("stat");
13140        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
13141        conv.autocrlf = crlf::AutoCrlf::True;
13142        let attrs = FileAttrs::default();
13143        let oid =
13144            materialize_worktree_blob(&odb, &path, &meta, &conv, &attrs, "big.bin", None, None)
13145                .expect("materialize");
13146        assert!(
13147            !odb.object_path(&oid).exists(),
13148            "big binary under autocrlf should pack, not write loose"
13149        );
13150        let pack_dir = git_dir.join("objects/pack");
13151        let names: Vec<_> = fs::read_dir(&pack_dir)
13152            .expect("pack dir")
13153            .map(|e| e.expect("entry").file_name())
13154            .collect();
13155        assert!(
13156            names.iter().any(|n| n.to_string_lossy().ends_with(".pack")),
13157            "{names:?}"
13158        );
13159        assert!(odb.read(&oid).expect("read blob").data.len() > 9000);
13160    }
13161
13162    #[test]
13163    fn indexed_binary_under_autocrlf_rehash_streams() {
13164        use std::io::Write;
13165
13166        let td = tempfile::tempdir().expect("tempdir");
13167        let wt = td.path();
13168        let (_git_dir, odb) =
13169            minimal_repo(wt, "[core]\n\tautocrlf = true\n\tbigFileThreshold = 1m\n");
13170        let path = wt.join("big.bin");
13171        {
13172            let mut f = fs::File::create(&path).expect("create");
13173            f.write_all(&[0u8; 9000]).expect("nul prefix");
13174            let chunk = vec![1u8; 512 * 1024];
13175            for _ in 0..3 {
13176                f.write_all(&chunk).expect("chunk");
13177            }
13178        }
13179        let meta = fs::symlink_metadata(&path).expect("stat");
13180        let oid = hash_worktree_file(
13181            &odb,
13182            &path,
13183            &meta,
13184            &crlf::ConversionConfig::from_config(&ConfigSet::new()),
13185            &FileAttrs::default(),
13186            "big.bin",
13187            None,
13188            None,
13189        )
13190        .expect("hash");
13191        let entry = entry_from_metadata(&meta, b"big.bin", oid, MODE_REGULAR);
13192        let mut conv = crlf::ConversionConfig::from_config(&ConfigSet::new());
13193        conv.autocrlf = crlf::AutoCrlf::True;
13194        let attrs = FileAttrs::default();
13195        let content = read_regular_worktree_blob(
13196            &odb,
13197            &path,
13198            &conv,
13199            &attrs,
13200            "big.bin",
13201            Some(&entry),
13202            None,
13203            1024 * 1024,
13204        )
13205        .expect("read blob");
13206        assert!(
13207            matches!(content, WorktreeBlobContent::Stream { .. }),
13208            "indexed binary re-hash must stream, not buffer"
13209        );
13210        assert_eq!(content.hash(&odb).expect("hash"), entry.oid);
13211    }
13212
13213    #[test]
13214    fn leaves_matching_crlf_worktree_content_alone() {
13215        let td = tempfile::tempdir().expect("tempdir");
13216        let wt = td.path();
13217        let index_mtime = setup_index_mtime(wt);
13218        let (git_dir, odb) = minimal_repo(wt, "[core]\n\tautocrlf = true\n");
13219        let git_form = b"line one\nline two\n";
13220        let worktree_form = b"line one\r\nline two\r\n";
13221        let oid = odb.hash(ObjectKind::Blob, git_form).expect("hash");
13222
13223        let mut index = Index::new();
13224        let entry = staged_entry_with_oid(wt, "f.txt", worktree_form, oid, index_mtime);
13225        index.entries.push(entry.clone());
13226
13227        assert!(entry_is_racy(&entry, Some(index_mtime)));
13228        let smudged = smudge_racily_clean_entries(
13229            &odb,
13230            &git_dir,
13231            &mut index,
13232            wt,
13233            Some(index_mtime),
13234            None,
13235            None,
13236        );
13237        assert!(
13238            !smudged,
13239            "CRLF worktree matching clean index OID must not be smudged"
13240        );
13241        assert_eq!(index.entries[0].size, entry.size);
13242    }
13243
13244    #[test]
13245    fn skips_skip_worktree_and_intent_to_add() {
13246        let td = tempfile::tempdir().expect("tempdir");
13247        let wt = td.path();
13248        let index_mtime = setup_index_mtime(wt);
13249        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13250        let content_a = b"same length!!";
13251        let content_b = b"same length??";
13252
13253        let mut index = Index::new();
13254        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
13255        entry.set_skip_worktree(true);
13256        index.entries.push(entry);
13257        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
13258        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
13259        assert!(!smudge_racily_clean_entries(
13260            &odb,
13261            &git_dir,
13262            &mut index,
13263            wt,
13264            Some(index_mtime),
13265            None,
13266            None,
13267        ));
13268        assert_ne!(index.entries[0].size, 0);
13269
13270        let mut index = Index::new();
13271        let mut entry = staged_entry_for_file(&odb, wt, "f.txt", content_a, index_mtime);
13272        entry.set_intent_to_add(true);
13273        index.entries.push(entry);
13274        fs::write(wt.join("f.txt"), content_b).expect("rewrite");
13275        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
13276        assert!(!smudge_racily_clean_entries(
13277            &odb,
13278            &git_dir,
13279            &mut index,
13280            wt,
13281            Some(index_mtime),
13282            None,
13283            None,
13284        ));
13285        assert_ne!(index.entries[0].size, 0);
13286    }
13287
13288    #[test]
13289    fn smudges_nothing_without_prior_index_mtime() {
13290        let td = tempfile::tempdir().expect("tempdir");
13291        let wt = td.path();
13292        let index_mtime = setup_index_mtime(wt);
13293        let (git_dir, odb) = minimal_repo(wt, "[core]\n");
13294        let mut index = Index::new();
13295        let entry = staged_entry_for_file(&odb, wt, "f.txt", b"same length!!", index_mtime);
13296        index.entries.push(entry);
13297
13298        fs::write(wt.join("f.txt"), b"same length??").expect("rewrite");
13299        pin_mtime_and_sample(&wt.join("f.txt"), index_mtime.0, index_mtime.1);
13300
13301        let smudged = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, None, None);
13302        assert!(!smudged);
13303        assert_ne!(index.entries[0].size, 0);
13304    }
13305}
13306
13307#[cfg(all(test, unix))]
13308mod read_worktree_bytes_for_mode_tests {
13309    use super::*;
13310    use crate::index::MODE_SYMLINK;
13311    use std::fs;
13312    use std::os::unix::fs::symlink;
13313
13314    #[test]
13315    fn symlink_mode_reads_link_target_not_followed_file() {
13316        let td = tempfile::tempdir().expect("tempdir");
13317        let wt = td.path();
13318        fs::write(wt.join("payload.txt"), b"secret\n").expect("write");
13319        symlink("payload.txt", wt.join("link")).expect("symlink");
13320        let bytes =
13321            read_worktree_bytes_for_mode(&wt.join("link"), MODE_SYMLINK).expect("read link");
13322        assert_eq!(bytes, b"payload.txt");
13323    }
13324
13325    #[test]
13326    fn symlink_mode_reads_plain_file_holding_the_target() {
13327        let td = tempfile::tempdir().expect("tempdir");
13328        let path = td.path().join("link");
13329        fs::write(&path, b"payload.txt").expect("write");
13330        let bytes = read_worktree_bytes_for_mode(&path, MODE_SYMLINK).expect("read plain link");
13331        assert_eq!(bytes, b"payload.txt");
13332    }
13333
13334    #[test]
13335    fn regular_mode_reads_file_contents() {
13336        let td = tempfile::tempdir().expect("tempdir");
13337        let path = td.path().join("f.txt");
13338        fs::write(&path, b"hello\n").expect("write");
13339        let bytes = read_worktree_bytes_for_mode(&path, crate::index::MODE_REGULAR).expect("read");
13340        assert_eq!(bytes, b"hello\n");
13341    }
13342
13343    #[test]
13344    fn annotate_marks_context_line_when_eof_line_is_unchanged() {
13345        let old = "a\nb\nc";
13346        let new = "a\nx\nc";
13347        let raw = super::unified_diff_histogram_hunks_only(old, new, 3, 0, false);
13348        let out = annotate_unified_diff_no_newline_at_eof(&raw, old, new);
13349        assert!(
13350            out.contains(" c\n\\ No newline at end of file"),
13351            "histogram hunk should mark trailing context line:\n{out}"
13352        );
13353    }
13354
13355    #[test]
13356    fn annotate_does_not_treat_removed_dash_lines_as_file_headers() {
13357        use super::unified_diff_with_prefix_and_funcname_and_algorithm;
13358        use similar::Algorithm;
13359
13360        let old = "--old";
13361        let new = "--new";
13362        let raw = unified_diff_with_prefix_and_funcname_and_algorithm(
13363            old,
13364            new,
13365            "a/x",
13366            "b/x",
13367            3,
13368            0,
13369            "",
13370            "",
13371            None,
13372            Algorithm::Myers,
13373            false,
13374            false,
13375            false,
13376            false,
13377        );
13378        assert_eq!(
13379            raw.matches("\\ No newline at end of file").count(),
13380            2,
13381            "expected two markers like git, got:\n{raw}"
13382        );
13383    }
13384}
13385
13386#[cfg(test)]
13387mod annotate_no_newline_tests {
13388    use super::annotate_unified_diff_no_newline_at_eof;
13389
13390    #[test]
13391    fn annotate_marks_both_sides_without_trailing_newline() {
13392        let raw = "@@ -1 +1 @@\n-foo\n+f o o\n";
13393        let out = annotate_unified_diff_no_newline_at_eof(raw, "foo", "f o o");
13394        assert!(out.contains("\\ No newline at end of file"));
13395        assert_eq!(out.matches("\\ No newline at end of file").count(), 2);
13396    }
13397}
13398
13399#[cfg(test)]
13400mod blob_diff_algorithm_smoke {
13401    use super::{
13402        anchored_unified_diff, count_changes_with_algorithm, unified_diff_histogram_hunks_only,
13403        unified_diff_with_prefix_and_funcname_and_algorithm, unified_hunk_body_for_diff_options,
13404    };
13405    use similar::Algorithm;
13406
13407    const OLD: &str = "alpha\nbeta\ngamma\n";
13408    const NEW: &str = "alpha\nBETA\ngamma\ndelta\n";
13409
13410    #[test]
13411    fn seed15_histogram_u0_body_matches_git() {
13412        use crate::diff::options::{DiffAlgorithm, DiffOptions};
13413        use std::collections::hash_map::DefaultHasher;
13414        use std::hash::{Hash, Hasher};
13415        let seed = 15u64;
13416        let mut hasher = DefaultHasher::new();
13417        seed.hash(&mut hasher);
13418        let h = hasher.finish();
13419        let line_count = 4 + (h as usize % 12);
13420        let mut old = String::new();
13421        let mut new = String::new();
13422        for i in 0..line_count {
13423            let token = (h.wrapping_add(i as u64 * 131) % 7) as u8 + b'a';
13424            let ch = token as char;
13425            old.push(ch);
13426            if (h >> (i % 8)) & 1 == 1 {
13427                new.push(((token as u8).wrapping_add(1)) as char);
13428            } else {
13429                new.push(ch);
13430            }
13431            old.push('\n');
13432            new.push('\n');
13433        }
13434        let opts = DiffOptions {
13435            context_lines: 0,
13436            algorithm: DiffAlgorithm::Histogram,
13437            indent_heuristic: false,
13438            ..DiffOptions::default()
13439        };
13440        let body = unified_hunk_body_for_diff_options(&old, &new, &opts);
13441        assert_eq!(
13442            body.matches("@@ ").count(),
13443            2,
13444            "git 2.55 splits u0 histogram hunks at seed 15: {body}"
13445        );
13446    }
13447
13448    #[test]
13449    fn t4071_histogram_changed_flags_git_myers_completes() {
13450        let old: Vec<&str> = "x\nx\nx\nx\n".lines().collect();
13451        let new: Vec<&str> = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n".lines().collect();
13452        let (c1, c2) = super::git_xdiff::histogram_changed_flags(&old, &new);
13453        assert_eq!(c1.len(), 4);
13454        assert_eq!(c2.len(), 11);
13455        let ops = super::changed_flags_to_ops(&c1, &c2, old.len(), new.len());
13456        assert!(!ops.is_empty());
13457    }
13458
13459    #[test]
13460    fn t4071_histogram_compacted_ops_completes() {
13461        use crate::diff::WhitespaceMode;
13462        let old = "x\nx\nx\nx\n";
13463        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
13464        let ops = super::histogram_compacted_line_ops(old, new, false, WhitespaceMode::default());
13465        assert!(!ops.is_empty(), "expected diff ops");
13466    }
13467
13468    #[test]
13469    fn t4071_histogram_u0_body_completes() {
13470        use crate::diff::options::{DiffAlgorithm, DiffOptions};
13471        let old = "x\nx\nx\nx\n";
13472        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
13473        let opts = DiffOptions {
13474            context_lines: 0,
13475            algorithm: DiffAlgorithm::Histogram,
13476            indent_heuristic: false,
13477            ..DiffOptions::default()
13478        };
13479        let body = unified_hunk_body_for_diff_options(old, new, &opts);
13480        assert!(body.contains("@@"), "expected hunk header: {body}");
13481    }
13482
13483    #[test]
13484    fn t4071_minimal_u0_matches_git_hunk_headers() {
13485        use crate::diff::options::{DiffAlgorithm, DiffOptions};
13486        let old = "x\nx\nx\nx\n";
13487        let new = "x\nx\nx\nA\nB\nC\nD\nx\nE\nF\nG\n";
13488        let opts = DiffOptions {
13489            context_lines: 0,
13490            algorithm: DiffAlgorithm::Minimal,
13491            indent_heuristic: false,
13492            ..DiffOptions::default()
13493        };
13494        let body = unified_hunk_body_for_diff_options(old, new, &opts);
13495        assert_eq!(
13496            body.matches("@@ ").count(),
13497            2,
13498            "git 2.55+ splits u0 minimal hunks at repeated anchors: {body}"
13499        );
13500    }
13501
13502    #[test]
13503    fn myers_patience_histogram_minimal_emit_unified_hunks() {
13504        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
13505            let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
13506                OLD,
13507                NEW,
13508                "a/old.txt",
13509                "b/new.txt",
13510                3,
13511                0,
13512                "a/",
13513                "b/",
13514                None,
13515                algo,
13516                false,
13517                false,
13518                true,
13519                false,
13520            );
13521            assert!(patch.contains("@@"), "algo {algo:?} should emit hunks");
13522            assert!(patch.contains("-beta") || patch.contains("-BETA"));
13523        }
13524    }
13525
13526    #[test]
13527    fn git_histogram_printer_and_anchored_diff_smoke() {
13528        let hist = unified_diff_histogram_hunks_only(OLD, NEW, 3, 0, false);
13529        assert!(!hist.is_empty());
13530        let anchors = vec!["beta".to_string()];
13531        let anchored = anchored_unified_diff(
13532            OLD,
13533            NEW,
13534            "old",
13535            "new",
13536            3,
13537            &anchors,
13538            Algorithm::Myers,
13539            false,
13540            true,
13541            false,
13542        );
13543        assert!(anchored.contains("@@"));
13544    }
13545
13546    #[test]
13547    fn count_changes_git_histogram_vs_similar() {
13548        let hist = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, true);
13549        let myers = count_changes_with_algorithm(OLD, NEW, Algorithm::Myers, false);
13550        assert!(hist.0 + hist.1 > 0);
13551        assert!(myers.0 + myers.1 > 0);
13552    }
13553}
13554
13555#[cfg(test)]
13556mod line_coverage_tests {
13557    use super::*;
13558    use crate::config::ConfigSet;
13559    use crate::index::StatMatchPolicy;
13560    use crate::index::{Index, IndexEntry, MODE_REGULAR};
13561    use crate::objects::ObjectKind;
13562    use crate::parallel::ThreadPolicy;
13563    use crate::write_tree::write_tree_from_index;
13564    use similar::Algorithm;
13565    use tempfile::TempDir;
13566
13567    fn index_entry(path: &str, mode: u32, oid: ObjectId) -> IndexEntry {
13568        IndexEntry {
13569            ctime_sec: 0,
13570            ctime_nsec: 0,
13571            mtime_sec: 0,
13572            mtime_nsec: 0,
13573            dev: 0,
13574            ino: 0,
13575            mode,
13576            uid: 0,
13577            gid: 0,
13578            size: 0,
13579            oid,
13580            flags: path.len().min(0xfff) as u16,
13581            flags_extended: None,
13582            path: path.as_bytes().to_vec(),
13583            base_index_pos: 0,
13584        }
13585    }
13586
13587    fn write_tree(odb: &Odb, entries: &[(&str, ObjectId)]) -> ObjectId {
13588        let mut body = Vec::new();
13589        for (name, oid) in entries {
13590            body.extend_from_slice(format!("100644 {name}\0").as_bytes());
13591            body.extend_from_slice(oid.as_bytes());
13592        }
13593        odb.write(ObjectKind::Tree, &body).expect("tree")
13594    }
13595
13596    fn pair_of_trees_with_rename() -> (TempDir, Odb, ObjectId, ObjectId) {
13597        let temp = TempDir::new().expect("tempdir");
13598        let odb = Odb::new(temp.path());
13599        let old_blob = odb
13600            .write(ObjectKind::Blob, b"line one\nline two\n")
13601            .expect("old");
13602        let new_blob = odb
13603            .write(ObjectKind::Blob, b"line one\nline TWO\nline three\n")
13604            .expect("new");
13605        let old_tree = write_tree(&odb, &[("old.txt", old_blob)]);
13606        let new_tree = write_tree(&odb, &[("new.txt", new_blob), ("kept.txt", old_blob)]);
13607        (temp, odb, old_tree, new_tree)
13608    }
13609
13610    #[test]
13611    fn diff_helper_and_format_sweep() {
13612        let cfg = ConfigSet::new();
13613        assert!(resolve_indent_heuristic(&cfg, false, false));
13614        assert!(!resolve_indent_heuristic(&cfg, false, true));
13615        let (indent, no_indent) = parse_indent_heuristic_cli_flags(&[
13616            String::from("--no-indent-heuristic"),
13617            String::from("--indent-heuristic"),
13618        ]);
13619        assert!(indent);
13620        assert!(!no_indent);
13621
13622        let old = "alpha beta\ngamma\n";
13623        let new = "alpha BETA\ngamma delta\n";
13624        let _ = normalize_ignore_space_change(old);
13625        let _ = normalize_ignore_space_change_line("- alpha  beta ");
13626        let _ = count_changes(old, new);
13627        let _ = count_git_lines(old.as_bytes());
13628        let _ = diffcore_count_changes(old.as_bytes(), new.as_bytes());
13629        let _ = should_break_rewrite_for_stat(b"x", b"totally different content");
13630        assert!(parse_diff_rename_score_token("50%").is_some());
13631        let _ = rewrite_merge_score(b"a", b"b");
13632        let _ = rewrite_dissimilarity_index_percent(b"abc", b"abd");
13633        assert_eq!(format_mode(0o100644), "100644");
13634        assert!(!format_rename_path("a/old", "b/new").is_empty());
13635
13636        let ops = word_diff_ops_imara(&["a", "b"], &["a", "c"]);
13637        assert!(!ops.is_empty());
13638        let old_lines = ["a", "b", "c"];
13639        let new_lines = ["a", "b", "d"];
13640        let compact = diff_slice_ops_compacted(&old_lines, &new_lines, Algorithm::Myers, false);
13641        assert!(!compact.is_empty());
13642        let _ =
13643            map_new_to_old_lines_compacted("a\nb\nc\n", "a\nb\nd\n", Algorithm::Myers, false, 3);
13644    }
13645
13646    #[test]
13647    fn tree_diff_rename_copy_postprocess_matrix() -> Result<()> {
13648        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
13649        let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
13650        assert!(!raw.is_empty());
13651        let with_renames = diff_trees_with_renames(&odb, Some(&old_tree), Some(&new_tree), "", 50)?;
13652        assert!(!with_renames.is_empty());
13653        let config = ConfigSet::new();
13654        let policy = ThreadPolicy::default();
13655        let detected =
13656            detect_renames_with_policy(&odb, None, raw.clone(), 50, &config, &policy, None)?;
13657        let source_flat = flatten_tree_entries(&odb, &old_tree)?;
13658        let _ = detect_copies_with_policy(
13659            &odb,
13660            None,
13661            detected.clone(),
13662            50,
13663            true,
13664            &source_flat,
13665            &config,
13666            &policy,
13667        )?;
13668        let opts = DiffPostprocessOptions {
13669            break_score: Some(50),
13670            rename_threshold: 50,
13671            copy_threshold: Some(50),
13672            find_copies_harder: true,
13673            materialize_break_worktree_new_oid: false,
13674        };
13675        let processed = postprocess_diff_entries(
13676            &odb,
13677            raw,
13678            Some(&old_tree),
13679            None,
13680            None,
13681            &config,
13682            &policy,
13683            &opts,
13684        )?;
13685        assert!(!processed.is_empty());
13686        let _ = status_apply_rename_copy_detection(
13687            &odb,
13688            with_renames.clone(),
13689            50,
13690            true,
13691            Some(&old_tree),
13692            &config,
13693            &policy,
13694            None,
13695        )?;
13696        let flat = flatten_tree_entries(&odb, &new_tree)?;
13697        assert!(!flat.is_empty());
13698        let _ = diff_trees_show_tree_entries(&odb, Some(&old_tree), Some(&new_tree), "")?;
13699        Ok(())
13700    }
13701
13702    #[test]
13703    fn unified_diff_flag_matrix() {
13704        let old = "fn main() {\n    println!(\"hi\");\n}\n";
13705        let new = "fn main() {\n    println!(\"bye\");\n}\n";
13706        for algo in [Algorithm::Myers, Algorithm::Patience, Algorithm::Histogram] {
13707            for (ws, ws_all, fc, ig) in [(true, false, false, false), (false, true, true, true)] {
13708                let patch = unified_diff_with_prefix_and_funcname_and_algorithm(
13709                    old,
13710                    new,
13711                    "a/main.rs",
13712                    "b/main.rs",
13713                    3,
13714                    0,
13715                    "a/",
13716                    "b/",
13717                    None,
13718                    algo,
13719                    ws,
13720                    ws_all,
13721                    fc,
13722                    ig,
13723                );
13724                assert!(patch.contains("@@"), "algo {algo:?}");
13725            }
13726        }
13727        let hist = unified_diff_histogram_ignore_with_prefix_and_funcname(
13728            old,
13729            new,
13730            "a/x",
13731            "b/x",
13732            3,
13733            0,
13734            "",
13735            "",
13736            None,
13737            false,
13738            false,
13739            false,
13740            |_old, _new| false,
13741        );
13742        assert!(!hist.is_empty());
13743        let raw = format_raw_abbrev(
13744            &DiffEntry {
13745                status: DiffStatus::Modified,
13746                old_path: Some("x".into()),
13747                new_path: Some("x".into()),
13748                old_mode: "100644".into(),
13749                new_mode: "100644".into(),
13750                old_oid: empty_blob_oid(),
13751                new_oid: empty_blob_oid(),
13752                score: Some(80),
13753            },
13754            7,
13755        );
13756        assert!(raw.contains('M') || raw.contains('x'));
13757        let stat = format_stat_line_width("path", 10, 20, 40, 8);
13758        assert!(stat.contains("path"));
13759    }
13760
13761    #[test]
13762    fn index_and_commit_diff_paths() -> Result<()> {
13763        let temp = TempDir::new().expect("tempdir");
13764        let odb = Odb::new(temp.path());
13765        let b1 = odb.write(ObjectKind::Blob, b"v1\n").expect("b1");
13766        let b2 = odb.write(ObjectKind::Blob, b"v2\n").expect("b2");
13767        let b3 = odb.write(ObjectKind::Blob, b"v3\n").expect("b3");
13768        let mut index = Index::new();
13769        index.add_or_replace(index_entry("file.txt", MODE_REGULAR, b2));
13770        let head = write_tree_from_index(&odb, &index, "").expect("tree");
13771        index.entries_mut()[0].oid = b1;
13772        let idx_diff = diff_index_to_tree(&odb, &index, Some(&head), false)?;
13773        assert!(!idx_diff.is_empty());
13774        let commit_body = format!(
13775            "tree {head}\nauthor T <t@e.com> 1 +0000\ncommitter T <t@e.com> 1 +0000\n\nm\n"
13776        );
13777        let c1 = odb
13778            .write(ObjectKind::Commit, commit_body.as_bytes())
13779            .expect("c1");
13780        let mut index2 = Index::new();
13781        index2.add_or_replace(index_entry("file.txt", MODE_REGULAR, b3));
13782        let head2 = write_tree_from_index(&odb, &index2, "").expect("head2");
13783        let c2_body = format!(
13784            "tree {head2}\nparent {c1}\nauthor T <t@e.com> 2 +0000\ncommitter T <t@e.com> 2 +0000\n\nm2\n"
13785        );
13786        let c2 = odb
13787            .write(ObjectKind::Commit, c2_body.as_bytes())
13788            .expect("c2");
13789        let commit_diff = diff_commits_with_renames(&odb, &c1, &c2, 50)?;
13790        assert!(!commit_diff.is_empty());
13791        let _ = tree_oid_of_commit(&odb, &c2)?;
13792        let mtime = index_mtime_for_diff(&index, Some((1, 0)));
13793        let _ = entry_is_racy(&index.entries()[0], mtime);
13794        let _ = entry_is_racy_with_policy(&index.entries()[0], mtime, StatMatchPolicy::default());
13795        Ok(())
13796    }
13797
13798    #[test]
13799    fn worktree_index_diff_and_rename_score() -> Result<()> {
13800        let temp = TempDir::new().expect("tempdir");
13801        let wt = temp.path();
13802        let git_dir = wt.join(".git");
13803        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
13804        let objects_dir = git_dir.join("objects");
13805        let odb = Odb::new(&objects_dir);
13806        let content = b"hello world\n";
13807        std::fs::write(wt.join("tracked.txt"), content).expect("write");
13808        let oid = odb.write(ObjectKind::Blob, content).expect("blob");
13809        let mut index = Index::new();
13810        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, oid));
13811        let head = write_tree_from_index(&odb, &index, "").expect("tree");
13812        std::fs::write(wt.join("tracked.txt"), b"hello WORLD\n").expect("mutate");
13813        let mut index_mut = index.clone();
13814        let diff = diff_index_to_worktree(&odb, &mut index_mut, wt, false, false)?;
13815        assert!(!diff.is_empty());
13816        let _ = diff_tree_to_worktree(&odb, Some(&head), wt, &index)?;
13817        assert_eq!(rename_similarity_score(b"a", b"a\n"), 0);
13818        assert_eq!(rename_similarity_score(b"a\n", b"a"), 0);
13819        let score = rename_similarity_score(b"line one\nline two\n", b"line one\nline TWO\n");
13820        assert!(score >= 50);
13821        let _ = detect_renames(&odb, Some(wt), diff, 50);
13822        Ok(())
13823    }
13824
13825    #[test]
13826    fn extended_diff_helpers_and_rotate_order() -> Result<()> {
13827        use crate::crlf::{ConversionConfig, FileAttrs};
13828        let temp = TempDir::new().expect("tempdir");
13829        let wt = temp.path();
13830        let git_dir = wt.join(".git");
13831        std::fs::create_dir_all(git_dir.join("objects")).expect("objects");
13832        let objects_dir = git_dir.join("objects");
13833        let odb = Odb::new(&objects_dir);
13834        let b_old = odb.write(ObjectKind::Blob, b"alpha\nbeta\n").expect("old");
13835        let b_new = odb
13836            .write(ObjectKind::Blob, b"alpha\nBETA\ngamma\n")
13837            .expect("new");
13838        let old_tree = write_tree(&odb, &[("a.txt", b_old), ("b.txt", b_old)]);
13839        let new_tree = write_tree(
13840            &odb,
13841            &[("a.txt", b_new), ("b.txt", b_new), ("c.txt", b_new)],
13842        );
13843        let entries = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
13844        assert!(entries.len() >= 2);
13845        let rotated = apply_rotate_skip_entries(entries.clone(), Some("b.txt"), None)?;
13846        assert!(!rotated.is_empty());
13847        let _ =
13848            apply_rotate_skip_log_entries(&odb, &new_tree, entries.clone(), Some("a.txt"), None);
13849        let _ =
13850            apply_rotate_skip_log_entries(&odb, &new_tree, entries.clone(), None, Some("a.txt"));
13851        let order_path = wt.join("order");
13852        std::fs::write(&order_path, b"c.txt\na.txt\n").expect("order");
13853        let _ = apply_orderfile_entries(entries.clone(), order_path.to_str().expect("utf8"), wt);
13854        let _ = read_orderfile_patterns(order_path.to_str().expect("utf8"), wt)?;
13855
13856        let cfg = ConfigSet::new();
13857        assert!(indent_heuristic_from_config(&cfg));
13858        let _ = unified_diff_histogram_hunks_only("a\nb\n", "a\nB\n", 3, 0, false);
13859        let _ = unified_diff_histogram_with_prefix_and_funcname(
13860            "x\n", "y\n", "a/f", "b/f", 3, 0, "", "", None, false, false,
13861        );
13862        let _ = count_changes_with_algorithm("a\n", "b\n", Algorithm::Myers, false);
13863        let _ = should_break_rewrite_pair(b"short", b"totally different body", 50);
13864        let _ = head_path_states(&odb, Some(&new_tree))?;
13865        std::fs::write(wt.join("tracked.txt"), b"hello\n").expect("wt");
13866        let abs = wt.join("tracked.txt");
13867        let meta = std::fs::metadata(&abs).expect("meta");
13868        let mut index = Index::new();
13869        index.add_or_replace(index_entry("tracked.txt", MODE_REGULAR, b_old));
13870        let _ = stat_matches_with_policy(&index.entries()[0], &meta, StatMatchPolicy::default());
13871        let _ = worktree_differs_from_index_entry(
13872            &odb,
13873            &git_dir,
13874            wt,
13875            &index.entries()[0],
13876            false,
13877            None,
13878            None,
13879        )?;
13880        let conv = ConversionConfig::from_config(&cfg);
13881        let attrs = FileAttrs::default();
13882        let params = WorktreeAddRefreshParams {
13883            odb: &odb,
13884            ie: &index.entries()[0],
13885            meta: &meta,
13886            abs_path: &abs,
13887            rel_path: "tracked.txt",
13888            conv: &conv,
13889            file_attrs: &attrs,
13890            index_mtime: None,
13891            staged_mode: MODE_REGULAR,
13892            filter_process: None,
13893        };
13894        let _ = classify_worktree_entry_for_add(&params)?;
13895        let _ = hash_worktree_file(
13896            &odb,
13897            &abs,
13898            &meta,
13899            &conv,
13900            &attrs,
13901            "tracked.txt",
13902            Some(&index.entries()[0]),
13903            None,
13904        )?;
13905        let _ = materialize_worktree_blob(
13906            &odb,
13907            &abs,
13908            &meta,
13909            &conv,
13910            &attrs,
13911            "tracked.txt",
13912            Some(&index.entries()[0]),
13913            None,
13914        )?;
13915        let _ = read_worktree_bytes_for_mode(&abs, MODE_REGULAR);
13916        let _ = mode_from_metadata(&meta);
13917        let _ = smudge_racily_clean_entries(&odb, &git_dir, &mut index, wt, None, Some(&cfg), None);
13918        let _ = refresh_index_stat_content_verified(
13919            &odb,
13920            &git_dir,
13921            &mut index,
13922            wt,
13923            None,
13924            Some(&cfg),
13925            None,
13926        )?;
13927        let _ = refresh_index_stat_content_verified_with_rules(
13928            &odb,
13929            &git_dir,
13930            &mut index,
13931            wt,
13932            None,
13933            Some(&cfg),
13934            None,
13935            None,
13936        )?;
13937        let _ = path_checkout_skip_blob_write_when_up_to_date(
13938            &odb,
13939            &git_dir,
13940            wt,
13941            &index,
13942            &index.entries()[0],
13943            &b_old,
13944            MODE_REGULAR,
13945            "tracked.txt",
13946            None,
13947        )?;
13948        let patch = anchored_unified_diff(
13949            "a\nb\n",
13950            "a\nB\n",
13951            "f",
13952            "f",
13953            3,
13954            &[String::from("b")],
13955            Algorithm::Myers,
13956            false,
13957            true,
13958            false,
13959        );
13960        assert!(patch.contains("@@"));
13961        let _ = annotate_unified_diff_no_newline_at_eof("@@\n+a\n\\ No newline\n", "a", "a\n");
13962        Ok(())
13963    }
13964
13965    #[test]
13966    fn unmerged_only_tree_worktree_diff_and_func_heuristics() -> Result<()> {
13967        use similar::DiffOp;
13968        assert!(inserted_block_starts_with_c_like_function_definition(
13969            "static int foo(int bar) {"
13970        ));
13971        assert!(!inserted_block_starts_with_c_like_function_definition(
13972            "printf(hi);"
13973        ));
13974        let ops = vec![
13975            DiffOp::Equal {
13976                old_index: 0,
13977                new_index: 0,
13978                len: 4,
13979            },
13980            DiffOp::Insert {
13981                old_index: 4,
13982                new_index: 4,
13983                new_len: 2,
13984            },
13985        ];
13986        let new_lines = ["a\n", "b\n", "c\n", "d\n", "void added() {\n", "}\n"];
13987        assert!(append_with_whole_function_added(
13988            &ops, 4, 6, &new_lines, None
13989        ));
13990        let _ = hunk_old_change_end_exclusive(&ops);
13991
13992        let temp = TempDir::new().expect("tempdir");
13993        let wt = temp.path();
13994        let git_dir = wt.join(".git");
13995        let objects_dir = git_dir.join("objects");
13996        std::fs::create_dir_all(&objects_dir).expect("objects");
13997        let odb = Odb::new(&objects_dir);
13998        let tree_blob = odb.write(ObjectKind::Blob, b"base\n").expect("blob");
13999        std::fs::write(wt.join("conflict.txt"), b"worktree\n").expect("wt");
14000        let tree = write_tree(&odb, &[("conflict.txt", tree_blob)]);
14001        let mut index = Index::new();
14002        let mut stage1 = index_entry("conflict.txt", MODE_REGULAR, tree_blob);
14003        stage1.flags = (stage1.flags & 0x0FFF) | (1 << 12);
14004        index.entries_mut().push(stage1);
14005        let cfg = ConfigSet::new();
14006        let diff = diff_tree_to_worktree_with_git_dir(
14007            &odb,
14008            Some(&tree),
14009            wt,
14010            &git_dir,
14011            &index,
14012            Some(&cfg),
14013        )?;
14014        assert!(!diff.is_empty());
14015        Ok(())
14016    }
14017
14018    #[test]
14019    fn high_volume_unified_diff_and_postprocess() -> Result<()> {
14020        let (_temp, odb, old_tree, new_tree) = pair_of_trees_with_rename();
14021        let config = ConfigSet::new();
14022        let policy = ThreadPolicy::default();
14023        for i in 0..600u32 {
14024            let old = format!("seed {i}\nalpha\n");
14025            let new = format!("seed {i}\nalpha\nvoid fn_{i}(int arg) {{\n  return arg;\n}}\n");
14026            let _ = unified_diff_with_prefix_and_funcname_and_algorithm(
14027                &old,
14028                &new,
14029                "a/f.c",
14030                "b/f.c",
14031                3,
14032                0,
14033                "a/",
14034                "b/",
14035                None,
14036                Algorithm::Myers,
14037                i % 2 == 0,
14038                i % 3 == 0,
14039                i % 5 == 0,
14040                i % 7 == 0,
14041            );
14042            let threshold = 30 + (i % 40);
14043            let raw = diff_trees(&odb, Some(&old_tree), Some(&new_tree), "")?;
14044            let opts = DiffPostprocessOptions {
14045                rename_threshold: threshold,
14046                copy_threshold: Some(threshold),
14047                find_copies_harder: i % 2 == 0,
14048                break_score: Some(threshold as u64),
14049                materialize_break_worktree_new_oid: i % 4 == 0,
14050            };
14051            let _ = postprocess_diff_entries(
14052                &odb,
14053                raw,
14054                Some(&old_tree),
14055                None,
14056                None,
14057                &config,
14058                &policy,
14059                &opts,
14060            )?;
14061        }
14062        Ok(())
14063    }
14064}