Skip to main content

grit_lib/
blame.rs

1//! Blame line-mapping algorithm.
2//!
3//! Pure line-correspondence machinery used by `git blame` / `annotate`: given an
4//! old and a new version of a file (as line slices), produce a per-new-line map
5//! back to the originating old line. Both an exact map (via the configured diff
6//! algorithm, honoring the indent heuristic) and a fuzzy fallback (for lines a
7//! parent rewrote) are provided. No I/O, no repository access — strings in,
8//! mappings out. [`compute_blame`] and [`blame_file`] drive the per-commit walk and
9//! call these helpers.
10//!
11//! Library guide: [blame](https://grit-scm.com/docs/library/embedding)
12//!
13//! ```rust
14//! use grit_lib::repo::{InitOptions, Repository};
15//! # fn main() -> grit_lib::error::Result<()> {
16//! let dir = tempfile::tempdir()?;
17//! let _repo = Repository::init(dir.path(), &InitOptions::default())?;
18//! # Ok(())
19//! # }
20//! ```
21
22use std::ops::RangeInclusive;
23use std::sync::OnceLock;
24
25use similar::{Algorithm as SimilarAlgorithm, DiffOp};
26
27#[path = "blame_ignore_revs.rs"]
28mod blame_ignore_revs;
29pub use crate::line_range::{resolve_line_range, LineRangeError};
30pub use blame_ignore_revs::{IgnoreRevs, IgnoreRevsSources};
31
32/// Diff algorithm used when mapping lines between blob revisions during blame.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum BlameDiffAlgorithm {
35    /// Default line diff (Myers); used when config does not specify an algorithm.
36    Myers,
37    /// Histogram-style blame diff (mapped to patience in the `similar` crate).
38    Histogram,
39    /// Patience diff for blame line correspondence.
40    Patience,
41    /// Minimal / LCS-style diff for blame line correspondence.
42    Minimal,
43}
44
45impl BlameDiffAlgorithm {
46    /// Map this blame diff setting to a [`similar::Algorithm`] instance.
47    pub fn to_similar(self) -> SimilarAlgorithm {
48        match self {
49            // The `similar` crate doesn't expose histogram/minimal directly.
50            // These mappings are chosen to match expected blame behavior in
51            // upstream t8015 parity tests.
52            BlameDiffAlgorithm::Myers => SimilarAlgorithm::Myers,
53            BlameDiffAlgorithm::Histogram => SimilarAlgorithm::Patience,
54            BlameDiffAlgorithm::Patience => SimilarAlgorithm::Patience,
55            BlameDiffAlgorithm::Minimal => SimilarAlgorithm::Lcs,
56        }
57    }
58}
59
60/// Parse a config-style diff algorithm name (`myers`, `histogram`, …).
61pub fn parse_diff_algorithm_name(name: &str) -> Option<BlameDiffAlgorithm> {
62    match name.to_ascii_lowercase().as_str() {
63        "myers" | "default" => Some(BlameDiffAlgorithm::Myers),
64        "histogram" => Some(BlameDiffAlgorithm::Histogram),
65        "patience" => Some(BlameDiffAlgorithm::Patience),
66        "minimal" => Some(BlameDiffAlgorithm::Minimal),
67        _ => None,
68    }
69}
70
71/// Parent line index for a child line known to lie in an unchanged affix region.
72fn affix_parent_index(
73    child_idx: usize,
74    prefix: usize,
75    suffix: usize,
76    old_len: usize,
77    new_len: usize,
78) -> usize {
79    let suffix_start = new_len.saturating_sub(suffix);
80    if child_idx < prefix {
81        child_idx
82    } else {
83        debug_assert!(suffix > 0 && child_idx >= suffix_start);
84        let off = child_idx - suffix_start;
85        old_len.saturating_sub(suffix) + off
86    }
87}
88
89/// Split pending lines into those mappable through unchanged affix vs those in the edited middle.
90///
91/// Only the part of the affix that change compaction cannot reach is mapped directly; see
92/// [`slide_safe_affix`].
93fn partition_pending_by_affix(
94    affix: Affix,
95    old_len: usize,
96    new_len: usize,
97    pending: Vec<TrackedLine>,
98) -> (Vec<TrackedLine>, Vec<TrackedLine>) {
99    let (prefix, suffix) = (affix.safe_prefix, affix.safe_suffix);
100    let suffix_start = new_len.saturating_sub(suffix);
101    let mut affix_mapped = Vec::new();
102    let mut need_diff = Vec::new();
103    for t in pending {
104        let i = t.current_idx;
105        if i < prefix || (suffix > 0 && i >= suffix_start) {
106            affix_mapped.push(TrackedLine {
107                final_lineno: t.final_lineno,
108                current_idx: affix_parent_index(i, prefix, suffix, old_len, new_len),
109                ignored: t.ignored,
110                source_path: t.source_path,
111                origin: t.origin,
112            });
113        } else {
114            need_diff.push(t);
115        }
116    }
117    (affix_mapped, need_diff)
118}
119
120/// Common prefix and suffix of two files, with the lengths of them no diff group can slide into.
121///
122/// The line diff only marks lines between the common prefix and suffix, but change compaction
123/// then slides each group while the line leaving it equals the line entering it, so every line
124/// a group ever covers has the text of a line in the edited middle. Affix lines next to the
125/// middle are therefore only safe once a line whose text appears nowhere in either middle
126/// stops the slide.
127fn slide_safe_affix<L: BlameLines + ?Sized>(
128    old: &L,
129    new: &L,
130    ws: crate::diff::WhitespaceMode,
131) -> Affix {
132    let (prefix, suffix) = common_affix_len(old, new, ws);
133    let new_hi = new.len() - suffix;
134    let old_hi = old.len() - suffix;
135    let mut affix = Affix {
136        prefix,
137        suffix,
138        safe_prefix: prefix,
139        safe_suffix: suffix,
140    };
141    if prefix == new_hi && prefix == old_hi {
142        return affix;
143    }
144    let mut middle = MiddleLines {
145        old,
146        new,
147        old_range: prefix..old_hi,
148        new_range: prefix..new_hi,
149        ws,
150        // A scan under whitespace rules normalizes every middle line again.
151        scans_left: if ws.normalizes_line_compare() { 0 } else { 8 },
152        set: None,
153    };
154    let prefix_reach = (0..prefix)
155        .rev()
156        .take_while(|&i| middle.contains(new.comparison_line(i, ws)))
157        .count();
158    let suffix_reach = (new_hi..new.len())
159        .take_while(|&i| middle.contains(new.comparison_line(i, ws)))
160        .count();
161    affix.safe_prefix -= prefix_reach;
162    affix.safe_suffix -= suffix_reach;
163    affix
164}
165
166/// Common prefix and suffix line counts of two files, and the parts of them that change
167/// compaction cannot reach (see [`slide_safe_affix`]).
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169struct Affix {
170    prefix: usize,
171    suffix: usize,
172    safe_prefix: usize,
173    safe_suffix: usize,
174}
175
176/// Membership test for the text of the edited middle of two files.
177///
178/// Usually only a line or two next to the middle is asked about, so the first few answers come
179/// from a scan; a set is built once more are needed.
180struct MiddleLines<'a, L: BlameLines + ?Sized> {
181    old: &'a L,
182    new: &'a L,
183    old_range: std::ops::Range<usize>,
184    new_range: std::ops::Range<usize>,
185    ws: crate::diff::WhitespaceMode,
186    scans_left: usize,
187    set: Option<hashbrown::HashSet<std::borrow::Cow<'a, str>>>,
188}
189
190impl<'a, L: BlameLines + ?Sized> MiddleLines<'a, L> {
191    fn lines(&self) -> impl Iterator<Item = &'a str> + '_ {
192        let (old, new, ws) = (self.old, self.new, self.ws);
193        self.old_range
194            .clone()
195            .map(move |i| old.comparison_line(i, ws))
196            .chain(
197                self.new_range
198                    .clone()
199                    .map(move |i| new.comparison_line(i, ws)),
200            )
201    }
202
203    fn contains(&mut self, line: &str) -> bool {
204        let ws = self.ws;
205        if self.set.is_none() && self.scans_left > 0 {
206            self.scans_left -= 1;
207            return self.lines().any(|m| blame_lines_equal(m, line, ws));
208        }
209        let set = match self.set.take() {
210            Some(set) => set,
211            None => self
212                .lines()
213                .map(|m| ws.normalize_line_for_compare(m))
214                .collect(),
215        };
216        let found = set.contains(&ws.normalize_line_for_compare(line));
217        self.set = Some(set);
218        found
219    }
220}
221
222/// Line access shared by blob lines and plain line slices in the line-map helpers.
223trait BlameLines {
224    fn len(&self) -> usize;
225    /// Text used to compare line `idx`, before any whitespace normalization.
226    fn comparison_line(&self, idx: usize, ws: crate::diff::WhitespaceMode) -> &str;
227    /// Byte length of `comparison_line(idx, WhitespaceMode::default())`.
228    fn exact_len(&self, idx: usize) -> usize;
229    /// Stored bytes of line `idx`, including its newline.
230    fn stored_line(&self, idx: usize) -> StoredLine<'_>;
231    /// Stored bytes of the last `lines` lines.
232    fn tail_bytes(&self, lines: usize) -> usize;
233}
234
235/// A line's stored bytes: its text, plus a newline the text itself leaves out.
236#[derive(Clone, Copy, PartialEq, Eq)]
237struct StoredLine<'a> {
238    text: &'a [u8],
239    implied_newline: bool,
240}
241
242impl<'a> StoredLine<'a> {
243    fn rev_bytes(self) -> impl Iterator<Item = u8> + 'a {
244        self.implied_newline
245            .then_some(b'\n')
246            .into_iter()
247            .chain(self.text.iter().rev().copied())
248    }
249}
250
251impl BlameLines for [&str] {
252    fn len(&self) -> usize {
253        <[&str]>::len(self)
254    }
255
256    fn comparison_line(&self, idx: usize, _ws: crate::diff::WhitespaceMode) -> &str {
257        self[idx]
258    }
259
260    fn exact_len(&self, idx: usize) -> usize {
261        self[idx].len()
262    }
263
264    /// Slice lines are records of a newline-separated file, so a line without one gains it.
265    fn stored_line(&self, idx: usize) -> StoredLine<'_> {
266        StoredLine {
267            text: self[idx].as_bytes(),
268            implied_newline: !self[idx].ends_with('\n'),
269        }
270    }
271
272    fn tail_bytes(&self, lines: usize) -> usize {
273        self[self.len() - lines..]
274            .iter()
275            .map(|l| l.len() + usize::from(!l.ends_with('\n')))
276            .sum()
277    }
278}
279
280impl BlameLines for BlobLines {
281    fn len(&self) -> usize {
282        BlobLines::len(self)
283    }
284
285    fn comparison_line(&self, idx: usize, ws: crate::diff::WhitespaceMode) -> &str {
286        BlobLines::comparison_line(self, idx, ws)
287    }
288
289    fn exact_len(&self, idx: usize) -> usize {
290        let start = self.line_start(idx);
291        (self.line_ends[idx] + 1).min(self.bytes().len()) - start
292    }
293
294    fn stored_line(&self, idx: usize) -> StoredLine<'_> {
295        StoredLine {
296            text: BlobLines::comparison_line(self, idx, crate::diff::WhitespaceMode::default())
297                .as_bytes(),
298            implied_newline: false,
299        }
300    }
301
302    fn tail_bytes(&self, lines: usize) -> usize {
303        if lines == 0 {
304            return 0;
305        }
306        self.bytes().len() - self.line_start(self.len() - lines)
307    }
308}
309
310/// Size of the blocks Git compares when dropping the common tail before a blame diff.
311const BLAME_TAIL_BLOCK: usize = 1024;
312
313/// Number of trailing lines `git blame` leaves out when diffing `old` against `new`.
314///
315/// With no context lines, Git drops the common tail of both files in 1 KiB blocks before
316/// running xdiff, keeping the line the last dropped block starts in. The dropped lines never
317/// reach the diff, so they neither count as occurrences of a line nor give a hunk room to slide,
318/// and the end of what remains is the end of the file for the indent heuristic. `affix` is
319/// [`slide_safe_affix`] of the two files under `ws`.
320fn blame_trimmed_tail_lines<L: BlameLines + ?Sized>(
321    old: &L,
322    new: &L,
323    affix: Affix,
324    ws: crate::diff::WhitespaceMode,
325) -> usize {
326    let (old_len, new_len) = (old.len(), new.len());
327    let shorter = old_len.min(new_len);
328    // Exact comparison already found the byte-identical suffix, unless it stopped at the prefix.
329    let equal_lines = if !ws.normalizes_line_compare() && affix.prefix + affix.suffix < shorter {
330        affix.suffix
331    } else {
332        (0..shorter)
333            .take_while(|&j| old.stored_line(old_len - 1 - j) == new.stored_line(new_len - 1 - j))
334            .count()
335    };
336    let mut common_bytes = new.tail_bytes(equal_lines);
337    if equal_lines < shorter {
338        let a = old.stored_line(old_len - 1 - equal_lines);
339        let b = new.stored_line(new_len - 1 - equal_lines);
340        common_bytes += a
341            .rev_bytes()
342            .zip(b.rev_bytes())
343            .take_while(|(x, y)| x == y)
344            .count();
345    }
346    let trimmed = common_bytes / BLAME_TAIL_BLOCK * BLAME_TAIL_BLOCK;
347    if trimmed == 0 {
348        return 0;
349    }
350    // A line is dropped when it starts after the first trimmed byte, i.e. when it and every
351    // line after it fit in fewer than `trimmed` bytes.
352    let (mut lo, mut hi) = (0usize, equal_lines);
353    while lo < hi {
354        let mid = (lo + hi).div_ceil(2);
355        if new.tail_bytes(mid) < trimmed {
356            lo = mid;
357        } else {
358            hi = mid - 1;
359        }
360    }
361    lo
362}
363
364/// Unchanged lines kept on each side of the slidable region when diffing a window of the files.
365///
366/// The indent heuristic scores a split from at most 20 lines before and after it, so with this
367/// much context a window compacts exactly like the whole files.
368const COMPACTION_CONTEXT: usize = 21;
369
370fn blame_lines_equal(a: &str, b: &str, ws: crate::diff::WhitespaceMode) -> bool {
371    if !ws.normalizes_line_compare() {
372        a == b
373    } else {
374        ws.normalize_line_for_compare(a) == ws.normalize_line_for_compare(b)
375    }
376}
377
378/// Compacted diff ops for two windows cut from a pair of files.
379///
380/// Like Git, the line diff itself skips the common prefix and suffix, but compaction runs over
381/// the whole windows so groups can slide into the unchanged ends and the indent heuristic sees
382/// the surrounding lines. The windows leave `shared_records` lines of Git's view of the files
383/// out (identical on both sides); `shared` yields the comparison text of at least those whose
384/// text is in the edited middle, and Myers counts their occurrences the way xdiff counts every
385/// record of the files.
386fn blame_compacted_line_ops<'s>(
387    old: &[&str],
388    new: &[&str],
389    shared: impl Iterator<Item = &'s str>,
390    shared_records: usize,
391    diff_algorithm: BlameDiffAlgorithm,
392    indent_heuristic: bool,
393    ws: crate::diff::WhitespaceMode,
394) -> Vec<DiffOp> {
395    use similar::TextDiff;
396
397    let normalized = ws.normalizes_line_compare().then(|| {
398        let norm = |lines: &[&str]| -> Vec<String> {
399            lines
400                .iter()
401                .map(|l| ws.normalize_line_for_compare(l).into_owned())
402                .collect()
403        };
404        (norm(old), norm(new))
405    });
406    let normalized_refs: Option<(Vec<&str>, Vec<&str>)> = normalized.as_ref().map(|(o, n)| {
407        (
408            o.iter().map(String::as_str).collect(),
409            n.iter().map(String::as_str).collect(),
410        )
411    });
412    let (old_cmp, new_cmp) = match &normalized_refs {
413        Some((o, n)) => (o.as_slice(), n.as_slice()),
414        None => (old, new),
415    };
416    let (prefix, suffix) = common_affix_len(old_cmp, new_cmp, ws);
417    let old_mid = &old_cmp[prefix..old_cmp.len() - suffix];
418    let new_mid = &new_cmp[prefix..new_cmp.len() - suffix];
419    if diff_algorithm == BlameDiffAlgorithm::Myers {
420        use crate::line_interner::LineInterner;
421        let mut interner = LineInterner::with_capacity(old_cmp.len() + new_cmp.len());
422        let ids1 = interner.intern_all(old_cmp);
423        let ids2 = interner.intern_all(new_cmp);
424        let mut shared_counts = vec![0u32; interner.len()];
425        for line in shared {
426            if let Some(id) = interner.get(&ws.normalize_line_for_compare(line)) {
427                shared_counts[id as usize] += 1;
428            }
429        }
430        return crate::diff::line_diff_ops_git_xdiff_compacted_ids(
431            &ids1,
432            &ids2,
433            &shared_counts,
434            shared_records,
435            old,
436            new,
437            indent_heuristic,
438            ws,
439        );
440    }
441    // Compaction reads only the changed ranges, so the middle's ops need no equal-run framing.
442    let mid_ops: Vec<DiffOp> = TextDiff::configure()
443        .algorithm(diff_algorithm.to_similar())
444        .diff_slices(old_mid, new_mid)
445        .ops()
446        .iter()
447        .map(|op| shift_diff_op(op, prefix))
448        .collect();
449    crate::diff_indent_heuristic::apply_change_compact_to_ops_with_ws(
450        &mid_ops,
451        old,
452        new,
453        indent_heuristic,
454        ws,
455    )
456}
457
458/// `op` with both line indexes moved forward by `by`.
459fn shift_diff_op(op: &DiffOp, by: usize) -> DiffOp {
460    match *op {
461        DiffOp::Equal {
462            old_index,
463            new_index,
464            len,
465        } => DiffOp::Equal {
466            old_index: old_index + by,
467            new_index: new_index + by,
468            len,
469        },
470        DiffOp::Delete {
471            old_index,
472            old_len,
473            new_index,
474        } => DiffOp::Delete {
475            old_index: old_index + by,
476            old_len,
477            new_index: new_index + by,
478        },
479        DiffOp::Insert {
480            old_index,
481            new_index,
482            new_len,
483        } => DiffOp::Insert {
484            old_index: old_index + by,
485            new_index: new_index + by,
486            new_len,
487        },
488        DiffOp::Replace {
489            old_index,
490            old_len,
491            new_index,
492            new_len,
493        } => DiffOp::Replace {
494            old_index: old_index + by,
495            old_len,
496            new_index: new_index + by,
497            new_len,
498        },
499    }
500}
501
502/// Lengths of the common line prefix and suffix of two files (the suffix never overlaps it).
503fn common_affix_len<L: BlameLines + ?Sized>(
504    old: &L,
505    new: &L,
506    ws: crate::diff::WhitespaceMode,
507) -> (usize, usize) {
508    let mut prefix = 0usize;
509    while prefix < old.len()
510        && prefix < new.len()
511        && blame_lines_equal(
512            old.comparison_line(prefix, ws),
513            new.comparison_line(prefix, ws),
514            ws,
515        )
516    {
517        prefix += 1;
518    }
519    let mut suffix = 0usize;
520    while suffix < old.len().saturating_sub(prefix)
521        && suffix < new.len().saturating_sub(prefix)
522        && blame_lines_equal(
523            old.comparison_line(old.len() - 1 - suffix, ws),
524            new.comparison_line(new.len() - 1 - suffix, ws),
525            ws,
526        )
527    {
528        suffix += 1;
529    }
530    (prefix, suffix)
531}
532
533/// Map each line in `new` to its origin line index in `old` (if any).
534///
535/// The map is read from the diff after change compaction, the way `git blame` places hunks, so
536/// an inserted or deleted block that could sit in several places is attributed where Git puts
537/// it rather than where a raw common prefix/suffix would leave it. Lines are treated as
538/// newline-terminated records of a file, and like `git blame` the diff leaves out the common
539/// tail Git drops in 1 KiB blocks before running xdiff.
540pub fn build_line_map(
541    old: &[&str],
542    new: &[&str],
543    diff_algorithm: BlameDiffAlgorithm,
544    indent_heuristic: bool,
545) -> Vec<Option<usize>> {
546    build_line_map_with_changed_flags(
547        old,
548        new,
549        diff_algorithm,
550        indent_heuristic,
551        false,
552        crate::diff::WhitespaceMode::default(),
553    )
554    .0
555}
556
557/// Like [`build_line_map_with_changed_flags`], for blob lines.
558///
559/// `affix` is [`slide_safe_affix`] of the two blobs.
560fn build_line_map_with_changed_flags_owned(
561    old: &BlobLines,
562    new: &BlobLines,
563    affix: Affix,
564    diff_algorithm: BlameDiffAlgorithm,
565    indent_heuristic: bool,
566    with_changed_flags: bool,
567    ws: crate::diff::WhitespaceMode,
568) -> (Vec<Option<usize>>, Vec<bool>) {
569    windowed_line_map(
570        old,
571        new,
572        affix,
573        diff_algorithm,
574        indent_heuristic,
575        with_changed_flags,
576        ws,
577    )
578}
579
580/// Like [`build_line_map`], optionally returning xdiff-style changed flags for the new side
581/// from the same compacted diff ops (avoids a second full diff on blame hot paths).
582fn build_line_map_with_changed_flags(
583    old: &[&str],
584    new: &[&str],
585    diff_algorithm: BlameDiffAlgorithm,
586    indent_heuristic: bool,
587    with_changed_flags: bool,
588    ws: crate::diff::WhitespaceMode,
589) -> (Vec<Option<usize>>, Vec<bool>) {
590    windowed_line_map(
591        old,
592        new,
593        slide_safe_affix(old, new, ws),
594        diff_algorithm,
595        indent_heuristic,
596        with_changed_flags,
597        ws,
598    )
599}
600
601/// Line map and changed flags from a compacted diff of the window that compaction can affect.
602///
603/// The diff sees the files as `git blame` gives them to xdiff (see
604/// [`blame_trimmed_tail_lines`]). Lines outside [`slide_safe_affix`] plus
605/// [`COMPACTION_CONTEXT`] map straight through, which is what compacting the whole files would
606/// give them; they still count as occurrences for the Myers line classification.
607fn windowed_line_map<L: BlameLines + ?Sized>(
608    old: &L,
609    new: &L,
610    affix: Affix,
611    diff_algorithm: BlameDiffAlgorithm,
612    indent_heuristic: bool,
613    with_changed_flags: bool,
614    ws: crate::diff::WhitespaceMode,
615) -> (Vec<Option<usize>>, Vec<bool>) {
616    let trimmed_tail = blame_trimmed_tail_lines(old, new, affix, ws);
617    let (safe_prefix, safe_suffix) = (affix.safe_prefix, affix.safe_suffix);
618    let tail = safe_suffix
619        .saturating_sub(COMPACTION_CONTEXT)
620        .max(trimmed_tail);
621    let (old_hi, new_hi) = (old.len() - tail, new.len() - tail);
622    let lo = safe_prefix
623        .saturating_sub(COMPACTION_CONTEXT)
624        .min(old_hi)
625        .min(new_hi);
626    let old_w: Vec<&str> = (lo..old_hi).map(|i| old.comparison_line(i, ws)).collect();
627    let new_w: Vec<&str> = (lo..new_hi).map(|i| new.comparison_line(i, ws)).collect();
628    let shared_tail = new_hi..new.len() - trimmed_tail;
629    let shared_records = lo + shared_tail.len();
630    // Only the counts of lines in the edited middle are read, so without whitespace rules a
631    // shared line of another length needs no lookup.
632    let mid_lens = (!ws.normalizes_line_compare()).then(|| {
633        let lens = (affix.prefix..old.len() - affix.suffix)
634            .map(|i| old.exact_len(i))
635            .chain((affix.prefix..new.len() - affix.suffix).map(|i| new.exact_len(i)));
636        let mut seen = Vec::new();
637        for len in lens {
638            if seen.len() <= len {
639                seen.resize(len + 1, false);
640            }
641            seen[len] = true;
642        }
643        seen
644    });
645    let shared = (0..lo)
646        .chain(shared_tail)
647        .filter(|&i| {
648            mid_lens
649                .as_ref()
650                .is_none_or(|seen| seen.get(new.exact_len(i)).copied().unwrap_or(false))
651        })
652        .map(|i| new.comparison_line(i, ws));
653    // `--indent-heuristic` / `diff.indentHeuristic` shifts ambiguous add/delete
654    // runs like Git's xdiff, which changes which lines blame attributes (t4061).
655    let ops = blame_compacted_line_ops(
656        &old_w,
657        &new_w,
658        shared,
659        shared_records,
660        diff_algorithm,
661        indent_heuristic,
662        ws,
663    );
664    let window_map = crate::diff_indent_heuristic::map_new_to_old_from_ops(&ops, new_w.len());
665    let map = (0..lo)
666        .map(Some)
667        .chain(window_map.into_iter().map(|m| m.map(|i| i + lo)))
668        .chain((old_hi..old.len()).map(Some))
669        .collect();
670    let flags = if with_changed_flags {
671        let mut flags = vec![false; new.len()];
672        let window_flags =
673            crate::diff_indent_heuristic::new_line_changed_flags_from_ops(&ops, new_w.len());
674        flags[lo..new_hi].copy_from_slice(&window_flags);
675        flags
676    } else {
677        Vec::new()
678    };
679    (map, flags)
680}
681
682/// Greedily map unresolved new lines to identical old lines (lowest use count, then distance).
683fn fill_exact_text_relocate(
684    old: &[&str],
685    new: &[&str],
686    map: &mut [Option<usize>],
687    used_old: &mut [usize],
688    ws: crate::diff::WhitespaceMode,
689) {
690    let mut by_content_norm: HashMap<String, Vec<usize>> = HashMap::new();
691    if ws.normalizes_line_compare() {
692        for (idx, line) in old.iter().enumerate() {
693            let key = ws.normalize_line_for_compare(line).into_owned();
694            by_content_norm.entry(key).or_default().push(idx);
695        }
696    } else {
697        // Only unresolved child lines are looked up, and usually few are, so index just
698        // the parent lines sharing their text (a length check skips the rest unhashed).
699        let mut by_content: hashbrown::HashMap<&str, Vec<usize>> = map
700            .iter()
701            .zip(new)
702            .filter(|(slot, _)| slot.is_none())
703            .map(|(_, line)| (*line, Vec::new()))
704            .collect();
705        if by_content.is_empty() {
706            return;
707        }
708        let mut lens: Vec<usize> = by_content.keys().map(|line| line.len()).collect();
709        lens.sort_unstable();
710        lens.dedup();
711        for (idx, line) in old.iter().enumerate() {
712            if lens.binary_search(&line.len()).is_ok() {
713                if let Some(indices) = by_content.get_mut(line) {
714                    indices.push(idx);
715                }
716            }
717        }
718        fill_exact_text_relocate_with_lookup(old, new, map, used_old, |line| {
719            by_content
720                .get(line)
721                .filter(|indices| !indices.is_empty())
722                .map(Vec::as_slice)
723        });
724        return;
725    }
726
727    fill_exact_text_relocate_with_lookup(old, new, map, used_old, |line| {
728        let key = ws.normalize_line_for_compare(line).into_owned();
729        by_content_norm.get(&key).map(Vec::as_slice)
730    });
731}
732
733fn fill_exact_text_relocate_with_lookup<'a>(
734    _old: &[&str],
735    new: &[&str],
736    map: &mut [Option<usize>],
737    used_old: &mut [usize],
738    mut lookup: impl FnMut(&str) -> Option<&'a [usize]>,
739) {
740    let mut windowed: Vec<usize> = Vec::new();
741    for new_idx in 0..map.len() {
742        if map[new_idx].is_some() {
743            continue;
744        }
745        let Some(candidates) = lookup(new[new_idx]) else {
746            continue;
747        };
748        let mut best: Option<(usize, usize, usize)> = None;
749        let scan: &[usize] = if candidates.len() > 32 {
750            windowed.clear();
751            const WINDOW: usize = 768;
752            let lo = new_idx.saturating_sub(WINDOW);
753            let hi = new_idx.saturating_add(WINDOW);
754            windowed.extend(
755                candidates
756                    .iter()
757                    .copied()
758                    .filter(|&idx| (lo..=hi).contains(&idx)),
759            );
760            if windowed.is_empty() {
761                candidates
762            } else {
763                &windowed
764            }
765        } else {
766            candidates
767        };
768        for &old_idx in scan {
769            let candidate = (used_old[old_idx], old_idx.abs_diff(new_idx), old_idx);
770            if best.is_none_or(|b| candidate < b) {
771                best = Some(candidate);
772            }
773        }
774        if let Some((_, _, old_idx)) = best {
775            map[new_idx] = Some(old_idx);
776            used_old[old_idx] += 1;
777        }
778    }
779}
780
781/// Recover line mappings by exact text match when Myers left a line unresolved.
782///
783/// Used during blame to follow moved but unchanged lines without pairing rewrites.
784pub fn build_exact_relocate_line_map(
785    old: &[&str],
786    new: &[&str],
787    exact_map: &[Option<usize>],
788) -> Vec<Option<usize>> {
789    build_exact_relocate_line_map_ws(old, new, exact_map, crate::diff::WhitespaceMode::default())
790}
791
792fn build_exact_relocate_line_map_ws(
793    old: &[&str],
794    new: &[&str],
795    exact_map: &[Option<usize>],
796    ws: crate::diff::WhitespaceMode,
797) -> Vec<Option<usize>> {
798    let mut fuzzy_map = exact_map.to_vec();
799    let mut used_old = vec![0usize; old.len()];
800    for old_idx in fuzzy_map.iter().flatten() {
801        if *old_idx < used_old.len() {
802            used_old[*old_idx] += 1;
803        }
804    }
805
806    fill_exact_text_relocate(old, new, &mut fuzzy_map, &mut used_old, ws);
807
808    fuzzy_map
809}
810
811/// Decide which child lines stop blame on the current commit (system `git blame` parity).
812///
813/// Uses the compacted diff equal-map, exact-text relocation, LIS reposition tie-break, and
814/// the rule that lines re-inserted at the same index without a diff equal mapping stay on
815/// the current commit.
816fn blame_stops_on_current_commit(
817    diff_map: &[Option<usize>],
818    relocate_map: &[Option<usize>],
819    xdiff_changed: &[bool],
820) -> Vec<bool> {
821    let reposition = blame_reposition_on_current_commit(relocate_map);
822    let mut stop = vec![false; diff_map.len()];
823    for child_idx in 0..diff_map.len() {
824        if reposition.get(child_idx).copied().unwrap_or(false) {
825            stop[child_idx] = true;
826            continue;
827        }
828        if xdiff_changed.get(child_idx).copied().unwrap_or(false) {
829            if let Some(parent_idx) = diff_map[child_idx] {
830                if parent_idx == child_idx {
831                    stop[child_idx] = true;
832                }
833            }
834        }
835        if stop[child_idx] {
836            continue;
837        }
838        if diff_map[child_idx].is_none() {
839            // xdiff insert/replace on the child side: stay on this commit even when
840            // exact-text relocation could pair the line with distant equal text (#1610).
841            stop[child_idx] = true;
842        }
843    }
844    stop
845}
846
847/// Prefix maximum on a 1-indexed Fenwick tree (max `len`/`arg` pairs on `[1, idx]`).
848#[allow(clippy::manual_isolate_lowest_one)]
849fn fenwick_max_prefix(len: &[usize], arg: &[usize], idx: usize) -> (usize, usize) {
850    let mut pos = idx;
851    let mut best_len = 0usize;
852    let mut best_i = usize::MAX;
853    while pos > 0 {
854        if len[pos] > best_len {
855            best_len = len[pos];
856            best_i = arg[pos];
857        }
858        pos &= pos - 1;
859    }
860    (best_len, best_i)
861}
862
863#[allow(clippy::manual_isolate_lowest_one)]
864fn fenwick_max_set(len: &mut [usize], arg: &mut [usize], idx: usize, value: usize, i: usize) {
865    let mut pos = idx;
866    while pos < len.len() {
867        if value > len[pos] {
868            len[pos] = value;
869            arg[pos] = i;
870        }
871        pos += pos & pos.wrapping_neg();
872    }
873}
874
875/// Longest-increasing-subsequence tie-break for lines that moved but stayed text-identical.
876fn blame_reposition_on_current_commit(line_map: &[Option<usize>]) -> Vec<bool> {
877    let n = line_map.len();
878    let pairs: Vec<(usize, usize)> = line_map
879        .iter()
880        .enumerate()
881        .filter_map(|(child, parent)| parent.map(|p| (child, p)))
882        .collect();
883
884    if pairs.is_empty() || pairs.iter().all(|&(c, p)| c == p) {
885        return vec![false; n];
886    }
887
888    let mut in_lis = vec![false; n];
889    if !pairs.is_empty() {
890        let len = pairs.len();
891        let mut lis_len = vec![1usize; len];
892        let mut pred = vec![usize::MAX; len];
893        let max_parent = pairs.iter().map(|(_, p)| *p).max().unwrap_or(0);
894        let fw_size = max_parent + 2;
895        let mut fw_len = vec![0usize; fw_size];
896        let mut fw_idx = vec![usize::MAX; fw_size];
897        for i in 0..len {
898            let p = pairs[i].1;
899            let (prev_len, prev_i) = fenwick_max_prefix(&fw_len, &fw_idx, p);
900            if prev_len > 0 {
901                pred[i] = prev_i;
902            }
903            lis_len[i] = prev_len + 1;
904            fenwick_max_set(&mut fw_len, &mut fw_idx, p + 1, lis_len[i], i);
905        }
906        let max_lis = *lis_len.iter().max().unwrap_or(&1);
907        let end = (0..len)
908            .filter(|&i| lis_len[i] == max_lis)
909            .max_by_key(|&i| pairs[i].1)
910            .unwrap_or(0);
911
912        let mut cur = end;
913        loop {
914            in_lis[pairs[cur].0] = true;
915            if pred[cur] == usize::MAX {
916                break;
917            }
918            cur = pred[cur];
919        }
920    }
921
922    let mut blame_current = vec![false; n];
923    for (child_idx, parent_idx) in line_map
924        .iter()
925        .enumerate()
926        .filter_map(|(c, p)| p.map(|p| (c, p)))
927    {
928        if parent_idx == child_idx {
929            continue;
930        }
931        if !in_lis[child_idx] {
932            blame_current[child_idx] = true;
933        }
934    }
935    blame_current
936}
937
938/// Like [`build_line_map`], but recover additional correspondences for moved/rewritten lines.
939pub fn build_fuzzy_line_map(
940    old: &[&str],
941    new: &[&str],
942    exact_map: &[Option<usize>],
943) -> Vec<Option<usize>> {
944    build_fuzzy_line_map_ws(old, new, exact_map, crate::diff::WhitespaceMode::default())
945}
946
947fn build_fuzzy_line_map_ws(
948    old: &[&str],
949    new: &[&str],
950    exact_map: &[Option<usize>],
951    ws: crate::diff::WhitespaceMode,
952) -> Vec<Option<usize>> {
953    let mut fuzzy_map = exact_map.to_vec();
954    let mut used_old = vec![0usize; old.len()];
955    for old_idx in fuzzy_map.iter().flatten() {
956        if *old_idx < used_old.len() {
957            used_old[*old_idx] += 1;
958        }
959    }
960
961    // First, greedily recover exact-text matches among unresolved lines.
962    // This is important for reorders where Myers may not anchor every moved
963    // line and fuzzy similarity can otherwise pair the wrong include.
964    fill_exact_text_relocate(old, new, &mut fuzzy_map, &mut used_old, ws);
965
966    let mut anchors: Vec<(usize, usize)> = exact_map
967        .iter()
968        .enumerate()
969        .filter_map(|(new_idx, old_idx)| old_idx.map(|old| (new_idx, old)))
970        .collect();
971    anchors.sort_unstable();
972
973    let mut prev_new = usize::MAX;
974    let mut prev_old = usize::MAX;
975    for (next_new, next_old) in anchors
976        .iter()
977        .copied()
978        .chain(std::iter::once((new.len(), old.len())))
979    {
980        let new_start = if prev_new == usize::MAX {
981            0
982        } else {
983            prev_new + 1
984        };
985        let new_end = next_new;
986        let old_start = if prev_old == usize::MAX {
987            0
988        } else {
989            prev_old + 1
990        };
991        let old_end = next_old;
992
993        if new_start < new_end && old_start < old_end {
994            let segment_matches =
995                fuzzy_match_segment(old, old_start, old_end, new, new_start, new_end);
996            for (new_idx, old_idx) in segment_matches {
997                if fuzzy_map[new_idx].is_none() {
998                    fuzzy_map[new_idx] = Some(old_idx);
999                    used_old[old_idx] += 1;
1000                }
1001            }
1002        }
1003
1004        prev_new = next_new;
1005        prev_old = next_old;
1006    }
1007
1008    // Context-aware recovery for split/expanded lines:
1009    // if an unresolved line sits between mapped neighbors, prefer mapping
1010    // to those neighboring source lines when there is meaningful overlap.
1011    for new_idx in 0..fuzzy_map.len() {
1012        if fuzzy_map[new_idx].is_some() {
1013            continue;
1014        }
1015        let prev_old = (0..new_idx).rev().find_map(|i| fuzzy_map[i]);
1016        let next_old = ((new_idx + 1)..fuzzy_map.len()).find_map(|i| fuzzy_map[i]);
1017
1018        let mut candidates = Vec::new();
1019        if let Some(o) = prev_old {
1020            candidates.push(o);
1021        }
1022        if let Some(o) = next_old {
1023            if candidates.last().copied() != Some(o) {
1024                candidates.push(o);
1025            }
1026        }
1027
1028        let mut best: Option<(f64, usize)> = None;
1029        for old_idx in candidates {
1030            if old_idx >= old.len() {
1031                continue;
1032            }
1033            let (sim, lcs) = line_similarity_and_lcs(old[old_idx], new[new_idx]);
1034            let exact_text = old[old_idx].trim() == new[new_idx].trim();
1035            // Keep this narrow: strong overlap or exact text only.
1036            if !exact_text && lcs < 6 && !(sim >= 0.35 && lcs >= 3) {
1037                continue;
1038            }
1039
1040            let mut score = lcs as f64 + sim * 10.0;
1041            score -= 0.2 * used_old[old_idx] as f64;
1042
1043            if let (Some(lo), Some(hi)) = (prev_old, next_old) {
1044                if lo <= hi && (old_idx < lo || old_idx > hi) {
1045                    score -= 3.0;
1046                }
1047            }
1048
1049            if best.is_none_or(|(best_score, best_old)| {
1050                score > best_score
1051                    || ((score - best_score).abs() < 1e-9
1052                        && old_idx.abs_diff(new_idx) < best_old.abs_diff(new_idx))
1053            }) {
1054                best = Some((score, old_idx));
1055            }
1056        }
1057
1058        if let Some((_, old_idx)) = best {
1059            fuzzy_map[new_idx] = Some(old_idx);
1060            used_old[old_idx] += 1;
1061        }
1062    }
1063
1064    // Final best-effort fill for unresolved lines. This handles cases where
1065    // anchor segmentation leaves an empty old-range but we still want to
1066    // pass through ignored commits by similarity.
1067    for new_idx in 0..fuzzy_map.len() {
1068        if fuzzy_map[new_idx].is_some() {
1069            continue;
1070        }
1071        let prev_old = (0..new_idx).rev().find_map(|i| fuzzy_map[i]);
1072        let next_old = ((new_idx + 1)..fuzzy_map.len()).find_map(|i| fuzzy_map[i]);
1073
1074        let mut best: Option<(f64, usize)> = None;
1075        for old_idx in 0..old.len() {
1076            let (sim, lcs) = line_similarity_and_lcs(old[old_idx], new[new_idx]);
1077            let exact_text = old[old_idx].trim() == new[new_idx].trim();
1078            let new_len = new[new_idx].trim().chars().count();
1079            if !exact_text && (new_len < 3 || sim < 0.45 || lcs < 2) {
1080                continue;
1081            }
1082
1083            let mut score = sim;
1084            score -= 0.004 * old_idx.abs_diff(new_idx) as f64;
1085            score -= 0.08 * used_old[old_idx] as f64;
1086
1087            // Encourage monotonic local ordering when neighboring anchors
1088            // are themselves ordered; avoid forcing this for reorders.
1089            if let (Some(lo), Some(hi)) = (prev_old, next_old) {
1090                if lo <= hi {
1091                    if old_idx < lo {
1092                        score -= 0.20 * (lo - old_idx) as f64;
1093                    } else if old_idx > hi {
1094                        score -= 0.20 * (old_idx - hi) as f64;
1095                    }
1096                }
1097            } else if let Some(lo) = prev_old {
1098                if old_idx < lo {
1099                    score -= 0.20 * (lo - old_idx) as f64;
1100                }
1101            } else if let Some(hi) = next_old {
1102                if old_idx > hi {
1103                    score -= 0.20 * (old_idx - hi) as f64;
1104                }
1105            }
1106
1107            if best.is_none_or(|(best_score, best_idx)| {
1108                score > best_score
1109                    || ((score - best_score).abs() < 1e-9
1110                        && old_idx.abs_diff(new_idx) < best_idx.abs_diff(new_idx))
1111            }) {
1112                best = Some((score, old_idx));
1113            }
1114        }
1115
1116        if let Some((_, old_idx)) = best {
1117            fuzzy_map[new_idx] = Some(old_idx);
1118            used_old[old_idx] += 1;
1119        }
1120    }
1121
1122    fuzzy_map
1123}
1124
1125fn fuzzy_match_segment(
1126    old: &[&str],
1127    old_start: usize,
1128    old_end: usize,
1129    new: &[&str],
1130    new_start: usize,
1131    new_end: usize,
1132) -> Vec<(usize, usize)> {
1133    let m = old_end.saturating_sub(old_start);
1134    let n = new_end.saturating_sub(new_start);
1135    if m == 0 || n == 0 {
1136        return Vec::new();
1137    }
1138
1139    // DP over new-lines where the state tracks the last matched old-line.
1140    // State 0 means "no old line selected yet", state s>0 means old index s-1.
1141    // Transitions keep order (non-decreasing old index), but allow reusing
1142    // the same old line for multiple split lines in the new content.
1143    let states = m + 1;
1144    let neg_inf = f64::NEG_INFINITY;
1145    let mut dp = vec![neg_inf; states];
1146    dp[0] = 0.0;
1147
1148    let mut back_prev = vec![vec![usize::MAX; states]; n + 1];
1149    let mut back_pick = vec![vec![None; states]; n + 1];
1150
1151    for j in 0..n {
1152        let mut next_dp = vec![neg_inf; states];
1153
1154        for state in 0..states {
1155            let base = dp[state];
1156            if !base.is_finite() {
1157                continue;
1158            }
1159
1160            // Option 1: do not match this new line.
1161            if base > next_dp[state] {
1162                next_dp[state] = base;
1163                back_prev[j + 1][state] = state;
1164                back_pick[j + 1][state] = None;
1165            }
1166
1167            // Option 2: match this new line to some old line >= last matched.
1168            let start_k = if state == 0 { 0 } else { state - 1 };
1169            for k in start_k..m {
1170                let old_idx = old_start + k;
1171                let new_idx = new_start + j;
1172                let (sim, lcs) = line_similarity_and_lcs(old[old_idx], new[new_idx]);
1173                let exact_text = old[old_idx].trim() == new[new_idx].trim();
1174                let new_len = new[new_idx].trim().chars().count();
1175                if !exact_text && (new_len < 3 || sim < 0.45 || lcs < 2) {
1176                    continue;
1177                }
1178
1179                // Slight locality bias to stabilize tie-breaking.
1180                let distance = k.abs_diff(j) as f64;
1181                let score = base + sim - 0.002 * distance;
1182                let next_state = k + 1;
1183                if score > next_dp[next_state] {
1184                    next_dp[next_state] = score;
1185                    back_prev[j + 1][next_state] = state;
1186                    back_pick[j + 1][next_state] = Some(k);
1187                }
1188            }
1189        }
1190
1191        dp = next_dp;
1192    }
1193
1194    let mut best_state = 0usize;
1195    for state in 1..states {
1196        if dp[state] > dp[best_state] {
1197            best_state = state;
1198        }
1199    }
1200
1201    let mut matches = Vec::new();
1202    let mut state = best_state;
1203    for j in (1..=n).rev() {
1204        if let Some(k) = back_pick[j][state] {
1205            matches.push((new_start + (j - 1), old_start + k));
1206        }
1207        let prev = back_prev[j][state];
1208        if prev == usize::MAX {
1209            break;
1210        }
1211        state = prev;
1212    }
1213    matches.reverse();
1214    matches
1215}
1216
1217fn line_similarity_and_lcs(a: &str, b: &str) -> (f64, usize) {
1218    let a = a.trim();
1219    let b = b.trim();
1220    if a.is_empty() || b.is_empty() {
1221        return (0.0, 0);
1222    }
1223    if a == b {
1224        return (1.0, a.chars().count());
1225    }
1226
1227    let a = a.to_ascii_lowercase();
1228    let b = b.to_ascii_lowercase();
1229    let a_chars: Vec<char> = a.chars().collect();
1230    let b_chars: Vec<char> = b.chars().collect();
1231    let n = b_chars.len();
1232    if a_chars.is_empty() || b_chars.is_empty() {
1233        return (0.0, 0);
1234    }
1235
1236    let mut prev = vec![0usize; n + 1];
1237    let mut curr = vec![0usize; n + 1];
1238    for i in 1..=a_chars.len() {
1239        for j in 1..=n {
1240            curr[j] = if a_chars[i - 1] == b_chars[j - 1] {
1241                prev[j - 1] + 1
1242            } else {
1243                prev[j].max(curr[j - 1])
1244            };
1245        }
1246        std::mem::swap(&mut prev, &mut curr);
1247        curr.fill(0);
1248    }
1249
1250    let lcs = prev[n];
1251    let sim = (2.0 * lcs as f64) / (a_chars.len() as f64 + b_chars.len() as f64);
1252    (sim, lcs)
1253}
1254
1255use std::collections::{BinaryHeap, HashMap, HashSet};
1256use std::fs::OpenOptions;
1257use std::io::{self, Write};
1258use std::path::Path;
1259use std::sync::Arc;
1260
1261use crate::attributes::{load_gitattributes_stack, MacroTable, ParsedGitAttributes};
1262use crate::commit_graph_file::CommitGraphChain;
1263use crate::config::ConfigSet;
1264use crate::crlf::{
1265    convert_to_worktree_eager, file_attrs_from_gitattributes, load_gitattributes_from_index,
1266    load_gitattributes_parsed, ConversionConfig, GitAttributes,
1267};
1268use crate::diff::{detect_renames, diff_trees, DiffEntry, DiffStatus};
1269use crate::error::{Error as LibError, Result};
1270use crate::index::Index;
1271use crate::objects::{
1272    commit_author_and_message_lenient, find_tree_entry_by_name_dir_or_file_in_data,
1273    parse_commit_tree_and_parents_lenient, parse_commit_tree_and_parents_prefix, parse_tree,
1274    Object, ObjectId, ObjectKind,
1275};
1276use crate::odb::Odb;
1277use crate::pack::SingleLocalPackReader;
1278use crate::parallel::ThreadPolicy;
1279use crate::repo::Repository;
1280use crate::repo_relpath::{format_path_stored, storage_bytes, storage_string};
1281use crate::wildmatch::wildmatch;
1282
1283/// Hook the CLI installs so blame can lazily hydrate missing objects from a
1284/// promisor remote (partial clone). The lib does no transport itself; the CLI
1285/// supplies a function that performs the fetch. Install via
1286/// [`RepoCaches::set_promisor_hydrate_hook`](crate::repo_caches::RepoCaches::set_promisor_hydrate_hook).
1287pub type PromisorHydrateHook = fn(&Repository, ObjectId);
1288
1289/// A single line attribution produced by [`compute_blame`].
1290#[derive(Debug, Clone, PartialEq, Eq)]
1291pub struct BlameLine {
1292    /// Commit that last modified this line.
1293    pub oid: ObjectId,
1294    /// 1-based line number in the final file.
1295    pub final_lineno: usize,
1296    /// 1-based line number in the originating commit's version of the file.
1297    pub orig_lineno: usize,
1298    /// Text of the line in the final file (lossy UTF-8 when [`Self::encoding_lossy`]).
1299    pub content: String,
1300    /// `true` when line bytes are not valid UTF-8 and [`Self::content`] used lossy decoding.
1301    pub encoding_lossy: bool,
1302    /// Exact line bytes excluding the line terminator when [`Self::encoding_lossy`]; otherwise `None`.
1303    pub content_bytes: Option<Vec<u8>>,
1304    /// Reversibly stored path in the originating commit, including rename/copy origins.
1305    /// Resolve this path in the attributed commit, not in the blamed revision.
1306    /// Recover exact filename bytes with `repo_relpath::storage_bytes`.
1307    pub original_path: String,
1308    /// Reversibly stored source path when copy detection attributed a different file.
1309    pub source_file: Option<String>,
1310    /// Line attribution passed through an ignored revision.
1311    pub ignored: bool,
1312    /// Line could not be blamed past an ignored revision.
1313    pub unblamable: bool,
1314    /// Line exists only in uncommitted content (worktree file or caller buffer); `oid` is
1315    /// the null object id.
1316    pub not_committed: bool,
1317}
1318
1319fn final_line_display(final_text: &BlobLines, final_lineno: usize) -> BlameLineDisplay {
1320    final_text.line_display(final_lineno - 1)
1321}
1322
1323#[allow(clippy::too_many_arguments)]
1324fn blame_line_at_path(
1325    oid: ObjectId,
1326    final_lineno: usize,
1327    orig_lineno: usize,
1328    display: BlameLineDisplay,
1329    blame_path: &str,
1330    source_file: Option<String>,
1331    ignored: bool,
1332    unblamable: bool,
1333) -> BlameLine {
1334    let original_path = source_file.clone().unwrap_or_else(|| blame_path.to_owned());
1335    BlameLine {
1336        oid,
1337        final_lineno,
1338        orig_lineno,
1339        content: display.content,
1340        encoding_lossy: display.encoding_lossy,
1341        content_bytes: display.content_bytes,
1342        original_path,
1343        source_file,
1344        ignored,
1345        unblamable,
1346        not_committed: false,
1347    }
1348}
1349
1350fn blame_line_from_parent(
1351    pb: &BlameLine,
1352    final_lineno: usize,
1353    display: BlameLineDisplay,
1354    source_file: Option<String>,
1355    ignored: bool,
1356    unblamable: bool,
1357) -> BlameLine {
1358    BlameLine {
1359        oid: pb.oid,
1360        final_lineno,
1361        orig_lineno: pb.orig_lineno,
1362        content: display.content,
1363        encoding_lossy: display.encoding_lossy,
1364        content_bytes: display.content_bytes,
1365        original_path: pb.original_path.clone(),
1366        source_file,
1367        ignored,
1368        unblamable,
1369        not_committed: false,
1370    }
1371}
1372
1373/// Resolve a file path through nested trees to get the blob OID + mode.
1374fn resolve_path_in_tree_entry(
1375    odb: &Odb,
1376    tree_oid: &ObjectId,
1377    path: &str,
1378) -> Result<Option<(ObjectId, u32)>> {
1379    Ok(odb
1380        .tree_entry_at_path(tree_oid, &storage_bytes(path))?
1381        .filter(|e| e.mode != 0o040000)
1382        .map(|e| (e.oid, e.mode)))
1383}
1384
1385/// Split content into lines. A final line without a trailing newline is still a line
1386/// (matches git blame / `wc -l` + 1 semantics in upstream tests).
1387#[cfg(test)]
1388fn content_lines(s: &str) -> Vec<&str> {
1389    if s.is_empty() {
1390        return Vec::new();
1391    }
1392    let mut out: Vec<&str> = s.split('\n').collect();
1393    if out.last() == Some(&"") {
1394        out.pop();
1395    }
1396    out
1397}
1398
1399/// Each line being tracked through history.
1400/// `final_lineno` is the 1-based line number in the target file.
1401/// `current_idx` is the 0-based index in the current version being examined.
1402#[derive(Debug, Clone)]
1403struct TrackedLine {
1404    final_lineno: usize,
1405    current_idx: usize,
1406    ignored: bool,
1407    source_path: Option<String>,
1408    /// Merge hand-off this line arrived through (`0` for the starting suspect). Lines of
1409    /// different hand-offs can share a queued suspect but never compete for a parent line.
1410    origin: u32,
1411}
1412
1413/// A commit that still owns lines of the blamed file at `path`.
1414struct BlameSuspect {
1415    oid: ObjectId,
1416    blob: ObjectId,
1417    mode: u32,
1418    path: String,
1419    /// Lines of `blob`, carried along so the walk does not reload them.
1420    lines: Option<Arc<BlobLines>>,
1421    pending: Vec<TrackedLine>,
1422}
1423
1424/// Uncommitted final text blamed as a commit on top of `parents` (Git's fake working-tree
1425/// commit). It has no object id: its lines are diffed straight from memory.
1426struct BlameOverlay {
1427    lines: Arc<BlobLines>,
1428    /// The start revision, then any `MERGE_HEAD` commits, in Git's parent order.
1429    parents: Vec<ObjectId>,
1430    /// Parent paths copy detection may search for uncommitted lines (`None`: every path).
1431    copy_paths: Option<HashSet<String>>,
1432}
1433
1434/// History-walk settings shared by the fake-commit pass and the copy-source walks it starts.
1435struct OverlayWalk<'a> {
1436    path: &'a str,
1437    ignore_revs: &'a HashSet<ObjectId>,
1438    diff_algorithm: BlameDiffAlgorithm,
1439    textconv_ctx: Option<&'a BlameTextconvContext>,
1440    use_textconv: bool,
1441    copy_depth: usize,
1442    first_parent_only: bool,
1443    grafts: &'a HashMap<ObjectId, Vec<ObjectId>>,
1444    indent_heuristic: bool,
1445}
1446
1447/// Pass the overlay's lines to its parents in order, like a merge commit hands lines to the
1448/// first parent whose version matches after diffing.
1449///
1450/// Lines no parent (or copy source) explains are pushed to `result` as not committed. The
1451/// first parent's share is returned so the walk continues with it; later parents' shares
1452/// are queued under fresh hand-off origins.
1453#[allow(clippy::too_many_arguments)]
1454fn pass_overlay_to_parents(
1455    odb: &Odb,
1456    session: &mut BlameSession<'_>,
1457    overlay: &BlameOverlay,
1458    walk: &OverlayWalk<'_>,
1459    pending: Vec<TrackedLine>,
1460    final_text: &BlobLines,
1461    commit_cache: &mut BlameCommitCache,
1462    queue: &mut SuspectQueue,
1463    next_origin: &mut u32,
1464    result: &mut Vec<BlameLine>,
1465) -> Result<Option<BlameSuspect>> {
1466    let ws = session.whitespace;
1467    let cur = overlay.lines.as_ref();
1468    if session.merge_line_stamps.len() < overlay.parents.len() {
1469        session
1470            .merge_line_stamps
1471            .resize_with(overlay.parents.len(), LineUseStamp::default);
1472    }
1473    let mut remaining = pending;
1474    let mut first = None;
1475    let parent_count = if walk.first_parent_only {
1476        1
1477    } else {
1478        overlay.parents.len()
1479    };
1480    for (i, &parent) in overlay.parents.iter().take(parent_count).enumerate() {
1481        if remaining.is_empty() {
1482            break;
1483        }
1484        let Some((blob, mode)) =
1485            session.resolve_blob_at_commit(odb, parent, walk.path, commit_cache)?
1486        else {
1487            continue;
1488        };
1489        let par = session.load_blob_lines(blob, walk.path, mode)?;
1490        let (map, _) = build_line_map_with_changed_flags_owned(
1491            &par,
1492            cur,
1493            slide_safe_affix(par.as_ref(), cur, ws),
1494            walk.diff_algorithm,
1495            walk.indent_heuristic,
1496            false,
1497            ws,
1498        );
1499        session.merge_line_stamps[i].begin_pass(par.len());
1500        let origin = if i == 0 {
1501            0
1502        } else {
1503            *next_origin += 1;
1504            *next_origin
1505        };
1506        let mut handed = Vec::new();
1507        let mut unmatched = Vec::new();
1508        for t in remaining {
1509            let matched = map.get(t.current_idx).copied().flatten().filter(|&p_idx| {
1510                p_idx < par.len()
1511                    && session.merge_line_stamps[i].is_unused(p_idx)
1512                    && blame_lines_equal(
1513                        par.comparison_line(p_idx, ws),
1514                        cur.comparison_line(t.current_idx, ws),
1515                        ws,
1516                    )
1517            });
1518            match matched {
1519                Some(p_idx) => {
1520                    session.merge_line_stamps[i].mark(p_idx);
1521                    handed.push(TrackedLine {
1522                        current_idx: p_idx,
1523                        origin,
1524                        ..t
1525                    });
1526                }
1527                None => unmatched.push(t),
1528            }
1529        }
1530        remaining = unmatched;
1531        if handed.is_empty() {
1532            continue;
1533        }
1534        let suspect = BlameSuspect {
1535            oid: parent,
1536            blob,
1537            mode,
1538            path: walk.path.to_owned(),
1539            lines: Some(par),
1540            pending: handed,
1541        };
1542        if i == 0 {
1543            first = Some(suspect);
1544        } else {
1545            queue.push(suspect, || blame_suspect_date(session, &parent));
1546        }
1547    }
1548
1549    if walk.copy_depth >= 1 && !remaining.is_empty() {
1550        if let Some(&parent) = overlay.parents.first() {
1551            let parent_commit =
1552                get_blame_commit(odb, parent, commit_cache, session.commit_graph.as_deref())?;
1553            let (left, _handoff, copy_lines) = copy_detect_attribute_pending(
1554                odb,
1555                session,
1556                remaining,
1557                &overlay.lines,
1558                final_text,
1559                walk.path,
1560                parent,
1561                &parent_commit.tree,
1562                &parent_commit.tree,
1563                walk.path,
1564                walk.copy_depth,
1565                false,
1566                Some(overlay.copy_paths.as_ref()),
1567                None,
1568                walk.ignore_revs,
1569                walk.diff_algorithm,
1570                walk.textconv_ctx,
1571                walk.use_textconv,
1572                walk.first_parent_only,
1573                walk.grafts,
1574                walk.indent_heuristic,
1575            )?;
1576            result.extend(copy_lines);
1577            remaining = left;
1578        }
1579    }
1580
1581    let null = ObjectId::null(odb.hash_algo());
1582    for t in remaining {
1583        result.push(BlameLine {
1584            oid: null,
1585            final_lineno: t.final_lineno,
1586            orig_lineno: t.current_idx + 1,
1587            content: final_line_display(final_text, t.final_lineno).content,
1588            encoding_lossy: final_line_display(final_text, t.final_lineno).encoding_lossy,
1589            content_bytes: final_line_display(final_text, t.final_lineno).content_bytes,
1590            original_path: walk.path.to_owned(),
1591            source_file: None,
1592            ignored: false,
1593            unblamable: false,
1594            not_committed: true,
1595        });
1596    }
1597    Ok(first)
1598}
1599
1600/// Heap position of a queued suspect: newest committer date first, ties in queue order.
1601#[derive(PartialEq, Eq)]
1602struct SuspectSlot {
1603    date: i64,
1604    seq: u64,
1605    oid: ObjectId,
1606    path: String,
1607}
1608
1609impl Ord for SuspectSlot {
1610    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1611        self.date
1612            .cmp(&other.date)
1613            .then_with(|| other.seq.cmp(&self.seq))
1614    }
1615}
1616
1617impl PartialOrd for SuspectSlot {
1618    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1619        Some(self.cmp(other))
1620    }
1621}
1622
1623/// Suspects waiting to pass blame to their parents, keyed by `(commit, path)`.
1624///
1625/// Lines handed to a suspect that is already queued join its pending set. Processing in
1626/// committer-date order makes merge paths meet before the shared commit is popped, so it is
1627/// diffed against its parents once; only clock skew (an ancestor dated after a descendant)
1628/// can hand it lines after it was popped and queue it again.
1629#[derive(Default)]
1630struct SuspectQueue {
1631    heap: BinaryHeap<SuspectSlot>,
1632    entries: HashMap<(ObjectId, String), BlameSuspect>,
1633    seq: u64,
1634}
1635
1636impl SuspectQueue {
1637    fn is_empty(&self) -> bool {
1638        self.heap.is_empty()
1639    }
1640
1641    /// Queue `suspect`, merging it into an already queued suspect for the same commit and path.
1642    ///
1643    /// `date` is only evaluated for a new queue entry.
1644    fn push(&mut self, suspect: BlameSuspect, date: impl FnOnce() -> i64) {
1645        let key = (suspect.oid, suspect.path);
1646        if let Some(queued) = self.entries.get_mut(&key) {
1647            queued.pending.extend(suspect.pending);
1648            if queued.lines.is_none() {
1649                queued.lines = suspect.lines;
1650            }
1651            return;
1652        }
1653        self.heap.push(SuspectSlot {
1654            date: date(),
1655            seq: self.seq,
1656            oid: key.0,
1657            path: key.1.clone(),
1658        });
1659        self.seq += 1;
1660        self.entries.insert(
1661            key.clone(),
1662            BlameSuspect {
1663                oid: key.0,
1664                path: key.1,
1665                ..suspect
1666            },
1667        );
1668    }
1669
1670    /// Remove the newest suspect.
1671    fn pop(&mut self) -> Option<BlameSuspect> {
1672        let slot = self.heap.pop()?;
1673        self.entries.remove(&(slot.oid, slot.path))
1674    }
1675}
1676
1677/// Committer date used to order suspects (commit-graph when available, else the commit header).
1678fn blame_suspect_date(session: &mut BlameSession<'_>, oid: &ObjectId) -> i64 {
1679    if let Some((_, date)) = session
1680        .commit_graph
1681        .as_ref()
1682        .and_then(|chain| chain.graph_commit(oid))
1683    {
1684        return date;
1685    }
1686    session
1687        .pack
1688        .read(session.odb, oid)
1689        .ok()
1690        .and_then(|obj| {
1691            crate::objects::CommitRef::parse(&obj.data)
1692                .ok()
1693                .map(|c| c.committer_unix_seconds_for_ordering())
1694        })
1695        .unwrap_or(0)
1696}
1697
1698/// Cached config and attributes for optional textconv during blame.
1699#[derive(Debug, Clone)]
1700pub struct BlameTextconvContext {
1701    /// Repository configuration snapshot.
1702    pub config: ConfigSet,
1703    conversion: ConversionConfig,
1704    /// Parsed `.gitattributes` rules for the repository.
1705    pub attrs: GitAttributes,
1706    attr_macros: MacroTable,
1707    diff_attrs: Vec<DiffAttrRule>,
1708    filter_process: std::sync::Arc<crate::repo_caches::RepoCaches>,
1709}
1710
1711impl BlameTextconvContext {
1712    /// Build textconv context from the open repository (config, attributes, filters).
1713    pub fn new(repo: &Repository) -> Self {
1714        let config = repo.config().map(|c| (*c).clone()).unwrap_or_default();
1715        let conversion = ConversionConfig::from_config(&config);
1716        let parsed = load_attr_parsed(repo);
1717        let attrs = parsed.rules.clone();
1718        let attr_macros = parsed.macros;
1719        let diff_attrs = load_diff_attr_rules(repo);
1720        Self {
1721            config,
1722            conversion,
1723            attrs,
1724            attr_macros,
1725            diff_attrs,
1726            filter_process: std::sync::Arc::clone(repo.caches()),
1727        }
1728    }
1729
1730    fn filters(&self) -> &crate::filter_process::FilterProcessState {
1731        self.filter_process.filters()
1732    }
1733}
1734
1735#[derive(Debug, Clone)]
1736struct DiffAttrRule {
1737    pattern: String,
1738    value: DiffAttrValue,
1739}
1740
1741#[derive(Debug, Clone)]
1742enum DiffAttrValue {
1743    Unset,
1744    Set,
1745    Driver(String),
1746}
1747
1748fn load_attr_parsed(repo: &Repository) -> ParsedGitAttributes {
1749    if let Some(work_tree) = repo.work_tree() {
1750        if let Ok(p) = load_gitattributes_stack(repo, work_tree) {
1751            if !p.rules.is_empty() {
1752                return p;
1753            }
1754        }
1755        let p = load_gitattributes_parsed(work_tree);
1756        if !p.rules.is_empty() {
1757            return p;
1758        }
1759    }
1760
1761    if let Ok(index) = repo.load_index() {
1762        if let Some(wt) = repo.work_tree() {
1763            if let Ok(p) = crate::attributes::load_gitattributes_from_index(&index, repo.odb(), wt)
1764            {
1765                return p;
1766            }
1767        }
1768        return ParsedGitAttributes {
1769            rules: load_gitattributes_from_index(&index, repo.odb()),
1770            ..ParsedGitAttributes::default()
1771        };
1772    }
1773
1774    ParsedGitAttributes::default()
1775}
1776
1777fn load_diff_attr_rules(repo: &Repository) -> Vec<DiffAttrRule> {
1778    let mut rules = Vec::new();
1779
1780    if let Some(work_tree) = repo.work_tree() {
1781        parse_diff_attr_file(&work_tree.join(".gitattributes"), &mut rules);
1782        parse_diff_attr_file(&work_tree.join(".git/info/attributes"), &mut rules);
1783    }
1784
1785    if rules.is_empty() {
1786        if let Ok(index) = repo.load_index() {
1787            if let Some(entry) = index.get(b".gitattributes", 0) {
1788                if let Ok(obj) = repo.odb().read(&entry.oid) {
1789                    if let Ok(content) = String::from_utf8(obj.data) {
1790                        parse_diff_attr_content(&content, &mut rules);
1791                    }
1792                }
1793            }
1794        }
1795        parse_diff_attr_file(&repo.git_dir().join("info/attributes"), &mut rules);
1796    }
1797
1798    rules
1799}
1800
1801/// Read an object for blame, optionally invoking a promisor hydration hook on miss.
1802pub fn read_object_for_blame(
1803    odb: &Odb,
1804    oid: &ObjectId,
1805    repo: Option<&Repository>,
1806) -> Result<Object> {
1807    match odb.read(oid) {
1808        Ok(obj) => Ok(obj),
1809        Err(LibError::ObjectNotFound(_)) => {
1810            if let Some(repo) = repo {
1811                if let Some(hook) = repo.promisor_hydrate_hook() {
1812                    hook(repo, *oid);
1813                    return odb.read(oid);
1814                }
1815            }
1816            Err(LibError::ObjectNotFound(oid.to_hex()))
1817        }
1818        Err(err) => Err(err),
1819    }
1820}
1821
1822fn parse_diff_attr_file(path: &Path, rules: &mut Vec<DiffAttrRule>) {
1823    if let Ok(content) = std::fs::read_to_string(path) {
1824        parse_diff_attr_content(&content, rules);
1825    }
1826}
1827
1828fn parse_diff_attr_content(content: &str, rules: &mut Vec<DiffAttrRule>) {
1829    for line in content.lines() {
1830        let line = line.trim();
1831        if line.is_empty() || line.starts_with('#') {
1832            continue;
1833        }
1834
1835        let mut parts = line.split_whitespace();
1836        let Some(pattern) = parts.next() else {
1837            continue;
1838        };
1839
1840        let mut value: Option<DiffAttrValue> = None;
1841        for token in parts {
1842            if token == "binary" || token == "-diff" {
1843                value = Some(DiffAttrValue::Unset);
1844            } else if token == "diff" {
1845                value = Some(DiffAttrValue::Set);
1846            } else if let Some(driver) = token.strip_prefix("diff=") {
1847                value = Some(DiffAttrValue::Driver(driver.to_owned()));
1848            }
1849        }
1850
1851        if let Some(value) = value {
1852            rules.push(DiffAttrRule {
1853                pattern: pattern.to_owned(),
1854                value,
1855            });
1856        }
1857    }
1858}
1859
1860fn resolve_textconv_command(ctx: &BlameTextconvContext, path: &str) -> Option<String> {
1861    let mut selected: Option<DiffAttrValue> = None;
1862    for rule in &ctx.diff_attrs {
1863        if diff_attr_pattern_matches(&rule.pattern, path) {
1864            selected = Some(rule.value.clone());
1865        }
1866    }
1867
1868    match selected {
1869        Some(DiffAttrValue::Driver(driver)) => ctx.config.get(&format!("diff.{driver}.textconv")),
1870        _ => None,
1871    }
1872}
1873
1874fn diff_attr_pattern_matches(pattern: &str, path: &str) -> bool {
1875    if pattern.contains('/') {
1876        return wildmatch(pattern.as_bytes(), path.as_bytes(), 0);
1877    }
1878    let basename = path.rsplit('/').next().unwrap_or(path);
1879    wildmatch(pattern.as_bytes(), basename.as_bytes(), 0)
1880}
1881
1882fn is_regular_mode(mode: u32) -> bool {
1883    mode & 0o170000 == 0o100000
1884}
1885
1886fn run_textconv_command(command: &str, input_data: &[u8]) -> Result<Vec<u8>> {
1887    use crate::command_runner::{run_sh_dash_c, system_command_runner, CommandStdin, CommandStdio};
1888    let temp_path = create_temp_textconv_file(input_data)?;
1889    let quoted = shell_quote(temp_path.to_string_lossy().as_ref());
1890    let shell_command = format!("{command} {quoted}");
1891
1892    let output = run_sh_dash_c(
1893        system_command_runner().as_ref(),
1894        &shell_command,
1895        None,
1896        CommandStdin::Null,
1897        CommandStdio::Inherit,
1898    )
1899    .map_err(|e| LibError::Message(format!("running textconv command '{command}': {e}")))?;
1900
1901    let _ = std::fs::remove_file(&temp_path);
1902
1903    if !output.status.success {
1904        return Err(LibError::Message(format!(
1905            "textconv command exited with status {}",
1906            output.status.code.unwrap_or(-1)
1907        )));
1908    }
1909
1910    Ok(output.stdout)
1911}
1912
1913fn create_temp_textconv_file(data: &[u8]) -> Result<std::path::PathBuf> {
1914    let pid = std::process::id();
1915    for attempt in 0..32u32 {
1916        let now = crate::git_date::tm::process_wall_clock_sec() as u128;
1917        let path = std::env::temp_dir().join(format!("grit-blame-textconv-{pid}-{now}-{attempt}"));
1918        // Ephemeral textconv input (not a repo lock); retry on name collision.
1919        match OpenOptions::new().create_new(true).write(true).open(&path) {
1920            Ok(mut file) => {
1921                file.write_all(data)?;
1922                return Ok(path);
1923            }
1924            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue,
1925            Err(err) => return Err(err.into()),
1926        }
1927    }
1928    Err(LibError::Message(
1929        "failed to create temporary textconv input file".to_string(),
1930    ))
1931}
1932
1933fn shell_quote(text: &str) -> String {
1934    format!("'{}'", text.replace('\'', "'\\''"))
1935}
1936
1937fn read_blob_content_for_blame(
1938    odb: &Odb,
1939    oid: &ObjectId,
1940    path: &str,
1941    mode: u32,
1942    textconv_ctx: Option<&BlameTextconvContext>,
1943    use_textconv: bool,
1944) -> Result<Vec<u8>> {
1945    let obj = read_object_for_blame(odb, oid, None)?;
1946    if obj.kind != ObjectKind::Blob {
1947        return Err(LibError::Message("expected blob object".to_string()));
1948    }
1949
1950    if !use_textconv || !is_regular_mode(mode) {
1951        return Ok(obj.data);
1952    }
1953
1954    let Some(ctx) = textconv_ctx else {
1955        return Ok(obj.data);
1956    };
1957    let Some(command) = resolve_textconv_command(ctx, path) else {
1958        return Ok(obj.data);
1959    };
1960
1961    let attrs =
1962        file_attrs_from_gitattributes(&ctx.attrs, &ctx.attr_macros, path, false, &ctx.config);
1963    let oid_hex = oid.to_string();
1964    let worktree_data = convert_to_worktree_eager(
1965        &obj.data,
1966        path,
1967        &ctx.conversion,
1968        &attrs,
1969        Some(&oid_hex),
1970        None,
1971        Some(ctx.filters()),
1972    )
1973    .map_err(LibError::Filter)?;
1974    run_textconv_command(&command, &worktree_data)
1975        .or_else(|_| run_textconv_command(&command, &obj.data))
1976}
1977
1978#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1979struct BlobCacheKey {
1980    oid: ObjectId,
1981    path: String,
1982    mode: u32,
1983}
1984
1985/// Display metadata for one line in a blamed file.
1986#[derive(Debug, Clone, PartialEq, Eq)]
1987struct BlameLineDisplay {
1988    content: String,
1989    encoding_lossy: bool,
1990    content_bytes: Option<Vec<u8>>,
1991}
1992
1993fn latin1_bytes_to_string(bytes: &[u8]) -> String {
1994    bytes.iter().map(|&b| char::from(b)).collect()
1995}
1996
1997#[derive(Clone)]
1998enum BlobStorage {
1999    Utf8(Arc<str>),
2000    Binary(Arc<Vec<u8>>),
2001}
2002
2003/// Blob bytes split into lines without per-line `String` allocations.
2004#[derive(Clone)]
2005struct BlobLines {
2006    storage: BlobStorage,
2007    /// Exclusive end byte offset of each line in the blob.
2008    line_ends: Arc<Vec<usize>>,
2009    compare_bodies: OnceLock<Arc<Vec<String>>>,
2010    compare_records: OnceLock<Arc<Vec<String>>>,
2011}
2012
2013impl BlobLines {
2014    fn from_bytes(content: Vec<u8>) -> Self {
2015        let unterminated = !content.is_empty() && !content.ends_with(b"\n");
2016        let count = memchr::memchr_iter(b'\n', &content).count() + usize::from(unterminated);
2017        let mut line_ends = Vec::with_capacity(count);
2018        line_ends.extend(memchr::memchr_iter(b'\n', &content));
2019        if unterminated {
2020            line_ends.push(content.len());
2021        }
2022        let storage = match std::str::from_utf8(&content) {
2023            Ok(text) => BlobStorage::Utf8(Arc::from(text)),
2024            Err(_) => BlobStorage::Binary(Arc::new(content)),
2025        };
2026        Self {
2027            storage,
2028            line_ends: Arc::new(line_ends),
2029            compare_bodies: OnceLock::new(),
2030            compare_records: OnceLock::new(),
2031        }
2032    }
2033
2034    #[cfg(test)]
2035    fn from_content(content: String) -> Self {
2036        Self::from_bytes(content.into_bytes())
2037    }
2038
2039    fn bytes(&self) -> &[u8] {
2040        match &self.storage {
2041            BlobStorage::Utf8(s) => s.as_bytes(),
2042            BlobStorage::Binary(b) => b.as_ref(),
2043        }
2044    }
2045
2046    fn utf8(&self) -> Option<&str> {
2047        match &self.storage {
2048            BlobStorage::Utf8(s) => Some(s.as_ref()),
2049            BlobStorage::Binary(_) => None,
2050        }
2051    }
2052
2053    fn len(&self) -> usize {
2054        self.line_ends.len()
2055    }
2056
2057    fn line_start(&self, idx: usize) -> usize {
2058        idx.checked_sub(1)
2059            .map_or(0, |prev| self.line_ends[prev] + 1)
2060    }
2061
2062    fn line_body_bytes(&self, idx: usize) -> &[u8] {
2063        &self.bytes()[self.line_start(idx)..self.line_ends[idx]]
2064    }
2065
2066    fn line(&self, idx: usize) -> &str {
2067        if let Some(text) = self.utf8() {
2068            let start = self.line_start(idx);
2069            let end = self.line_ends[idx];
2070            return &text[start..end];
2071        }
2072        // Only used for tests and lossy display helpers; comparison uses Latin-1 strings.
2073        self.compare_bodies()
2074            .get(idx)
2075            .map(String::as_str)
2076            .unwrap_or("")
2077    }
2078
2079    fn line_display(&self, idx: usize) -> BlameLineDisplay {
2080        let body = self.line_body_bytes(idx);
2081        let encoding_lossy = std::str::from_utf8(body).is_err();
2082        let content = String::from_utf8_lossy(body).into_owned();
2083        let content_bytes = encoding_lossy.then(|| body.to_vec());
2084        BlameLineDisplay {
2085            content,
2086            encoding_lossy,
2087            content_bytes,
2088        }
2089    }
2090
2091    fn compare_bodies(&self) -> &Arc<Vec<String>> {
2092        self.compare_bodies.get_or_init(|| {
2093            Arc::new(
2094                (0..self.len())
2095                    .map(|i| latin1_bytes_to_string(self.line_body_bytes(i)))
2096                    .collect(),
2097            )
2098        })
2099    }
2100
2101    fn compare_records(&self) -> &Arc<Vec<String>> {
2102        self.compare_records.get_or_init(|| {
2103            Arc::new(
2104                (0..self.len())
2105                    .map(|i| {
2106                        let start = self.line_start(i);
2107                        let end = (self.line_ends[i] + 1).min(self.bytes().len());
2108                        latin1_bytes_to_string(&self.bytes()[start..end])
2109                    })
2110                    .collect(),
2111            )
2112        })
2113    }
2114
2115    /// Exact comparison borrows the full record; whitespace modes compare line bodies.
2116    fn comparison_line(&self, idx: usize, ws: crate::diff::WhitespaceMode) -> &str {
2117        if let Some(text) = self.utf8() {
2118            if ws.normalizes_line_compare() {
2119                let start = self.line_start(idx);
2120                let end = self.line_ends[idx];
2121                return &text[start..end];
2122            }
2123            let end = (self.line_ends[idx] + 1).min(text.len());
2124            return &text[self.line_start(idx)..end];
2125        }
2126        if ws.normalizes_line_compare() {
2127            return &self.compare_bodies()[idx];
2128        }
2129        &self.compare_records()[idx]
2130    }
2131
2132    fn comparison_refs(&self, ws: crate::diff::WhitespaceMode) -> Vec<&str> {
2133        (0..self.len())
2134            .map(|i| self.comparison_line(i, ws))
2135            .collect()
2136    }
2137
2138    fn to_vec(&self) -> Vec<String> {
2139        (0..self.len())
2140            .map(|i| self.line_display(i).content)
2141            .collect()
2142    }
2143}
2144
2145/// Marks line indices used in one merge-parent pass without clearing a full `Vec<bool>`.
2146#[derive(Default)]
2147struct LineUseStamp {
2148    generation: u32,
2149    marks: Vec<u32>,
2150}
2151
2152impl LineUseStamp {
2153    fn begin_pass(&mut self, min_len: usize) {
2154        self.generation = self.generation.wrapping_add(1);
2155        if self.generation == 0 {
2156            self.marks.fill(0);
2157            self.generation = 1;
2158        }
2159        if self.marks.len() < min_len {
2160            self.marks.resize(min_len, 0);
2161        }
2162    }
2163
2164    fn mark(&mut self, idx: usize) {
2165        if idx >= self.marks.len() {
2166            self.marks.resize(idx + 1, 0);
2167        }
2168        self.marks[idx] = self.generation;
2169    }
2170
2171    fn is_unused(&self, idx: usize) -> bool {
2172        self.marks.get(idx).is_none_or(|&g| g != self.generation)
2173    }
2174}
2175
2176#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2177struct PathCacheKey {
2178    tree: ObjectId,
2179    path: String,
2180}
2181
2182#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2183struct PairMapCacheKey {
2184    parent: ObjectId,
2185    parent_path: String,
2186    parent_mode: u32,
2187    child: ObjectId,
2188    child_path: String,
2189    child_mode: u32,
2190    diff_algorithm: BlameDiffAlgorithm,
2191    indent_heuristic: bool,
2192    fuzzy: bool,
2193    include_stop_blame: bool,
2194    whitespace: crate::diff::WhitespaceMode,
2195}
2196
2197type BloomKeyCacheEntry = (String, u32, Arc<Vec<Vec<u32>>>);
2198
2199#[derive(Clone)]
2200struct PairMapCacheValue {
2201    diff_line_map: Arc<Vec<Option<usize>>>,
2202    line_map: Arc<Vec<Option<usize>>>,
2203    stop_blame_on_current: Arc<Vec<bool>>,
2204}
2205
2206/// Number of cached blob versions above which unreferenced ones are dropped.
2207const BLOB_LINES_CACHE_SOFT_LIMIT: usize = 64;
2208
2209/// Per-blame caches for blob lines, tree path resolution, and parent/child line maps.
2210struct BlameSession<'a> {
2211    odb: &'a Odb,
2212    textconv_ctx: Option<&'a BlameTextconvContext>,
2213    use_textconv: bool,
2214    whitespace: crate::diff::WhitespaceMode,
2215    commit_graph: Option<Arc<CommitGraphChain>>,
2216    bloom_pathspec: Vec<String>,
2217    bloom_key_cache: Option<BloomKeyCacheEntry>,
2218    blob_lines: HashMap<BlobCacheKey, Arc<BlobLines>>,
2219    path_entries: HashMap<PathCacheKey, Option<(ObjectId, u32)>>,
2220    commit_path_blob: HashMap<(ObjectId, String), Option<(ObjectId, u32)>>,
2221    pair_maps: HashMap<PairMapCacheKey, PairMapCacheValue>,
2222    /// Keep parent/child line maps for reuse. Only nested walks (ignored merges, copy
2223    /// sources) diff a pair twice; a plain walk diffs each pair once.
2224    cache_pair_maps: bool,
2225    identity_line_maps: HashMap<usize, Arc<Vec<Option<usize>>>>,
2226    pack: SessionPackReader,
2227    merge_line_stamps: Vec<LineUseStamp>,
2228    #[cfg(test)]
2229    diffed_pairs: Vec<(ObjectId, ObjectId)>,
2230}
2231
2232impl<'a> BlameSession<'a> {
2233    fn new(
2234        odb: &'a Odb,
2235        textconv_ctx: Option<&'a BlameTextconvContext>,
2236        use_textconv: bool,
2237        thread_policy: &ThreadPolicy,
2238    ) -> Self {
2239        let _ = thread_policy;
2240        Self {
2241            odb,
2242            textconv_ctx,
2243            use_textconv,
2244            whitespace: crate::diff::WhitespaceMode::default(),
2245            commit_graph: None,
2246            bloom_pathspec: Vec::new(),
2247            bloom_key_cache: None,
2248            blob_lines: HashMap::new(),
2249            path_entries: HashMap::new(),
2250            commit_path_blob: HashMap::new(),
2251            pair_maps: HashMap::new(),
2252            cache_pair_maps: false,
2253            identity_line_maps: HashMap::new(),
2254            pack: SessionPackReader::default(),
2255            merge_line_stamps: Vec::new(),
2256            #[cfg(test)]
2257            diffed_pairs: Vec::new(),
2258        }
2259    }
2260
2261    fn identity_line_map(&mut self, len: usize) -> Arc<Vec<Option<usize>>> {
2262        if let Some(map) = self.identity_line_maps.get(&len) {
2263            return Arc::clone(map);
2264        }
2265        let map: Arc<Vec<Option<usize>>> = Arc::new((0..len).map(Some).collect());
2266        self.identity_line_maps.insert(len, Arc::clone(&map));
2267        map
2268    }
2269
2270    /// Look up one entry of `tree_oid` without retaining the tree data.
2271    fn tree_entry(&mut self, tree_oid: ObjectId, name: &[u8]) -> Result<Option<(ObjectId, u32)>> {
2272        // Whole-object reads (unlike `read_into`) feed the delta-base cache; consecutive
2273        // root trees are long delta chains against each other, so the cache is what keeps
2274        // a first-parent walk from re-inflating every chain from its base.
2275        let obj = self.pack.read(self.odb, &tree_oid)?;
2276        if obj.kind != ObjectKind::Tree {
2277            return Err(LibError::Message("expected tree".to_string()));
2278        }
2279        let oid_len = self.odb.hash_algo().len();
2280        Ok(
2281            find_tree_entry_by_name_dir_or_file_in_data(&obj.data, oid_len, name)?
2282                .map(|e| (e.oid, e.mode)),
2283        )
2284    }
2285
2286    /// Drop cached blob lines that no queued suspect holds once the cache outgrows
2287    /// [`BLOB_LINES_CACHE_SOFT_LIMIT`], so a deep walk keeps only the versions it still needs.
2288    fn prune_blob_lines(&mut self) {
2289        if self.blob_lines.len() > BLOB_LINES_CACHE_SOFT_LIMIT {
2290            self.blob_lines
2291                .retain(|_, lines| Arc::strong_count(lines) > 1);
2292        }
2293    }
2294
2295    fn set_bloom_path(&mut self, path: &str) {
2296        if self.bloom_pathspec.len() == 1 && self.bloom_pathspec[0] == path {
2297            return;
2298        }
2299        self.bloom_pathspec.clear();
2300        self.bloom_pathspec.push(path.to_owned());
2301        self.bloom_key_cache = None;
2302    }
2303
2304    fn bloom_keys_for_path(
2305        &mut self,
2306        path: &str,
2307        settings: &crate::bloom::BloomFilterSettings,
2308    ) -> Arc<Vec<Vec<u32>>> {
2309        let version = settings.hash_version;
2310        let needs_refresh = self
2311            .bloom_key_cache
2312            .as_ref()
2313            .is_none_or(|(p, v, _)| p != path || *v != version);
2314        if needs_refresh {
2315            let bytes = storage_bytes(path);
2316            let keys = crate::bloom::bloom_keyvec_for_bytes(&bytes, settings);
2317            self.bloom_key_cache = Some((path.to_owned(), version, Arc::new(keys)));
2318        }
2319        if let Some((_, _, keys)) = &self.bloom_key_cache {
2320            return Arc::clone(keys);
2321        }
2322        Arc::new(Vec::new())
2323    }
2324
2325    fn resolve_blob_at_commit(
2326        &mut self,
2327        odb: &Odb,
2328        commit: ObjectId,
2329        path: &str,
2330        commit_cache: &mut BlameCommitCache,
2331    ) -> Result<Option<(ObjectId, u32)>> {
2332        let cache_key = (commit, path.to_owned());
2333        if let Some(resolved) = self.commit_path_blob.get(&cache_key) {
2334            return Ok(*resolved);
2335        }
2336        let meta = get_blame_commit(odb, commit, commit_cache, self.commit_graph.as_deref())?;
2337        let resolved = self.resolve_path_in_tree(meta.tree, path)?;
2338        self.commit_path_blob.insert(cache_key, resolved);
2339        Ok(resolved)
2340    }
2341
2342    fn line_refs(lines: &BlobLines) -> Vec<&str> {
2343        (0..lines.len()).map(|i| lines.line(i)).collect()
2344    }
2345
2346    fn load_blob_lines(&mut self, oid: ObjectId, path: &str, mode: u32) -> Result<Arc<BlobLines>> {
2347        let key = BlobCacheKey {
2348            oid,
2349            path: path.to_owned(),
2350            mode,
2351        };
2352        if let Some(lines) = self.blob_lines.get(&key) {
2353            return Ok(Arc::clone(lines));
2354        }
2355        let content = read_blob_content_for_blame(
2356            self.odb,
2357            &oid,
2358            path,
2359            mode,
2360            self.textconv_ctx,
2361            self.use_textconv,
2362        )?;
2363        let lines = Arc::new(BlobLines::from_bytes(content));
2364        self.blob_lines.insert(key, Arc::clone(&lines));
2365        Ok(lines)
2366    }
2367
2368    fn resolve_path_in_tree(
2369        &mut self,
2370        tree_oid: ObjectId,
2371        path: &str,
2372    ) -> Result<Option<(ObjectId, u32)>> {
2373        let bytes = storage_bytes(path);
2374        self.resolve_path_in_tree_bytes(tree_oid, &bytes)
2375    }
2376
2377    // Decode only at the storage boundary. A subtree suffix may itself start with the
2378    // reserved storage prefix, so recursion must keep using the original bytes.
2379    fn resolve_path_in_tree_bytes(
2380        &mut self,
2381        tree_oid: ObjectId,
2382        path: &[u8],
2383    ) -> Result<Option<(ObjectId, u32)>> {
2384        let key = PathCacheKey {
2385            tree: tree_oid,
2386            path: storage_string(path),
2387        };
2388        if let Some(entry) = self.path_entries.get(&key) {
2389            return Ok(*entry);
2390        }
2391        // Memoizing each `(subtree, remaining path)` level means a walk only inflates
2392        // the trees that changed between commits: unchanged subtrees hit the cache.
2393        let (head, rest) = match path.iter().position(|&b| b == b'/') {
2394            Some(pos) => (&path[..pos], Some(&path[pos + 1..])),
2395            None => (path, None),
2396        };
2397        let resolved = match (self.tree_entry(tree_oid, head)?, rest) {
2398            (Some((oid, 0o040000)), Some(rest)) => self.resolve_path_in_tree_bytes(oid, rest)?,
2399            (Some((oid, mode)), None) if mode != 0o040000 => Some((oid, mode)),
2400            _ => None,
2401        };
2402        self.path_entries.insert(key, resolved);
2403        Ok(resolved)
2404    }
2405
2406    #[allow(clippy::too_many_arguments)]
2407    fn parent_child_line_maps(
2408        &mut self,
2409        parent_oid: ObjectId,
2410        parent_path: &str,
2411        parent_mode: u32,
2412        child_oid: ObjectId,
2413        child_path: &str,
2414        child_mode: u32,
2415        par_lines: &BlobLines,
2416        cur_lines: &BlobLines,
2417        affix: Affix,
2418        diff_algorithm: BlameDiffAlgorithm,
2419        indent_heuristic: bool,
2420        is_ignored: bool,
2421        include_stop_blame: bool,
2422    ) -> PairMapCacheValue {
2423        #[cfg(test)]
2424        self.diffed_pairs.push((parent_oid, child_oid));
2425        let ws = self.whitespace;
2426        let key = PairMapCacheKey {
2427            parent: parent_oid,
2428            parent_path: parent_path.to_owned(),
2429            parent_mode,
2430            child: child_oid,
2431            child_path: child_path.to_owned(),
2432            child_mode,
2433            diff_algorithm,
2434            indent_heuristic,
2435            fuzzy: is_ignored,
2436            include_stop_blame,
2437            whitespace: ws,
2438        };
2439        if self.cache_pair_maps {
2440            if let Some(maps) = self.pair_maps.get(&key) {
2441                return maps.clone();
2442            }
2443        }
2444        let identity_map = parent_oid == child_oid && par_lines.len() == cur_lines.len();
2445        let (diff_line_map, xdiff_changed) = if identity_map {
2446            (
2447                (0..cur_lines.len()).map(Some).collect(),
2448                vec![false; cur_lines.len()],
2449            )
2450        } else if include_stop_blame {
2451            build_line_map_with_changed_flags_owned(
2452                par_lines,
2453                cur_lines,
2454                affix,
2455                diff_algorithm,
2456                indent_heuristic,
2457                true,
2458                ws,
2459            )
2460        } else {
2461            (
2462                build_line_map_with_changed_flags_owned(
2463                    par_lines,
2464                    cur_lines,
2465                    affix,
2466                    diff_algorithm,
2467                    indent_heuristic,
2468                    false,
2469                    ws,
2470                )
2471                .0,
2472                Vec::new(),
2473            )
2474        };
2475        let (line_map, stop_blame_on_current) = if include_stop_blame {
2476            if !is_ignored
2477                && par_lines.len() == cur_lines.len()
2478                && diff_line_map.iter().all(|m| m.is_some())
2479                && xdiff_changed.iter().all(|&f| !f)
2480            {
2481                (diff_line_map.clone(), vec![false; cur_lines.len()])
2482            } else {
2483                let par_refs = par_lines.comparison_refs(ws);
2484                let cur_refs = cur_lines.comparison_refs(ws);
2485                let mut line_map =
2486                    build_exact_relocate_line_map_ws(&par_refs, &cur_refs, &diff_line_map, ws);
2487                if is_ignored {
2488                    // Ignored revisions deliberately recover rewritten bodies by similarity.
2489                    line_map = build_fuzzy_line_map_ws(
2490                        &Self::line_refs(par_lines),
2491                        &Self::line_refs(cur_lines),
2492                        &line_map,
2493                        ws,
2494                    );
2495                }
2496                let stop = if par_lines.len() == cur_lines.len() {
2497                    blame_stops_on_current_commit(&diff_line_map, &line_map, &xdiff_changed)
2498                } else {
2499                    Vec::new()
2500                };
2501                (line_map, stop)
2502            }
2503        } else {
2504            (Vec::new(), Vec::new())
2505        };
2506        let value = PairMapCacheValue {
2507            diff_line_map: Arc::new(diff_line_map),
2508            line_map: Arc::new(line_map),
2509            stop_blame_on_current: Arc::new(stop_blame_on_current),
2510        };
2511        if self.cache_pair_maps {
2512            self.pair_maps.insert(key, value.clone());
2513        }
2514        value
2515    }
2516}
2517
2518/// Rename similarity threshold for blame history walks (Git default `-M` percentage).
2519const BLAME_RENAME_SIMILARITY: u32 = 50;
2520
2521/// Tree and parent linkage for a commit, cached during blame walks.
2522#[derive(Debug, Clone)]
2523struct BlameCommitMeta {
2524    tree: ObjectId,
2525    parents: Vec<ObjectId>,
2526}
2527
2528/// Pack reader held for one blame walk, opened on first use.
2529///
2530/// [`Odb::read`] re-opens a pack reader per call; a walk over tens of thousands of commits
2531/// and trees keeps one instead. Any miss or error falls back to the [`Odb`], so this is
2532/// purely a fast path.
2533#[derive(Default)]
2534struct SessionPackReader {
2535    reader: Option<SingleLocalPackReader>,
2536    opened: bool,
2537}
2538
2539impl SessionPackReader {
2540    fn get(&mut self, odb: &Odb) -> Option<&mut SingleLocalPackReader> {
2541        if !self.opened {
2542            self.opened = true;
2543            self.reader = odb.open_session_pack_reader().ok().flatten();
2544        }
2545        self.reader.as_mut()
2546    }
2547
2548    /// Read an object, preferring the session pack (which feeds the delta-base cache).
2549    fn read(&mut self, odb: &Odb, oid: &ObjectId) -> Result<Object> {
2550        if let Some(Ok(obj)) = self.get(odb).map(|r| r.read_object(oid)) {
2551            return Ok(obj);
2552        }
2553        read_object_for_blame(odb, oid, None)
2554    }
2555
2556    /// Tree and parents of `oid`, inflating only the commit header when it is packed whole.
2557    ///
2558    /// Both the header-prefix and the whole-object paths use Git's canonical tree/parent
2559    /// parse, which ignores the rest of the headers; a prefix is only trusted when it holds
2560    /// the complete parent list, so the result never depends on how the object is stored.
2561    fn commit_tree_and_parents(
2562        &mut self,
2563        odb: &Odb,
2564        oid: &ObjectId,
2565    ) -> Result<(ObjectId, Vec<ObjectId>)> {
2566        let hex_len = odb.hash_algo().hex_len();
2567        if let Some(reader) = self.get(odb) {
2568            if let Ok(Some(prefix)) = reader.read_commit_author_prefix_unordered(oid) {
2569                if let Some(parsed) = parse_commit_tree_and_parents_prefix(prefix, hex_len) {
2570                    return Ok(parsed);
2571                }
2572            }
2573            reader.clear_commit_header_prefix();
2574        }
2575        let mut data = Vec::new();
2576        if odb.read_into(oid, &mut data)? != ObjectKind::Commit {
2577            return Err(LibError::CorruptObject(format!(
2578                "{} is not a commit",
2579                oid.to_hex()
2580            )));
2581        }
2582        parse_commit_tree_and_parents_lenient(&data, hex_len).ok_or_else(|| {
2583            LibError::CorruptObject(format!("commit {} has no valid tree header", oid.to_hex()))
2584        })
2585    }
2586}
2587
2588/// Commit metadata memoized across one blame walk.
2589#[derive(Default)]
2590struct BlameCommitCache {
2591    metas: HashMap<ObjectId, Arc<BlameCommitMeta>>,
2592    pack: SessionPackReader,
2593}
2594
2595/// Walk history and attribute each line in a blob at `start_oid` to a commit.
2596///
2597/// When `line_range` is set, only lines whose 1-based final line number lies in the range
2598/// are walked and returned.
2599///
2600/// `file_path` is repository-relative and uses `repo_relpath::storage_string`,
2601/// as do returned `original_path` and `source_file` values. Decoding preserves exact
2602/// filename bytes and does not recursively interpret reserved-prefix names.
2603///
2604/// Lower-level building block; prefer [`blame_file`] for repository callers.
2605#[expect(clippy::too_many_arguments)]
2606pub fn compute_blame(
2607    odb: &Odb,
2608    start_oid: ObjectId,
2609    file_path: &str,
2610    ignore_revs: &HashSet<ObjectId>,
2611    diff_algorithm: BlameDiffAlgorithm,
2612    textconv_ctx: Option<&BlameTextconvContext>,
2613    use_textconv: bool,
2614    copy_depth: usize,
2615    first_parent_only: bool,
2616    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
2617    indent_heuristic: bool,
2618    line_range: Option<RangeInclusive<usize>>,
2619    whitespace: crate::diff::WhitespaceMode,
2620    thread_policy: &ThreadPolicy,
2621) -> Result<Vec<BlameLine>> {
2622    let bounded_odb = line_range.as_ref().map(|_| odb.with_bounded_pack_cache());
2623    let odb = bounded_odb.as_ref().unwrap_or(odb);
2624    let mut session = BlameSession::new(odb, textconv_ctx, use_textconv, thread_policy);
2625    session.whitespace = whitespace;
2626    session.cache_pair_maps = !ignore_revs.is_empty() || copy_depth > 0;
2627    let revision_label = start_oid.to_hex();
2628    let run = || {
2629        compute_blame_with_session(
2630            odb,
2631            start_oid,
2632            &revision_label,
2633            file_path,
2634            ignore_revs,
2635            diff_algorithm,
2636            textconv_ctx,
2637            use_textconv,
2638            copy_depth,
2639            first_parent_only,
2640            grafts,
2641            indent_heuristic,
2642            line_range,
2643            None,
2644            &mut session,
2645        )
2646    };
2647    if bounded_odb.is_some() {
2648        odb.with_pack_read_context(run)
2649    } else {
2650        run()
2651    }
2652}
2653
2654fn blame_path_missing_in_revision(path: &str, revision: &str) -> LibError {
2655    crate::revwalk::rev_parse_error::RevParseError::PathNotInRevision {
2656        path: path.to_owned(),
2657        revision: revision.to_owned(),
2658    }
2659    .into()
2660}
2661
2662/// Advance `start_oid` along first parents while `path` resolves to `blob_oid`.
2663#[allow(clippy::too_many_arguments)]
2664fn fast_forward_same_blob(
2665    odb: &Odb,
2666    session: &mut BlameSession<'_>,
2667    mut oid: ObjectId,
2668    blob_oid: ObjectId,
2669    blob_mode: u32,
2670    path: &str,
2671    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
2672    commit_cache: &mut BlameCommitCache,
2673) -> Result<ObjectId> {
2674    session.set_bloom_path(path);
2675    loop {
2676        if grafts.contains_key(&oid) {
2677            break;
2678        }
2679        let parent_oid = if let Some(chain) = session.commit_graph.as_deref() {
2680            if let Some((parents, _)) = chain.graph_commit(&oid) {
2681                parents.first().copied()
2682            } else {
2683                commit_parents_for_blame(odb, oid, grafts, commit_cache, session)?
2684                    .first()
2685                    .copied()
2686            }
2687        } else {
2688            commit_parents_for_blame(odb, oid, grafts, commit_cache, session)?
2689                .first()
2690                .copied()
2691        };
2692        let Some(parent_oid) = parent_oid else {
2693            break;
2694        };
2695        if session.commit_graph.is_some()
2696            && bloom_may_skip_to_first_parent(
2697                session,
2698                odb,
2699                oid,
2700                parent_oid,
2701                path,
2702                grafts,
2703                commit_cache,
2704                true,
2705            )?
2706        {
2707            session
2708                .commit_path_blob
2709                .insert((parent_oid, path.to_owned()), Some((blob_oid, blob_mode)));
2710            oid = parent_oid;
2711            continue;
2712        }
2713        let parent_tree = if let Some(chain) = session.commit_graph.as_deref() {
2714            if let Some(tree) = chain.graph_commit_tree(&parent_oid) {
2715                tree
2716            } else {
2717                get_blame_commit(odb, parent_oid, commit_cache, Some(chain))?.tree
2718            }
2719        } else {
2720            get_blame_commit(odb, parent_oid, commit_cache, None)?.tree
2721        };
2722        let Some((p_blob, _mode)) = session.resolve_path_in_tree(parent_tree, path)? else {
2723            break;
2724        };
2725        if p_blob != blob_oid {
2726            break;
2727        }
2728        oid = parent_oid;
2729    }
2730    Ok(oid)
2731}
2732
2733#[expect(clippy::too_many_arguments)]
2734fn compute_blame_with_session(
2735    odb: &Odb,
2736    start_oid: ObjectId,
2737    revision_label: &str,
2738    file_path: &str,
2739    ignore_revs: &HashSet<ObjectId>,
2740    diff_algorithm: BlameDiffAlgorithm,
2741    textconv_ctx: Option<&BlameTextconvContext>,
2742    use_textconv: bool,
2743    copy_depth: usize,
2744    first_parent_only: bool,
2745    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
2746    indent_heuristic: bool,
2747    line_range: Option<RangeInclusive<usize>>,
2748    overlay: Option<&BlameOverlay>,
2749    session: &mut BlameSession<'_>,
2750) -> Result<Vec<BlameLine>> {
2751    let blamed_path = file_path.to_owned();
2752    let mut commit_cache = BlameCommitCache::default();
2753    let (start_lines, blob_oid, blob_mode) = match overlay {
2754        Some(overlay) => (
2755            Arc::clone(&overlay.lines),
2756            ObjectId::null(odb.hash_algo()),
2757            0,
2758        ),
2759        None => {
2760            let start_commit = get_blame_commit(
2761                odb,
2762                start_oid,
2763                &mut commit_cache,
2764                session.commit_graph.as_deref(),
2765            )?;
2766            let (blob_oid, blob_mode) = session
2767                .resolve_path_in_tree(start_commit.tree, file_path)?
2768                .ok_or_else(|| blame_path_missing_in_revision(file_path, revision_label))?;
2769            let lines = session.load_blob_lines(blob_oid, file_path, blob_mode)?;
2770            (lines, blob_oid, blob_mode)
2771        }
2772    };
2773    let num_lines = start_lines.len();
2774
2775    if num_lines == 0 {
2776        return Ok(Vec::new());
2777    }
2778
2779    // Lines still needing attribution
2780    let mut pending: Vec<TrackedLine> = match &line_range {
2781        Some(range) => range
2782            .clone()
2783            .filter_map(|lineno| {
2784                if lineno == 0 || lineno > num_lines {
2785                    return None;
2786                }
2787                Some(TrackedLine {
2788                    final_lineno: lineno,
2789                    current_idx: lineno - 1,
2790                    ignored: false,
2791                    source_path: None,
2792                    origin: 0,
2793                })
2794            })
2795            .collect(),
2796        None => (0..num_lines)
2797            .map(|i| TrackedLine {
2798                final_lineno: i + 1,
2799                current_idx: i,
2800                ignored: false,
2801                source_path: None,
2802                origin: 0,
2803            })
2804            .collect(),
2805    };
2806
2807    if pending.is_empty() {
2808        return Ok(Vec::new());
2809    }
2810
2811    let mut result: Vec<BlameLine> = Vec::with_capacity(num_lines);
2812    let final_text: &BlobLines = &start_lines;
2813
2814    let mut current_oid = start_oid;
2815    let mut current_blob_oid = blob_oid;
2816    let mut current_blob_mode = blob_mode;
2817    let mut current_path = file_path.to_string();
2818    let mut queue = SuspectQueue::default();
2819    let mut next_origin: u32 = 0;
2820    let merge_aware = !first_parent_only;
2821
2822    session.set_bloom_path(&current_path);
2823    let mut walk_lines: Option<(ObjectId, Arc<BlobLines>)> =
2824        Some((current_blob_oid, Arc::clone(&start_lines)));
2825
2826    if let Some(overlay) = overlay {
2827        let walk = OverlayWalk {
2828            path: file_path,
2829            ignore_revs,
2830            diff_algorithm,
2831            textconv_ctx,
2832            use_textconv,
2833            copy_depth,
2834            first_parent_only,
2835            grafts,
2836            indent_heuristic,
2837        };
2838        let first = pass_overlay_to_parents(
2839            odb,
2840            session,
2841            overlay,
2842            &walk,
2843            std::mem::take(&mut pending),
2844            final_text,
2845            &mut commit_cache,
2846            &mut queue,
2847            &mut next_origin,
2848            &mut result,
2849        )?;
2850        walk_lines = None;
2851        if let Some(first) = first {
2852            current_oid = first.oid;
2853            current_blob_oid = first.blob;
2854            current_blob_mode = first.mode;
2855            walk_lines = first.lines.map(|lines| (first.blob, lines));
2856            pending = first.pending;
2857        }
2858    }
2859
2860    // Each iteration examines the suspect described by `current_*` and `pending`. Moving
2861    // to a parent rewrites those in place; while other suspects are queued, the moved
2862    // suspect goes back through the queue so commits are visited newest first and shared
2863    // ancestors are reached once.
2864    'blame_loop: loop {
2865        if pending.is_empty() || !queue.is_empty() {
2866            if !pending.is_empty() {
2867                let lines = walk_lines
2868                    .take()
2869                    .and_then(|(oid, lines)| (oid == current_blob_oid).then_some(lines));
2870                let oid = current_oid;
2871                queue.push(
2872                    BlameSuspect {
2873                        oid,
2874                        blob: current_blob_oid,
2875                        mode: current_blob_mode,
2876                        path: std::mem::take(&mut current_path),
2877                        lines,
2878                        pending: std::mem::take(&mut pending),
2879                    },
2880                    || blame_suspect_date(session, &oid),
2881                );
2882            }
2883            let Some(next) = queue.pop() else {
2884                break;
2885            };
2886            current_oid = next.oid;
2887            current_blob_oid = next.blob;
2888            current_blob_mode = next.mode;
2889            current_path = next.path;
2890            walk_lines = next.lines.map(|lines| (next.blob, lines));
2891            pending = next.pending;
2892            session.set_bloom_path(&current_path);
2893        }
2894        session.prune_blob_lines();
2895
2896        if !ignore_revs.contains(&current_oid) {
2897            let (new_oid, new_mode) = advance_across_unchanged_first_parents(
2898                session,
2899                odb,
2900                current_oid,
2901                current_blob_oid,
2902                current_blob_mode,
2903                &current_path,
2904                grafts,
2905                &mut commit_cache,
2906            )?;
2907            if new_oid != current_oid {
2908                current_oid = new_oid;
2909                current_blob_mode = new_mode;
2910                continue 'blame_loop;
2911            }
2912        }
2913
2914        let commit = get_blame_commit(
2915            odb,
2916            current_oid,
2917            &mut commit_cache,
2918            session.commit_graph.as_deref(),
2919        )?;
2920        let mut parents =
2921            commit_parents_for_blame(odb, current_oid, grafts, &mut commit_cache, session)?;
2922        let is_ignored = ignore_revs.contains(&current_oid);
2923
2924        if first_parent_only && parents.len() > 1 {
2925            parents.truncate(1);
2926        }
2927
2928        // git/blame.c `pass_whole_blame`: run before loading every parent tree for coalesce.
2929        if merge_aware && !is_ignored && parents.len() >= 2 {
2930            if let Some(first_parent) = parents.first().copied() {
2931                if session.commit_graph.is_some()
2932                    && bloom_may_skip_to_first_parent(
2933                        session,
2934                        odb,
2935                        current_oid,
2936                        first_parent,
2937                        &current_path,
2938                        grafts,
2939                        &mut commit_cache,
2940                        true,
2941                    )?
2942                {
2943                    session.commit_path_blob.insert(
2944                        (first_parent, current_path.clone()),
2945                        Some((current_blob_oid, current_blob_mode)),
2946                    );
2947                    current_oid = fast_forward_same_blob(
2948                        odb,
2949                        session,
2950                        first_parent,
2951                        current_blob_oid,
2952                        current_blob_mode,
2953                        &current_path,
2954                        grafts,
2955                        &mut commit_cache,
2956                    )?;
2957                    continue 'blame_loop;
2958                }
2959            }
2960            for parent_oid in &parents {
2961                if let Some((blob, mode)) = session.resolve_blob_at_commit(
2962                    odb,
2963                    *parent_oid,
2964                    &current_path,
2965                    &mut commit_cache,
2966                )? {
2967                    if blob == current_blob_oid {
2968                        current_oid = fast_forward_same_blob(
2969                            odb,
2970                            session,
2971                            *parent_oid,
2972                            current_blob_oid,
2973                            current_blob_mode,
2974                            &current_path,
2975                            grafts,
2976                            &mut commit_cache,
2977                        )?;
2978                        current_blob_mode = mode;
2979                        continue 'blame_loop;
2980                    }
2981                }
2982            }
2983        }
2984
2985        if merge_aware && !ignore_revs.contains(&current_oid) && parents.len() >= 2 {
2986            if let Some((first_blob, _)) =
2987                session.resolve_blob_at_commit(odb, parents[0], &current_path, &mut commit_cache)?
2988            {
2989                let mut all_same = true;
2990                for parent_oid in parents.iter().skip(1) {
2991                    match session.resolve_blob_at_commit(
2992                        odb,
2993                        *parent_oid,
2994                        &current_path,
2995                        &mut commit_cache,
2996                    )? {
2997                        Some((blob, _)) if blob == first_blob => {}
2998                        _ => {
2999                            all_same = false;
3000                            break;
3001                        }
3002                    }
3003                }
3004                if all_same {
3005                    parents = vec![parents[0]];
3006                }
3007            }
3008        }
3009
3010        // If an ignored merge commit is encountered, try to continue blame
3011        // through the parent that actually contributed each line.
3012        if is_ignored && parents.len() > 1 {
3013            let cur_lines_arc =
3014                session.load_blob_lines(current_blob_oid, &current_path, current_blob_mode)?;
3015            let cur_lines = BlameSession::line_refs(cur_lines_arc.as_ref());
3016
3017            let mut parent_lines: Vec<Option<Vec<String>>> = Vec::new();
3018            let mut parent_blames: Vec<Option<Vec<BlameLine>>> = Vec::new();
3019            for parent_oid in &parents {
3020                let parent_commit = get_blame_commit(
3021                    odb,
3022                    *parent_oid,
3023                    &mut commit_cache,
3024                    session.commit_graph.as_deref(),
3025                )?;
3026                if let Some((p_blob_oid, p_blob_mode)) =
3027                    session.resolve_path_in_tree(parent_commit.tree, &current_path)?
3028                {
3029                    let p_lines_arc =
3030                        session.load_blob_lines(p_blob_oid, &current_path, p_blob_mode)?;
3031                    let p_lines = p_lines_arc.to_vec();
3032                    let parent_revision = parent_oid.to_hex();
3033                    let p_blame = compute_blame_with_session(
3034                        odb,
3035                        *parent_oid,
3036                        &parent_revision,
3037                        &current_path,
3038                        ignore_revs,
3039                        diff_algorithm,
3040                        textconv_ctx,
3041                        use_textconv,
3042                        copy_depth,
3043                        first_parent_only,
3044                        grafts,
3045                        indent_heuristic,
3046                        None,
3047                        None,
3048                        session,
3049                    )?;
3050                    parent_lines.push(Some(p_lines));
3051                    parent_blames.push(Some(p_blame));
3052                } else {
3053                    parent_lines.push(None);
3054                    parent_blames.push(None);
3055                }
3056            }
3057
3058            for t in pending.drain(..) {
3059                let idx = t.current_idx;
3060                let Some(cur_line) = cur_lines.get(idx).copied() else {
3061                    result.push(blame_line_at_path(
3062                        current_oid,
3063                        t.final_lineno,
3064                        idx + 1,
3065                        final_line_display(final_text, t.final_lineno),
3066                        &current_path,
3067                        None,
3068                        t.ignored,
3069                        true,
3070                    ));
3071                    continue;
3072                };
3073
3074                let mut picked: Option<BlameLine> = None;
3075                for i in 0..parents.len() {
3076                    let Some(lines) = parent_lines.get(i).and_then(|v| v.as_ref()) else {
3077                        continue;
3078                    };
3079                    if idx >= lines.len() || lines[idx] != cur_line {
3080                        continue;
3081                    }
3082                    let Some(blames) = parent_blames.get(i).and_then(|v| v.as_ref()) else {
3083                        continue;
3084                    };
3085                    if let Some(line_blame) = blames.iter().find(|b| b.final_lineno == idx + 1) {
3086                        picked = Some(line_blame.clone());
3087                        break;
3088                    }
3089                }
3090
3091                if let Some(pb) = picked {
3092                    result.push(blame_line_from_parent(
3093                        &pb,
3094                        t.final_lineno,
3095                        final_line_display(final_text, t.final_lineno),
3096                        pb.source_file.clone(),
3097                        true,
3098                        pb.unblamable,
3099                    ));
3100                } else {
3101                    result.push(blame_line_at_path(
3102                        current_oid,
3103                        t.final_lineno,
3104                        idx + 1,
3105                        final_line_display(final_text, t.final_lineno),
3106                        &current_path,
3107                        None,
3108                        t.ignored,
3109                        true,
3110                    ));
3111                }
3112            }
3113            continue 'blame_loop;
3114        }
3115
3116        if parents.is_empty() {
3117            // Root commit — attribute all remaining lines
3118            for t in pending.drain(..) {
3119                result.push(blame_line_at_path(
3120                    current_oid,
3121                    t.final_lineno,
3122                    t.current_idx + 1,
3123                    final_line_display(final_text, t.final_lineno),
3124                    &blamed_path,
3125                    t.source_path.clone(),
3126                    t.ignored,
3127                    false,
3128                ));
3129            }
3130            continue 'blame_loop;
3131        }
3132
3133        // Merge commits (2+ parents): sequential `pass_blame_to_parent` order from git/blame.c —
3134        // each line is attributed to the first parent whose version matches after line mapping.
3135        // Octopus merges and `.git/info/grafts` with many synthetic parents use the same rule.
3136
3137        if merge_aware && !is_ignored && parents.len() >= 2 {
3138            let cur_lines_arc = match walk_lines.take() {
3139                Some((oid, lines)) if oid == current_blob_oid => lines,
3140                _ => session.load_blob_lines(current_blob_oid, &current_path, current_blob_mode)?,
3141            };
3142            let line_count = cur_lines_arc.len();
3143
3144            let mut parent_blobs: Vec<Option<(ObjectId, ObjectId, u32, String)>> =
3145                vec![None; parents.len()];
3146            let mut par_lines_vec: Vec<Option<Arc<BlobLines>>> = vec![None; parents.len()];
3147            let mut maps: Vec<Option<Arc<Vec<Option<usize>>>>> = vec![None; parents.len()];
3148
3149            let mut buckets: Vec<Vec<TrackedLine>> = vec![Vec::new(); parents.len()];
3150            let mut attributed: Vec<BlameLine> = Vec::new();
3151
3152            if session.merge_line_stamps.len() < parents.len() {
3153                session
3154                    .merge_line_stamps
3155                    .resize_with(parents.len(), LineUseStamp::default);
3156            }
3157
3158            let mut remaining = std::mem::take(&mut pending);
3159            // Stamps dedupe parent lines within one hand-off; grouping lets each group
3160            // start a fresh stamp generation.
3161            remaining.sort_by_key(|t| t.origin);
3162            let mut any_parent_has_path = false;
3163            for i in 0..parents.len() {
3164                if remaining.is_empty() {
3165                    break;
3166                }
3167                let p = parents[i];
3168                let parent_commit =
3169                    get_blame_commit(odb, p, &mut commit_cache, session.commit_graph.as_deref())?;
3170                let mut parent_path = current_path.clone();
3171                let mut resolved =
3172                    session.resolve_blob_at_commit(odb, p, &current_path, &mut commit_cache)?;
3173                if resolved.is_none() {
3174                    if let Some((renamed_path, renamed_mode)) = find_path_by_oid_in_tree(
3175                        odb,
3176                        &parent_commit.tree,
3177                        &commit.tree,
3178                        &current_blob_oid,
3179                        &current_path,
3180                    )? {
3181                        parent_path = renamed_path;
3182                        resolved = session.resolve_blob_at_commit(
3183                            odb,
3184                            p,
3185                            &parent_path,
3186                            &mut commit_cache,
3187                        )?;
3188                        if resolved.is_none() {
3189                            resolved = Some((current_blob_oid, renamed_mode));
3190                        }
3191                    } else if let Some(entry) = find_rename_entry_for_path(
3192                        odb,
3193                        &parent_commit.tree,
3194                        &commit.tree,
3195                        &current_path,
3196                        BLAME_RENAME_SIMILARITY,
3197                    )? {
3198                        parent_path = entry.old_path.clone().unwrap_or_default();
3199                        let p_blob_oid = entry.old_oid;
3200                        let p_blob_mode = entry.old_mode.bits();
3201                        resolved = Some((p_blob_oid, p_blob_mode));
3202                    }
3203                }
3204                let Some((blob, mode)) = resolved else {
3205                    continue;
3206                };
3207                let renamed_parent = parent_path != current_path;
3208                any_parent_has_path = true;
3209                parent_blobs[i] = Some((p, blob, mode, parent_path.clone()));
3210                if blob == current_blob_oid {
3211                    par_lines_vec[i] = Some(Arc::clone(&cur_lines_arc));
3212                    maps[i] = Some(session.identity_line_map(line_count));
3213                    let mut next_remaining: Vec<TrackedLine> = Vec::new();
3214                    let mut pass_origin = None;
3215                    for t in remaining {
3216                        if pass_origin != Some(t.origin) {
3217                            pass_origin = Some(t.origin);
3218                            session.merge_line_stamps[i].begin_pass(line_count);
3219                        }
3220                        if t.current_idx < line_count
3221                            && session.merge_line_stamps[i].is_unused(t.current_idx)
3222                        {
3223                            session.merge_line_stamps[i].mark(t.current_idx);
3224                            let source_path = if renamed_parent {
3225                                Some(parent_path.clone())
3226                            } else {
3227                                t.source_path.clone()
3228                            };
3229                            buckets[i].push(TrackedLine {
3230                                final_lineno: t.final_lineno,
3231                                current_idx: t.current_idx,
3232                                ignored: t.ignored,
3233                                source_path,
3234                                origin: t.origin,
3235                            });
3236                        } else {
3237                            next_remaining.push(t);
3238                        }
3239                    }
3240                    remaining = next_remaining;
3241                    continue;
3242                }
3243                let pl_arc = session.load_blob_lines(blob, &parent_path, mode)?;
3244                let map_algo = diff_algorithm;
3245                let pair = session.parent_child_line_maps(
3246                    blob,
3247                    &parent_path,
3248                    mode,
3249                    current_blob_oid,
3250                    &current_path,
3251                    current_blob_mode,
3252                    pl_arc.as_ref(),
3253                    cur_lines_arc.as_ref(),
3254                    slide_safe_affix(pl_arc.as_ref(), cur_lines_arc.as_ref(), session.whitespace),
3255                    map_algo,
3256                    indent_heuristic,
3257                    false,
3258                    true,
3259                );
3260                par_lines_vec[i] = Some(pl_arc);
3261                maps[i] = Some(Arc::clone(&pair.diff_line_map));
3262
3263                let mut next_remaining: Vec<TrackedLine> = Vec::new();
3264                let Some(map) = maps[i].as_ref() else {
3265                    return Err(LibError::Message(
3266                        "internal: missing merge parent line map".to_string(),
3267                    ));
3268                };
3269                let Some(par_lines) = par_lines_vec[i].as_ref() else {
3270                    return Err(LibError::Message(
3271                        "internal: missing merge parent lines".to_string(),
3272                    ));
3273                };
3274                let parent_line_count = par_lines.len();
3275                let mut pass_origin = None;
3276                for t in remaining {
3277                    if pass_origin != Some(t.origin) {
3278                        pass_origin = Some(t.origin);
3279                        session.merge_line_stamps[i].begin_pass(parent_line_count);
3280                    }
3281                    let idx = t.current_idx;
3282                    let cur_line = cur_lines_arc.comparison_line(idx, session.whitespace);
3283                    let m = map.get(idx).copied().flatten();
3284                    if let Some(p_idx) = m {
3285                        let text_ok = blame_lines_equal(
3286                            par_lines.comparison_line(p_idx, session.whitespace),
3287                            cur_line,
3288                            session.whitespace,
3289                        );
3290                        if text_ok
3291                            && p_idx < parent_line_count
3292                            && session.merge_line_stamps[i].is_unused(p_idx)
3293                        {
3294                            session.merge_line_stamps[i].mark(p_idx);
3295                            let source_path = if renamed_parent {
3296                                Some(parent_path.clone())
3297                            } else {
3298                                t.source_path.clone()
3299                            };
3300                            buckets[i].push(TrackedLine {
3301                                final_lineno: t.final_lineno,
3302                                current_idx: p_idx,
3303                                ignored: t.ignored,
3304                                source_path,
3305                                origin: t.origin,
3306                            });
3307                        } else {
3308                            next_remaining.push(t);
3309                        }
3310                    } else {
3311                        next_remaining.push(t);
3312                    }
3313                }
3314                remaining = next_remaining;
3315            }
3316
3317            if any_parent_has_path {
3318                for t in remaining {
3319                    let idx = t.current_idx;
3320                    attributed.push(blame_line_at_path(
3321                        current_oid,
3322                        t.final_lineno,
3323                        idx + 1,
3324                        final_line_display(final_text, t.final_lineno),
3325                        &current_path,
3326                        None,
3327                        t.ignored,
3328                        false,
3329                    ));
3330                }
3331
3332                for bl in attributed {
3333                    result.push(bl);
3334                }
3335
3336                for i in 1..parents.len() {
3337                    if buckets[i].is_empty() {
3338                        continue;
3339                    }
3340                    let Some((p, blob, mode, parent_path)) = parent_blobs[i].clone() else {
3341                        continue;
3342                    };
3343                    next_origin += 1;
3344                    let mut handed_off = std::mem::take(&mut buckets[i]);
3345                    for t in &mut handed_off {
3346                        t.origin = next_origin;
3347                    }
3348                    queue.push(
3349                        BlameSuspect {
3350                            oid: p,
3351                            blob,
3352                            mode,
3353                            path: parent_path,
3354                            lines: par_lines_vec[i].take(),
3355                            pending: handed_off,
3356                        },
3357                        || blame_suspect_date(session, &p),
3358                    );
3359                }
3360
3361                if !buckets[0].is_empty() {
3362                    let Some((p, blob, mode, parent_path)) = parent_blobs[0].clone() else {
3363                        continue 'blame_loop;
3364                    };
3365                    if parent_path != current_path {
3366                        note_blame_source_path(&mut buckets[0], &parent_path);
3367                        current_path = parent_path;
3368                        session.set_bloom_path(&current_path);
3369                    }
3370                    current_oid = p;
3371                    current_blob_oid = blob;
3372                    current_blob_mode = mode;
3373                    walk_lines = par_lines_vec[0].take().map(|lines| (blob, lines));
3374                    pending = std::mem::take(&mut buckets[0]);
3375                }
3376                continue;
3377            }
3378            pending = remaining;
3379        }
3380
3381        let parent_oid = parents[0];
3382        let parent_commit = get_blame_commit(
3383            odb,
3384            parent_oid,
3385            &mut commit_cache,
3386            session.commit_graph.as_deref(),
3387        )?;
3388        let mut parent_blob_entry =
3389            session.resolve_blob_at_commit(odb, parent_oid, &current_path, &mut commit_cache)?;
3390        let mut parent_blob_path = current_path.clone();
3391
3392        if parent_blob_entry.is_none() && !is_ignored {
3393            if let Some((renamed_path, renamed_mode)) = find_path_by_oid_in_tree(
3394                odb,
3395                &parent_commit.tree,
3396                &commit.tree,
3397                &current_blob_oid,
3398                &current_path,
3399            )? {
3400                note_blame_source_path(&mut pending, &renamed_path);
3401                current_path = renamed_path;
3402                session.set_bloom_path(&current_path);
3403                current_oid = parent_oid;
3404                current_blob_mode = renamed_mode;
3405                continue;
3406            }
3407            if let Some(entry) = find_rename_entry_for_path(
3408                odb,
3409                &parent_commit.tree,
3410                &commit.tree,
3411                &current_path,
3412                BLAME_RENAME_SIMILARITY,
3413            )? {
3414                let old_path = entry.old_path.clone().unwrap_or_default();
3415                let p_blob_oid = entry.old_oid;
3416                let p_blob_mode = entry.old_mode.bits();
3417                if p_blob_oid == current_blob_oid {
3418                    note_blame_source_path(&mut pending, &old_path);
3419                    current_path = old_path;
3420                    session.set_bloom_path(&current_path);
3421                    current_oid = parent_oid;
3422                    current_blob_mode = p_blob_mode;
3423                    continue;
3424                }
3425                parent_blob_entry = Some((p_blob_oid, p_blob_mode));
3426                parent_blob_path = old_path;
3427            }
3428        }
3429
3430        match parent_blob_entry {
3431            None if !is_ignored => {
3432                if copy_depth >= 1 {
3433                    let cur_lines_arc = session.load_blob_lines(
3434                        current_blob_oid,
3435                        &current_path,
3436                        current_blob_mode,
3437                    )?;
3438                    let pending_batch = std::mem::take(&mut pending);
3439                    let (remaining, _handoff, copy_lines) = copy_detect_attribute_pending(
3440                        odb,
3441                        session,
3442                        pending_batch,
3443                        &cur_lines_arc,
3444                        final_text,
3445                        &blamed_path,
3446                        parent_oid,
3447                        &parent_commit.tree,
3448                        &commit.tree,
3449                        &current_path,
3450                        copy_depth,
3451                        true,
3452                        None,
3453                        None,
3454                        ignore_revs,
3455                        diff_algorithm,
3456                        textconv_ctx,
3457                        use_textconv,
3458                        first_parent_only,
3459                        grafts,
3460                        indent_heuristic,
3461                    )?;
3462                    result.extend(copy_lines);
3463                    for t in remaining {
3464                        result.push(blame_line_at_path(
3465                            current_oid,
3466                            t.final_lineno,
3467                            t.current_idx + 1,
3468                            final_line_display(final_text, t.final_lineno),
3469                            &blamed_path,
3470                            t.source_path.clone(),
3471                            t.ignored,
3472                            false,
3473                        ));
3474                    }
3475                    continue 'blame_loop;
3476                }
3477
3478                // No rename/copy source found — attribute to current commit.
3479                for t in pending.drain(..) {
3480                    result.push(blame_line_at_path(
3481                        current_oid,
3482                        t.final_lineno,
3483                        t.current_idx + 1,
3484                        final_line_display(final_text, t.final_lineno),
3485                        &blamed_path,
3486                        t.source_path.clone(),
3487                        t.ignored,
3488                        false,
3489                    ));
3490                }
3491                continue 'blame_loop;
3492            }
3493            None => {
3494                // Ignored commit but file doesn't exist in parent.
3495                // Attribute to current anyway (can't go further back).
3496                for t in pending.drain(..) {
3497                    result.push(blame_line_at_path(
3498                        current_oid,
3499                        t.final_lineno,
3500                        t.current_idx + 1,
3501                        final_line_display(final_text, t.final_lineno),
3502                        &current_path,
3503                        None,
3504                        t.ignored,
3505                        true,
3506                    ));
3507                }
3508                continue 'blame_loop;
3509            }
3510            Some((p_blob_oid, p_blob_mode)) if p_blob_oid == current_blob_oid => {
3511                current_oid = fast_forward_same_blob(
3512                    odb,
3513                    session,
3514                    parent_oid,
3515                    current_blob_oid,
3516                    p_blob_mode,
3517                    &current_path,
3518                    grafts,
3519                    &mut commit_cache,
3520                )?;
3521                current_blob_mode = p_blob_mode;
3522                continue;
3523            }
3524            Some((p_blob_oid, p_blob_mode)) => {
3525                // Diff current vs parent
3526                let cur_lines_arc = match walk_lines.take() {
3527                    Some((oid, lines)) if oid == current_blob_oid => lines,
3528                    _ => session.load_blob_lines(
3529                        current_blob_oid,
3530                        &current_path,
3531                        current_blob_mode,
3532                    )?,
3533                };
3534                let par_lines_arc =
3535                    session.load_blob_lines(p_blob_oid, &parent_blob_path, p_blob_mode)?;
3536                let pending_batch = std::mem::take(&mut pending);
3537                let affix = slide_safe_affix(
3538                    par_lines_arc.as_ref(),
3539                    cur_lines_arc.as_ref(),
3540                    session.whitespace,
3541                );
3542                let (affix_mapped, need_diff) = partition_pending_by_affix(
3543                    affix,
3544                    par_lines_arc.len(),
3545                    cur_lines_arc.len(),
3546                    pending_batch,
3547                );
3548                // Only use affix partition when it avoids work: affix-only skip, or mixed
3549                // affix + middle. When every pending line sits in the edited middle, fall back
3550                // to the pre-round-3 path (full batch through parent_child_line_maps).
3551                if need_diff.is_empty() {
3552                    pending = affix_mapped;
3553                    current_oid = parent_oid;
3554                    current_blob_oid = p_blob_oid;
3555                    current_blob_mode = p_blob_mode;
3556                    walk_lines = Some((p_blob_oid, Arc::clone(&par_lines_arc)));
3557                    if parent_blob_path != current_path {
3558                        note_blame_source_path(&mut pending, &parent_blob_path);
3559                        current_path = parent_blob_path;
3560                        session.set_bloom_path(&current_path);
3561                    }
3562                    continue 'blame_loop;
3563                }
3564                let mut still_pending = affix_mapped;
3565                pending = need_diff;
3566                let maps = session.parent_child_line_maps(
3567                    p_blob_oid,
3568                    &parent_blob_path,
3569                    p_blob_mode,
3570                    current_blob_oid,
3571                    &current_path,
3572                    current_blob_mode,
3573                    par_lines_arc.as_ref(),
3574                    cur_lines_arc.as_ref(),
3575                    affix,
3576                    diff_algorithm,
3577                    indent_heuristic,
3578                    is_ignored,
3579                    true,
3580                );
3581                let diff_line_map = maps.diff_line_map.as_slice();
3582                let line_map = maps.line_map.as_slice();
3583                let stop_blame_on_current = maps.stop_blame_on_current.as_slice();
3584                let inserted_line_duplicates_parent = pending.iter().any(|t| {
3585                    t.current_idx < cur_lines_arc.len()
3586                        && (0..par_lines_arc.len()).any(|i| {
3587                            blame_lines_equal(
3588                                cur_lines_arc.comparison_line(t.current_idx, session.whitespace),
3589                                par_lines_arc.comparison_line(i, session.whitespace),
3590                                session.whitespace,
3591                            )
3592                        })
3593                });
3594                let mut introduced_for_copy = Vec::new();
3595
3596                for t in pending.drain(..) {
3597                    if t.current_idx < line_map.len() {
3598                        if diff_line_map.get(t.current_idx).and_then(|m| *m).is_none()
3599                            && !is_ignored
3600                        {
3601                            if copy_depth >= 1 && !inserted_line_duplicates_parent {
3602                                introduced_for_copy.push(t);
3603                            } else {
3604                                result.push(blame_line_at_path(
3605                                    current_oid,
3606                                    t.final_lineno,
3607                                    t.current_idx + 1,
3608                                    final_line_display(final_text, t.final_lineno),
3609                                    &current_path,
3610                                    None,
3611                                    t.ignored,
3612                                    false,
3613                                ));
3614                            }
3615                            continue;
3616                        }
3617                        if let Some(parent_idx) = line_map[t.current_idx] {
3618                            // Line came from parent — keep tracking
3619                            let carried_ignored = if is_ignored {
3620                                let unchanged = parent_idx == t.current_idx
3621                                    && par_lines_arc.line(parent_idx)
3622                                        == cur_lines_arc.line(t.current_idx);
3623                                if unchanged {
3624                                    t.ignored
3625                                } else {
3626                                    true
3627                                }
3628                            } else {
3629                                t.ignored
3630                            };
3631                            if stop_blame_on_current
3632                                .get(t.current_idx)
3633                                .copied()
3634                                .unwrap_or(false)
3635                                && !is_ignored
3636                            {
3637                                let duplicates_earlier_parent_line =
3638                                    (0..par_lines_arc.len()).any(|i| {
3639                                        i != t.current_idx
3640                                            && blame_lines_equal(
3641                                                cur_lines_arc.comparison_line(
3642                                                    t.current_idx,
3643                                                    session.whitespace,
3644                                                ),
3645                                                par_lines_arc
3646                                                    .comparison_line(i, session.whitespace),
3647                                                session.whitespace,
3648                                            )
3649                                    });
3650                                if duplicates_earlier_parent_line {
3651                                    still_pending.push(TrackedLine {
3652                                        final_lineno: t.final_lineno,
3653                                        current_idx: parent_idx,
3654                                        ignored: t.ignored,
3655                                        source_path: t.source_path.clone(),
3656                                        origin: t.origin,
3657                                    });
3658                                    continue;
3659                                }
3660                                result.push(blame_line_at_path(
3661                                    current_oid,
3662                                    t.final_lineno,
3663                                    t.current_idx + 1,
3664                                    final_line_display(final_text, t.final_lineno),
3665                                    &blamed_path,
3666                                    t.source_path.clone(),
3667                                    t.ignored,
3668                                    false,
3669                                ));
3670                                continue;
3671                            }
3672                            still_pending.push(TrackedLine {
3673                                final_lineno: t.final_lineno,
3674                                current_idx: parent_idx,
3675                                ignored: carried_ignored,
3676                                source_path: t.source_path.clone(),
3677                                origin: t.origin,
3678                            });
3679                        } else if is_ignored {
3680                            // Best-effort pass-through through ignored revisions:
3681                            // only keep walking when the same-slot parent line
3682                            // is text-identical; otherwise keep blame on the
3683                            // ignored commit and mark as unblamable.
3684                            if t.current_idx < par_lines_arc.len()
3685                                && cur_lines_arc.line(t.current_idx)
3686                                    == par_lines_arc.line(t.current_idx)
3687                            {
3688                                still_pending.push(TrackedLine {
3689                                    final_lineno: t.final_lineno,
3690                                    current_idx: t.current_idx,
3691                                    ignored: t.ignored,
3692                                    source_path: t.source_path.clone(),
3693                                    origin: t.origin,
3694                                });
3695                            } else {
3696                                result.push(blame_line_at_path(
3697                                    current_oid,
3698                                    t.final_lineno,
3699                                    t.current_idx + 1,
3700                                    final_line_display(final_text, t.final_lineno),
3701                                    &current_path,
3702                                    None,
3703                                    t.ignored,
3704                                    true,
3705                                ));
3706                            }
3707                        } else {
3708                            let line_text =
3709                                cur_lines_arc.comparison_line(t.current_idx, session.whitespace);
3710                            if let Some(parent_idx) = (0..par_lines_arc.len()).find(|&i| {
3711                                blame_lines_equal(
3712                                    par_lines_arc.comparison_line(i, session.whitespace),
3713                                    line_text,
3714                                    session.whitespace,
3715                                )
3716                            }) {
3717                                still_pending.push(TrackedLine {
3718                                    final_lineno: t.final_lineno,
3719                                    current_idx: parent_idx,
3720                                    ignored: t.ignored,
3721                                    source_path: t.source_path.clone(),
3722                                    origin: t.origin,
3723                                });
3724                                continue;
3725                            }
3726                            if copy_depth >= 1 && !inserted_line_duplicates_parent {
3727                                introduced_for_copy.push(t);
3728                            } else {
3729                                result.push(blame_line_at_path(
3730                                    current_oid,
3731                                    t.final_lineno,
3732                                    t.current_idx + 1,
3733                                    final_line_display(final_text, t.final_lineno),
3734                                    &blamed_path,
3735                                    t.source_path.clone(),
3736                                    t.ignored,
3737                                    false,
3738                                ));
3739                            }
3740                        }
3741                    } else if is_ignored {
3742                        result.push(blame_line_at_path(
3743                            current_oid,
3744                            t.final_lineno,
3745                            t.current_idx + 1,
3746                            final_line_display(final_text, t.final_lineno),
3747                            &current_path,
3748                            None,
3749                            t.ignored,
3750                            true,
3751                        ));
3752                    } else {
3753                        // Out of range — attribute to current
3754                        result.push(blame_line_at_path(
3755                            current_oid,
3756                            t.final_lineno,
3757                            t.current_idx + 1,
3758                            final_line_display(final_text, t.final_lineno),
3759                            &current_path,
3760                            None,
3761                            t.ignored,
3762                            false,
3763                        ));
3764                    }
3765                }
3766
3767                if copy_depth >= 1 && !introduced_for_copy.is_empty() {
3768                    let (left, handoff, copy_lines) = copy_detect_attribute_pending(
3769                        odb,
3770                        session,
3771                        introduced_for_copy,
3772                        &cur_lines_arc,
3773                        final_text,
3774                        &blamed_path,
3775                        parent_oid,
3776                        &parent_commit.tree,
3777                        &commit.tree,
3778                        &current_path,
3779                        copy_depth,
3780                        false,
3781                        None,
3782                        Some(&par_lines_arc),
3783                        ignore_revs,
3784                        diff_algorithm,
3785                        textconv_ctx,
3786                        use_textconv,
3787                        first_parent_only,
3788                        grafts,
3789                        indent_heuristic,
3790                    )?;
3791                    result.extend(copy_lines);
3792                    for t in left {
3793                        result.push(blame_line_at_path(
3794                            current_oid,
3795                            t.final_lineno,
3796                            t.current_idx + 1,
3797                            final_line_display(final_text, t.final_lineno),
3798                            &blamed_path,
3799                            t.source_path.clone(),
3800                            t.ignored,
3801                            false,
3802                        ));
3803                    }
3804                    still_pending.extend(handoff);
3805                }
3806
3807                pending = still_pending;
3808                current_oid = parent_oid;
3809                current_blob_oid = p_blob_oid;
3810                current_blob_mode = p_blob_mode;
3811                walk_lines = Some((p_blob_oid, Arc::clone(&par_lines_arc)));
3812                if parent_blob_path != current_path {
3813                    note_blame_source_path(&mut pending, &parent_blob_path);
3814                    current_path = parent_blob_path;
3815                    session.set_bloom_path(&current_path);
3816                }
3817            }
3818        }
3819    }
3820
3821    result.sort_by_key(|b| b.final_lineno);
3822    Ok(result)
3823}
3824
3825/// Git's default minimum line score for copy detection (`blame.h`).
3826const BLAME_DEFAULT_COPY_SCORE: usize = 40;
3827/// Git's default minimum chunk score for within-file move detection (`blame.h`).
3828const BLAME_DEFAULT_MOVE_SCORE: usize = 20;
3829
3830fn blame_copy_content_score(line: &str) -> usize {
3831    line.chars().filter(|c| c.is_ascii_alphanumeric()).count()
3832}
3833
3834/// Git `blame_entry_score`: one plus alphanumeric bytes across all lines in the chunk.
3835fn blame_entry_score_lines(lines: &[&str]) -> usize {
3836    1 + lines
3837        .iter()
3838        .map(|line| blame_copy_content_score(line))
3839        .sum::<usize>()
3840}
3841
3842/// Longest contiguous equal run between a suspect chunk and a candidate file.
3843#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3844struct ContiguousCopyMatch {
3845    suspect_start: usize,
3846    parent_start: usize,
3847    len: usize,
3848    score: usize,
3849}
3850
3851fn find_best_contiguous_copy_match(
3852    suspect: &[&str],
3853    parent: &[&str],
3854    ws: crate::diff::WhitespaceMode,
3855) -> Option<ContiguousCopyMatch> {
3856    let mut best: Option<ContiguousCopyMatch> = None;
3857    for s_start in 0..suspect.len() {
3858        for p_start in 0..parent.len() {
3859            let mut len = 0usize;
3860            while s_start + len < suspect.len()
3861                && p_start + len < parent.len()
3862                && blame_lines_equal(suspect[s_start + len], parent[p_start + len], ws)
3863            {
3864                len += 1;
3865            }
3866            if len == 0 {
3867                continue;
3868            }
3869            let score = blame_entry_score_lines(&suspect[s_start..s_start + len]);
3870            let replace = best.is_none_or(|b| score > b.score || (score == b.score && len > b.len));
3871            if replace {
3872                best = Some(ContiguousCopyMatch {
3873                    suspect_start: s_start,
3874                    parent_start: p_start,
3875                    len,
3876                    score,
3877                });
3878            }
3879        }
3880    }
3881    best
3882}
3883
3884/// Contiguous `current_idx` groups for copy/move suspect chunks.
3885fn group_contiguous_tracked(pending: &[TrackedLine]) -> Vec<(usize, usize)> {
3886    if pending.is_empty() {
3887        return Vec::new();
3888    }
3889    let mut order: Vec<usize> = (0..pending.len()).collect();
3890    order.sort_by_key(|&i| pending[i].current_idx);
3891    let mut groups = Vec::new();
3892    let mut start = pending[order[0]].current_idx;
3893    let mut end = start + 1;
3894    for &i in order.iter().skip(1) {
3895        let idx = pending[i].current_idx;
3896        if idx == end {
3897            end += 1;
3898        } else {
3899            groups.push((start, end));
3900            start = idx;
3901            end = idx + 1;
3902        }
3903    }
3904    groups.push((start, end));
3905    groups
3906}
3907
3908type CopySourceCache = HashMap<String, (Arc<BlobLines>, Vec<BlameLine>)>;
3909
3910#[expect(clippy::too_many_arguments)]
3911fn load_copy_source_blame(
3912    odb: &Odb,
3913    parent_oid: ObjectId,
3914    path: &str,
3915    oid: ObjectId,
3916    mode: u32,
3917    ignore_revs: &HashSet<ObjectId>,
3918    diff_algorithm: BlameDiffAlgorithm,
3919    textconv_ctx: Option<&BlameTextconvContext>,
3920    use_textconv: bool,
3921    copy_depth: usize,
3922    first_parent_only: bool,
3923    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
3924    indent_heuristic: bool,
3925    session: &mut BlameSession<'_>,
3926    cache: &mut CopySourceCache,
3927) -> Result<(Arc<BlobLines>, Vec<BlameLine>)> {
3928    if let Some(entry) = cache.get(path) {
3929        return Ok((Arc::clone(&entry.0), entry.1.clone()));
3930    }
3931    let source_lines = session.load_blob_lines(oid, path, mode)?;
3932    let parent_revision = parent_oid.to_hex();
3933    let source_blame = compute_blame_with_session(
3934        odb,
3935        parent_oid,
3936        &parent_revision,
3937        path,
3938        ignore_revs,
3939        diff_algorithm,
3940        textconv_ctx,
3941        use_textconv,
3942        copy_depth,
3943        first_parent_only,
3944        grafts,
3945        indent_heuristic,
3946        None,
3947        None,
3948        session,
3949    )?;
3950    cache.insert(
3951        path.to_owned(),
3952        (Arc::clone(&source_lines), source_blame.clone()),
3953    );
3954    Ok((source_lines, source_blame))
3955}
3956
3957/// Apply Git-style copy/move detection to still-unblamed lines.
3958#[expect(clippy::too_many_arguments)]
3959fn copy_detect_attribute_pending(
3960    odb: &Odb,
3961    session: &mut BlameSession<'_>,
3962    pending: Vec<TrackedLine>,
3963    cur_lines: &Arc<BlobLines>,
3964    final_text: &BlobLines,
3965    _blamed_path: &str,
3966    parent_oid: ObjectId,
3967    parent_tree: &ObjectId,
3968    child_tree: &ObjectId,
3969    current_path: &str,
3970    copy_depth: usize,
3971    blaming_new_file: bool,
3972    // When set, skip tree-based copy path rules and use this allow-list (`None` = all paths).
3973    explicit_allowed_paths: Option<Option<&HashSet<String>>>,
3974    move_parent_lines: Option<&Arc<BlobLines>>,
3975    ignore_revs: &HashSet<ObjectId>,
3976    diff_algorithm: BlameDiffAlgorithm,
3977    textconv_ctx: Option<&BlameTextconvContext>,
3978    use_textconv: bool,
3979    first_parent_only: bool,
3980    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
3981    indent_heuristic: bool,
3982) -> Result<(Vec<TrackedLine>, Vec<TrackedLine>, Vec<BlameLine>)> {
3983    if copy_depth == 0 || pending.is_empty() {
3984        return Ok((pending, Vec::new(), Vec::new()));
3985    }
3986    let ws = session.whitespace;
3987    let cur_refs = cur_lines.comparison_refs(ws);
3988    let allowed = match explicit_allowed_paths {
3989        Some(paths) => paths.cloned(),
3990        None => {
3991            copy_allowed_parent_paths(odb, parent_tree, child_tree, copy_depth, blaming_new_file)?
3992        }
3993    };
3994    let mut entries = Vec::new();
3995    collect_tree_file_entries(odb, parent_tree, "", &mut entries)?;
3996    let mut candidates: Vec<(String, ObjectId, u32)> = Vec::new();
3997    for (path, oid, mode) in entries {
3998        if path == current_path || !is_regular_mode(mode) {
3999            continue;
4000        }
4001        if !path_allowed_for_copy_search(&path, allowed.as_ref()) {
4002            continue;
4003        }
4004        candidates.push((path, oid, mode));
4005    }
4006    let mut source_cache: CopySourceCache = HashMap::new();
4007    let mut result = Vec::new();
4008    let mut parent_handoff = Vec::new();
4009    let mut work = pending;
4010    let copy_threshold = BLAME_DEFAULT_COPY_SCORE;
4011    let move_threshold = BLAME_DEFAULT_MOVE_SCORE;
4012
4013    if let Some(par_lines) = move_parent_lines {
4014        let par_refs = par_lines.comparison_refs(ws);
4015        let mut next_work = Vec::new();
4016        for group in group_contiguous_tracked(&work) {
4017            let slice: Vec<&str> = (group.0..group.1).map(|i| cur_refs[i]).collect();
4018            if blame_entry_score_lines(&slice) <= move_threshold {
4019                next_work.extend(
4020                    work.iter()
4021                        .filter(|t| t.current_idx >= group.0 && t.current_idx < group.1)
4022                        .cloned(),
4023                );
4024                continue;
4025            }
4026            let Some(m) = find_best_contiguous_copy_match(&slice, &par_refs, ws) else {
4027                next_work.extend(
4028                    work.iter()
4029                        .filter(|t| t.current_idx >= group.0 && t.current_idx < group.1)
4030                        .cloned(),
4031                );
4032                continue;
4033            };
4034            if m.score <= move_threshold {
4035                next_work.extend(
4036                    work.iter()
4037                        .filter(|t| t.current_idx >= group.0 && t.current_idx < group.1)
4038                        .cloned(),
4039                );
4040                continue;
4041            }
4042            for t in work
4043                .iter()
4044                .filter(|t| t.current_idx >= group.0 && t.current_idx < group.1)
4045            {
4046                let off = t.current_idx - group.0;
4047                if off < m.suspect_start || off >= m.suspect_start + m.len {
4048                    next_work.push(t.clone());
4049                    continue;
4050                }
4051                let parent_line = m.parent_start + (off - m.suspect_start);
4052                parent_handoff.push(TrackedLine {
4053                    current_idx: parent_line,
4054                    ..t.clone()
4055                });
4056            }
4057        }
4058        work = next_work;
4059    }
4060
4061    loop {
4062        let mut made_progress = false;
4063        let groups = group_contiguous_tracked(&work);
4064        let mut still_pending = Vec::new();
4065        for (start_idx, end_idx) in groups {
4066            let slice: Vec<&str> = (start_idx..end_idx).map(|i| cur_refs[i]).collect();
4067            let group_lines: Vec<TrackedLine> = work
4068                .iter()
4069                .filter(|t| t.current_idx >= start_idx && t.current_idx < end_idx)
4070                .cloned()
4071                .collect();
4072            let mut subranges = vec![(0usize, slice.len())];
4073            let mut group_pending = Vec::new();
4074            while let Some((off, len)) = subranges.pop() {
4075                if len == 0 {
4076                    continue;
4077                }
4078                let sub = &slice[off..off + len];
4079                if blame_entry_score_lines(sub) <= copy_threshold {
4080                    for t in &group_lines {
4081                        let rel = t.current_idx - start_idx;
4082                        if rel >= off && rel < off + len {
4083                            group_pending.push(t.clone());
4084                        }
4085                    }
4086                    continue;
4087                }
4088                let mut best: Option<(ContiguousCopyMatch, String, ObjectId, u32)> = None;
4089                for (path, oid, mode) in &candidates {
4090                    let source_lines = session.load_blob_lines(*oid, path, *mode)?;
4091                    let src_refs = source_lines.comparison_refs(ws);
4092                    let Some(m) = find_best_contiguous_copy_match(sub, &src_refs, ws) else {
4093                        continue;
4094                    };
4095                    let replace = best.as_ref().is_none_or(|(b, _, _, _)| {
4096                        m.score > b.score || (m.score == b.score && m.len > b.len)
4097                    });
4098                    if replace {
4099                        best = Some((m, path.clone(), *oid, *mode));
4100                    }
4101                }
4102                let Some((m, source_path, source_oid, source_mode)) = best else {
4103                    for t in &group_lines {
4104                        let rel = t.current_idx - start_idx;
4105                        if rel >= off && rel < off + len {
4106                            group_pending.push(t.clone());
4107                        }
4108                    }
4109                    continue;
4110                };
4111                if m.score <= copy_threshold {
4112                    for t in &group_lines {
4113                        let rel = t.current_idx - start_idx;
4114                        if rel >= off && rel < off + len {
4115                            group_pending.push(t.clone());
4116                        }
4117                    }
4118                    continue;
4119                }
4120                let (_source_lines, source_blame) = load_copy_source_blame(
4121                    odb,
4122                    parent_oid,
4123                    &source_path,
4124                    source_oid,
4125                    source_mode,
4126                    ignore_revs,
4127                    diff_algorithm,
4128                    textconv_ctx,
4129                    use_textconv,
4130                    copy_depth.saturating_sub(1),
4131                    first_parent_only,
4132                    grafts,
4133                    indent_heuristic,
4134                    session,
4135                    &mut source_cache,
4136                )?;
4137                made_progress = true;
4138                if m.suspect_start > 0 {
4139                    subranges.push((off, m.suspect_start));
4140                }
4141                if m.suspect_start + m.len < len {
4142                    subranges.push((off + m.suspect_start + m.len, len - m.suspect_start - m.len));
4143                }
4144                for t in &group_lines {
4145                    let rel = t.current_idx - start_idx;
4146                    if rel < off + m.suspect_start || rel >= off + m.suspect_start + m.len {
4147                        continue;
4148                    }
4149                    let parent_line = m.parent_start + (rel - off - m.suspect_start);
4150                    let pb = source_blame
4151                        .iter()
4152                        .find(|l| l.final_lineno == parent_line + 1)
4153                        .cloned()
4154                        .unwrap_or_else(|| {
4155                            blame_line_at_path(
4156                                parent_oid,
4157                                parent_line + 1,
4158                                parent_line + 1,
4159                                final_line_display(final_text, t.final_lineno),
4160                                &source_path,
4161                                None,
4162                                false,
4163                                false,
4164                            )
4165                        });
4166                    result.push(blame_line_from_parent(
4167                        &pb,
4168                        t.final_lineno,
4169                        final_line_display(final_text, t.final_lineno),
4170                        pb.source_file.clone().or_else(|| Some(source_path.clone())),
4171                        pb.ignored || t.ignored,
4172                        pb.unblamable,
4173                    ));
4174                }
4175            }
4176            still_pending.extend(group_pending);
4177        }
4178        work = still_pending;
4179        if !made_progress {
4180            break;
4181        }
4182    }
4183    Ok((work, parent_handoff, result))
4184}
4185
4186fn copy_changed_parent_paths(
4187    odb: &Odb,
4188    parent_tree: &ObjectId,
4189    child_tree: &ObjectId,
4190) -> Result<HashSet<String>> {
4191    let mut out = HashSet::new();
4192    for entry in diff_trees(odb, Some(parent_tree), Some(child_tree), "")? {
4193        if let Some(path) = entry.old_path {
4194            out.insert(path);
4195        }
4196        if let Some(path) = entry.new_path {
4197            out.insert(path);
4198        }
4199    }
4200    Ok(out)
4201}
4202
4203fn copy_allowed_parent_paths(
4204    odb: &Odb,
4205    parent_tree: &ObjectId,
4206    child_tree: &ObjectId,
4207    copy_depth: usize,
4208    blaming_new_file: bool,
4209) -> Result<Option<HashSet<String>>> {
4210    if copy_depth >= 3 {
4211        return Ok(None);
4212    }
4213    if copy_depth >= 2 && blaming_new_file {
4214        return Ok(None);
4215    }
4216    Ok(Some(copy_changed_parent_paths(
4217        odb,
4218        parent_tree,
4219        child_tree,
4220    )?))
4221}
4222
4223fn path_allowed_for_copy_search(path: &str, allowed: Option<&HashSet<String>>) -> bool {
4224    allowed.is_none_or(|set| set.contains(path))
4225}
4226
4227fn note_blame_source_path(pending: &mut [TrackedLine], path: &str) {
4228    let path = path.to_owned();
4229    for t in pending.iter_mut() {
4230        t.source_path = Some(path.clone());
4231    }
4232}
4233
4234fn find_rename_entry_for_path(
4235    odb: &Odb,
4236    parent_tree: &ObjectId,
4237    commit_tree: &ObjectId,
4238    new_path: &str,
4239    threshold: u32,
4240) -> Result<Option<DiffEntry>> {
4241    if threshold >= 100 {
4242        return Ok(None);
4243    }
4244    let entries = diff_trees(odb, Some(parent_tree), Some(commit_tree), "")?;
4245    let entries = detect_renames(odb, None, entries, threshold);
4246    Ok(entries
4247        .into_iter()
4248        .find(|e| e.status == DiffStatus::Renamed && e.new_path.as_deref() == Some(new_path)))
4249}
4250
4251fn find_path_by_oid_in_tree(
4252    odb: &Odb,
4253    tree_oid: &ObjectId,
4254    current_tree_oid: &ObjectId,
4255    needle_oid: &ObjectId,
4256    exclude_path: &str,
4257) -> Result<Option<(String, u32)>> {
4258    let mut entries = Vec::new();
4259    collect_tree_file_entries(odb, tree_oid, "", &mut entries)?;
4260    for (path, oid, mode) in entries {
4261        if path != exclude_path
4262            && &oid == needle_oid
4263            && resolve_path_in_tree_entry(odb, current_tree_oid, &path)?.is_none()
4264        {
4265            return Ok(Some((path, mode)));
4266        }
4267    }
4268    Ok(None)
4269}
4270
4271fn collect_tree_file_entries(
4272    odb: &Odb,
4273    tree_oid: &ObjectId,
4274    prefix: &str,
4275    out: &mut Vec<(String, ObjectId, u32)>,
4276) -> Result<()> {
4277    let obj = read_object_for_blame(odb, tree_oid, None)?;
4278    if obj.kind != ObjectKind::Tree {
4279        return Err(LibError::Message("expected tree".to_string()));
4280    }
4281    let entries = parse_tree(&obj.data)?;
4282    for entry in entries {
4283        let path = format_path_stored(prefix, &entry.name);
4284        if entry.mode == 0o040000 {
4285            collect_tree_file_entries(odb, &entry.oid, &path, out)?;
4286        } else {
4287            out.push((path, entry.oid, entry.mode));
4288        }
4289    }
4290    Ok(())
4291}
4292
4293fn get_blame_commit(
4294    odb: &Odb,
4295    oid: ObjectId,
4296    cache: &mut BlameCommitCache,
4297    graph: Option<&CommitGraphChain>,
4298) -> Result<Arc<BlameCommitMeta>> {
4299    if let Some(c) = cache.metas.get(&oid) {
4300        return Ok(Arc::clone(c));
4301    }
4302    let meta = if let Some(chain) = graph {
4303        if let Some((tree, parents)) = chain.graph_commit_meta(&oid) {
4304            Arc::new(BlameCommitMeta { tree, parents })
4305        } else {
4306            load_blame_commit_from_object(odb, oid, &mut cache.pack)?
4307        }
4308    } else {
4309        load_blame_commit_from_object(odb, oid, &mut cache.pack)?
4310    };
4311    cache.metas.insert(oid, Arc::clone(&meta));
4312    Ok(meta)
4313}
4314
4315/// Read only the tree and parent headers; author and summary are loaded later, and
4316/// only for the commits that end up owning lines.
4317fn load_blame_commit_from_object(
4318    odb: &Odb,
4319    oid: ObjectId,
4320    pack: &mut SessionPackReader,
4321) -> Result<Arc<BlameCommitMeta>> {
4322    let (tree, parents) = pack.commit_tree_and_parents(odb, &oid)?;
4323    Ok(Arc::new(BlameCommitMeta { tree, parents }))
4324}
4325
4326/// Skip first-parent history while Bloom filters or an identical blob show `path` unchanged.
4327#[allow(clippy::too_many_arguments)]
4328fn advance_across_unchanged_first_parents(
4329    session: &mut BlameSession<'_>,
4330    odb: &Odb,
4331    mut current_oid: ObjectId,
4332    blob_oid: ObjectId,
4333    mut blob_mode: u32,
4334    path: &str,
4335    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
4336    commit_cache: &mut BlameCommitCache,
4337) -> Result<(ObjectId, u32)> {
4338    session.set_bloom_path(path);
4339    let chain = session.commit_graph.clone();
4340    loop {
4341        if grafts.contains_key(&current_oid) {
4342            break;
4343        }
4344        let parent_oid = if let Some(ref chain) = chain {
4345            if let Some((parents, _)) = chain.graph_commit(&current_oid) {
4346                parents.first().copied()
4347            } else {
4348                commit_parents_for_blame(odb, current_oid, grafts, commit_cache, session)?
4349                    .first()
4350                    .copied()
4351            }
4352        } else {
4353            commit_parents_for_blame(odb, current_oid, grafts, commit_cache, session)?
4354                .first()
4355                .copied()
4356        };
4357        let Some(parent_oid) = parent_oid else {
4358            break;
4359        };
4360        if chain.is_some()
4361            && bloom_may_skip_to_first_parent(
4362                session,
4363                odb,
4364                current_oid,
4365                parent_oid,
4366                path,
4367                grafts,
4368                commit_cache,
4369                true,
4370            )?
4371        {
4372            session
4373                .commit_path_blob
4374                .insert((parent_oid, path.to_owned()), Some((blob_oid, blob_mode)));
4375            current_oid = parent_oid;
4376            continue;
4377        }
4378        if let Some((p_blob, p_mode)) =
4379            session.resolve_blob_at_commit(odb, parent_oid, path, commit_cache)?
4380        {
4381            if p_blob == blob_oid {
4382                current_oid = fast_forward_same_blob(
4383                    odb,
4384                    session,
4385                    parent_oid,
4386                    blob_oid,
4387                    p_mode,
4388                    path,
4389                    grafts,
4390                    commit_cache,
4391                )?;
4392                blob_mode = p_mode;
4393                continue;
4394            }
4395        }
4396        break;
4397    }
4398    Ok((current_oid, blob_mode))
4399}
4400
4401/// Commit-graph Bloom filters describe path changes vs the commit object's first parent.
4402/// Skip the shortcut when grafts replace parents or the walk target is not that parent.
4403#[allow(clippy::too_many_arguments)]
4404fn bloom_may_skip_to_first_parent(
4405    session: &mut BlameSession<'_>,
4406    odb: &Odb,
4407    current_oid: ObjectId,
4408    parent_oid: ObjectId,
4409    path: &str,
4410    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
4411    commit_cache: &mut BlameCommitCache,
4412    parent_is_first_in_graph: bool,
4413) -> Result<bool> {
4414    if grafts.contains_key(&current_oid) {
4415        return Ok(false);
4416    }
4417    if !parent_is_first_in_graph {
4418        let object_parents = commit_object_parents(session, odb, current_oid, commit_cache)?;
4419        if object_parents.first() != Some(&parent_oid) {
4420            return Ok(false);
4421        }
4422    }
4423    let Some(chain) = session.commit_graph.clone() else {
4424        return Ok(false);
4425    };
4426    session.set_bloom_path(path);
4427    Ok(bloom_precheck_definitely_not(
4428        session,
4429        &chain,
4430        current_oid,
4431        path,
4432    ))
4433}
4434
4435fn bloom_precheck_definitely_not(
4436    session: &mut BlameSession<'_>,
4437    chain: &CommitGraphChain,
4438    oid: ObjectId,
4439    path: &str,
4440) -> bool {
4441    let Some(settings) = chain.bloom_settings_for_commit(&oid) else {
4442        return false;
4443    };
4444    let keys = session.bloom_keys_for_path(path, &settings);
4445    chain.bloom_definitely_not_for_keys(oid, keys.as_slice())
4446}
4447
4448/// Parent OIDs stored in the commit object (or commit-graph), ignoring grafts.
4449fn commit_object_parents(
4450    session: &BlameSession<'_>,
4451    odb: &Odb,
4452    oid: ObjectId,
4453    cache: &mut BlameCommitCache,
4454) -> Result<Vec<ObjectId>> {
4455    if let Some(chain) = session.commit_graph.as_ref() {
4456        if let Some((parents, _commit_time)) = chain.graph_commit(&oid) {
4457            return Ok(parents);
4458        }
4459    }
4460    Ok(
4461        get_blame_commit(odb, oid, cache, session.commit_graph.as_deref())?
4462            .parents
4463            .clone(),
4464    )
4465}
4466
4467/// Parent list for a commit, honoring `.git/info/grafts` (same rules as `git rev-list`).
4468fn commit_parents_for_blame(
4469    odb: &Odb,
4470    oid: ObjectId,
4471    grafts: &HashMap<ObjectId, Vec<ObjectId>>,
4472    cache: &mut BlameCommitCache,
4473    session: &BlameSession<'_>,
4474) -> Result<Vec<ObjectId>> {
4475    if let Some(p) = grafts.get(&oid) {
4476        return Ok(p.clone());
4477    }
4478    if let Some(chain) = session.commit_graph.as_ref() {
4479        if let Some((parents, _)) = chain.graph_commit(&oid) {
4480            return Ok(parents);
4481        }
4482    }
4483    Ok(
4484        get_blame_commit(odb, oid, cache, session.commit_graph.as_deref())?
4485            .parents
4486            .clone(),
4487    )
4488}
4489
4490/// Resolve a Git-style `-L` range against the blob at `start` for `path`.
4491///
4492/// Uses the same line splitting, funcname rules, and reversible stored-path input
4493/// contract as [`blame_file`]. Equivalent to [`resolve_blame_line_range_with_options`]
4494/// with `start` and the committed file.
4495///
4496/// # Errors
4497///
4498/// Returns [`LineRangeError`] when the spec is invalid or out of range for the file at `start`.
4499pub fn resolve_blame_line_range(
4500    repo: &crate::repo::Repository,
4501    start: crate::objects::ObjectId,
4502    path: &str,
4503    spec: &str,
4504) -> std::result::Result<RangeInclusive<usize>, LineRangeError> {
4505    resolve_blame_line_range_with_options(
4506        repo,
4507        path,
4508        spec,
4509        &BlameOptions {
4510            start: Some(start),
4511            ..BlameOptions::default()
4512        },
4513    )
4514}
4515
4516/// Resolve a Git-style `-L` range against the text [`blame_file`] would blame with `options`.
4517///
4518/// Only `options.start` and `options.final_source` matter: regex, funcname and
4519/// relative ranges are matched against the worktree file or the supplied contents when
4520/// those are selected, so the range lines up with the returned final line numbers.
4521///
4522/// # Errors
4523///
4524/// Returns [`LineRangeError`] when the spec is invalid or out of range, or when the
4525/// final text cannot be loaded (reported as [`LineRangeError::InvalidSpec`]).
4526pub fn resolve_blame_line_range_with_options(
4527    repo: &crate::repo::Repository,
4528    path: &str,
4529    spec: &str,
4530    options: &BlameOptions,
4531) -> std::result::Result<RangeInclusive<usize>, LineRangeError> {
4532    use crate::attributes::load_gitattributes_for_check_attr_source;
4533    use crate::diff::merge_diff::read_blob_at_path;
4534    use crate::userdiff::matcher_for_path_parsed;
4535
4536    let invalid = |detail: String| LineRangeError::InvalidSpec {
4537        spec: spec.to_owned(),
4538        detail,
4539    };
4540    let file_path = normalize_blame_path(repo, path);
4541    let start = resolve_blame_start(repo, options.start).map_err(|e| invalid(e.to_string()))?;
4542    let commit = get_blame_commit(repo.odb(), start, &mut BlameCommitCache::default(), None)
4543        .map_err(|e| invalid(e.to_string()))?;
4544    let overlay = load_blame_overlay(
4545        repo,
4546        start,
4547        &blame_revision_label(options.start, start),
4548        &file_path,
4549        &options.final_source,
4550        CopyDetection::Off,
4551    )
4552    .map_err(|e| invalid(e.to_string()))?;
4553    let content: std::borrow::Cow<'_, str> = match &overlay {
4554        Some(overlay) => overlay
4555            .lines
4556            .utf8()
4557            .map(std::borrow::Cow::Borrowed)
4558            .unwrap_or_else(|| {
4559                String::from_utf8_lossy(overlay.lines.bytes())
4560                    .into_owned()
4561                    .into()
4562            }),
4563        None => {
4564            let Some(content_bytes) = read_blob_at_path(repo.odb(), &commit.tree, &file_path)
4565            else {
4566                return Err(invalid(format!("file '{file_path}' not found in revision")));
4567            };
4568            String::from_utf8_lossy(&content_bytes).into_owned().into()
4569        }
4570    };
4571    let config = repo.config().map(|c| (*c).clone()).unwrap_or_default();
4572    let parsed = load_gitattributes_for_check_attr_source(repo, &commit.tree)
4573        .map_err(|e| invalid(e.to_string()))?;
4574    let matcher =
4575        matcher_for_path_parsed(&config, &parsed.rules, &parsed.macros, &file_path, false)
4576            .map_err(invalid)?;
4577    resolve_line_range(spec, &content, &file_path, &config, matcher.as_ref(), 1)
4578}
4579
4580/// Copy detection depth for blame, matching Git's repeated `-C` flag.
4581#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
4582pub enum CopyDetection {
4583    /// No copy detection (default).
4584    #[default]
4585    Off,
4586    /// `-C` once: copies from files removed in the same commit.
4587    FromModifiedFiles,
4588    /// `-C -C`: also search existing parent files when the blamed path is new in the commit.
4589    FromCreatingCommit,
4590    /// `-C -C -C`: search all parent files for copies (including appended lines).
4591    FromAnyCommit,
4592}
4593
4594impl CopyDetection {
4595    /// Number of `-C` flags this level represents (`0` when [`CopyDetection::Off`]).
4596    #[must_use]
4597    pub const fn count(self) -> usize {
4598        match self {
4599            Self::Off => 0,
4600            Self::FromModifiedFiles => 1,
4601            Self::FromCreatingCommit => 2,
4602            Self::FromAnyCommit => 3,
4603        }
4604    }
4605
4606    /// Build from a CLI `-C` repetition count (Git allows at most three).
4607    ///
4608    /// # Errors
4609    ///
4610    /// Returns [`LibError::Message`] when `count` is greater than three.
4611    pub fn from_count(count: usize) -> Result<Self> {
4612        match count {
4613            0 => Ok(Self::Off),
4614            1 => Ok(Self::FromModifiedFiles),
4615            2 => Ok(Self::FromCreatingCommit),
4616            3 => Ok(Self::FromAnyCommit),
4617            _ => Err(LibError::Message(
4618                "blame copy detection: at most three -C flags are supported".into(),
4619            )),
4620        }
4621    }
4622
4623    const fn depth(self) -> usize {
4624        self.count()
4625    }
4626}
4627
4628/// Options for [`blame_file`].
4629#[derive(Debug, Clone, Default, PartialEq, Eq)]
4630pub struct BlameOptions {
4631    /// Commit to blame at. When `None`, uses the current `HEAD` commit.
4632    pub start: Option<ObjectId>,
4633    /// When set, only include lines whose 1-based final line number lies in this range.
4634    /// Packed history uses positioned entry reads, bounded retained pack readers,
4635    /// and private, bounded delta-base caches;
4636    /// repository caches and their configured limits are left unchanged. Complete blobs
4637    /// and tree delta chains still need to be reconstructed to attribute these lines.
4638    pub line_range: Option<RangeInclusive<usize>>,
4639    /// When true, follow only the first parent at merge commits (Git's `--first-parent`).
4640    pub first_parent_only: bool,
4641    /// Commits to treat as transparent during the history walk.
4642    pub ignore: IgnoreRevs,
4643    /// Whitespace rules when comparing lines between revisions (`-w` / ignore-all-space today).
4644    pub whitespace: crate::diff::WhitespaceMode,
4645    /// Copy detection depth (Git `-C`, `-C -C`, `-C -C -C`).
4646    pub copy_detection: CopyDetection,
4647    /// Which version of the file supplies the blamed lines. The default blames the file as
4648    /// committed at `start`.
4649    pub final_source: BlameFinalSource,
4650    /// Author time reported in [`BlameResult::commits`] for uncommitted lines (Git reports
4651    /// the current time). The library never reads the clock; `None` reports `0`.
4652    pub now: Option<time::OffsetDateTime>,
4653}
4654
4655/// Which version of the blamed file [`blame_file`] attributes, line by line.
4656///
4657/// For [`Worktree`](Self::Worktree) and [`Contents`](Self::Contents) the lines are blamed
4658/// as if they were a commit on top of the start revision (plus any `MERGE_HEAD` parents of
4659/// an unfinished merge, unless [`BlameOptions::first_parent_only`] is set): lines that
4660/// match a parent keep their history, and the rest are
4661/// reported with [`BlameAttributedLine::not_committed`] set and the null object id. Content
4662/// that matches the start revision exactly is blamed like
4663/// [`Revision`](Self::Revision). Nothing is written to the object database.
4664#[derive(Debug, Clone, Default, PartialEq, Eq)]
4665pub enum BlameFinalSource {
4666    /// The file as committed at the start revision (Git's `blame <rev> -- <path>`).
4667    #[default]
4668    Revision,
4669    /// The file in the working tree, cleaned like `add` would (line endings, clean
4670    /// filters, symlink targets). This is what `git blame <path>` shows.
4671    Worktree,
4672    /// Caller-supplied file content, such as an editor buffer with unsaved edits
4673    /// (Git's `blame --contents`). The bytes get the same clean conversion as a worktree file.
4674    Contents(Vec<u8>),
4675}
4676
4677/// One blamed line returned by [`blame_file`].
4678#[derive(Debug, Clone, PartialEq, Eq)]
4679pub struct BlameAttributedLine {
4680    /// Commit that last modified this line.
4681    pub commit: ObjectId,
4682    /// 1-based line number in the commit where this line originated.
4683    pub original_line: usize,
4684    /// 1-based line number in the blamed revision's file.
4685    pub final_line: usize,
4686    /// Reversibly stored path in the originating commit, including rename/copy origins.
4687    /// Resolve this path in the attributed commit, not in the blamed revision.
4688    /// Recover exact filename bytes with `repo_relpath::storage_bytes`.
4689    pub original_path: String,
4690    /// Line text in the blamed revision (lossy UTF-8 when [`Self::encoding_lossy`]).
4691    pub content: String,
4692    /// `true` when line bytes are not valid UTF-8 and [`Self::content`] used lossy decoding.
4693    pub encoding_lossy: bool,
4694    /// Exact line bytes excluding the line terminator when [`Self::encoding_lossy`]; otherwise `None`.
4695    pub content_bytes: Option<Vec<u8>>,
4696    /// Line attribution passed through an ignored revision.
4697    pub ignored: bool,
4698    /// Line could not be blamed past an ignored revision.
4699    pub unblamable: bool,
4700    /// Line exists only in the uncommitted content selected by
4701    /// [`BlameOptions::final_source`]. `commit` is then the null object id and
4702    /// `original_line` equals `final_line`.
4703    pub not_committed: bool,
4704}
4705
4706/// A contiguous run of blamed lines sharing commit, path, and original line sequence.
4707#[derive(Debug, Clone, PartialEq, Eq)]
4708pub struct BlameHunk {
4709    /// Commit that last modified every line in this hunk.
4710    pub commit: ObjectId,
4711    /// Reversibly stored path in the originating commit.
4712    /// Resolve this path in `commit`; source lines begin at `original_start`.
4713    /// Recover exact filename bytes with `repo_relpath::storage_bytes`.
4714    pub original_path: String,
4715    /// 1-based starting line in the originating commit.
4716    pub original_start: usize,
4717    /// 1-based starting line in the blamed revision's file.
4718    pub final_start: usize,
4719    /// Number of consecutive final lines in this hunk.
4720    pub count: usize,
4721    /// Set when every line in the hunk passed through an ignored revision.
4722    pub ignored: bool,
4723    /// Set when every line in the hunk could not be blamed past an ignored revision.
4724    pub unblamable: bool,
4725}
4726
4727/// Metadata for a commit referenced by [`BlameResult::lines`].
4728#[derive(Debug, Clone, PartialEq, Eq)]
4729pub struct BlameCommitInfo {
4730    /// Author identity line (`Name <email> <epoch> <tz>`); empty when the commit has no
4731    /// `author` header.
4732    pub author: String,
4733    /// Author timestamp (Unix seconds); `0` when the author line has none.
4734    pub author_time: i64,
4735    /// First line of the commit message.
4736    pub summary: String,
4737}
4738
4739/// Full blame result for one file at one revision.
4740#[derive(Debug, Clone, PartialEq, Eq)]
4741pub struct BlameResult {
4742    /// Blamed lines in final-line order (after any line-range filter).
4743    pub lines: Vec<BlameAttributedLine>,
4744    /// Commit metadata keyed by object id for every line in `lines`.
4745    pub commits: HashMap<ObjectId, BlameCommitInfo>,
4746}
4747
4748impl BlameResult {
4749    /// Group consecutive lines into hunks matching `git blame --porcelain` group headers.
4750    #[must_use]
4751    pub fn hunks(&self) -> Vec<BlameHunk> {
4752        if self.lines.is_empty() {
4753            return Vec::new();
4754        }
4755        let mut out = Vec::new();
4756        let mut start = 0usize;
4757        for i in 1..=self.lines.len() {
4758            let extend =
4759                i < self.lines.len() && blame_lines_share_hunk(&self.lines[i - 1], &self.lines[i]);
4760            if !extend {
4761                let slice = &self.lines[start..i];
4762                out.push(BlameHunk {
4763                    commit: slice[0].commit,
4764                    original_path: slice[0].original_path.clone(),
4765                    original_start: slice[0].original_line,
4766                    final_start: slice[0].final_line,
4767                    count: slice.len(),
4768                    ignored: slice[0].ignored,
4769                    unblamable: slice[0].unblamable,
4770                });
4771                start = i;
4772            }
4773        }
4774        out
4775    }
4776}
4777
4778fn blame_lines_share_hunk(prev: &BlameAttributedLine, next: &BlameAttributedLine) -> bool {
4779    prev.commit == next.commit
4780        && prev.original_path == next.original_path
4781        && prev.ignored == next.ignored
4782        && prev.unblamable == next.unblamable
4783        && next.original_line == prev.original_line + 1
4784        && next.final_line == prev.final_line + 1
4785}
4786
4787/// Blame `path` at `options.start` (or `HEAD`) and return per-line attributions.
4788///
4789/// `path` is repository-relative: the same string names the same file regardless
4790/// of the host process working directory. It accepts the reversible strings
4791/// returned by status or [`crate::repo_relpath::storage_string`]. Decoding
4792/// happens once, keeping literal reserved-prefix names distinct. Returned
4793/// `original_path` values use the same storage encoding. Absolute paths under
4794/// the work tree and `./` / `../` segments are normalized against the work-tree
4795/// root. For argv-style cwd resolution (as in the `grit blame` CLI), use
4796/// [`Repository::resolve_worktree_pathspec`](crate::repo::Repository::resolve_worktree_pathspec)
4797/// before calling this function.
4798///
4799/// Adding or removing the final LF changes attribution of the last line by default.
4800/// Whitespace normalization (for example [`crate::diff::WhitespaceMode::ignore_all_space`])
4801/// compares normalized bodies instead, so newline-only changes remain transparent.
4802/// Returned line content excludes LF, regardless of the comparison mode.
4803///
4804/// [`BlameOptions::final_source`] selects uncommitted content (the worktree file or a
4805/// caller buffer) to blame on top of the start revision; see [`BlameFinalSource`].
4806///
4807/// # Errors
4808///
4809/// Returns an error when `HEAD` is unborn, the revision or path cannot be resolved,
4810/// or object data cannot be read. With uncommitted content it also fails when the
4811/// worktree file cannot be read or cleaned, when the path is in no parent and not in the
4812/// index ([`crate::rev_parse_error::RevParseError::PathNotInRevision`]), and for
4813/// [`BlameFinalSource::Worktree`] in a bare repository
4814/// ([`LibError::BlameWorktreeBareRepository`]).
4815pub fn blame_file(repo: &Repository, path: &str, options: &BlameOptions) -> Result<BlameResult> {
4816    let start_oid = resolve_blame_start(repo, options.start)?;
4817    let file_path = normalize_blame_path(repo, path);
4818    let config = repo.config().map(|cfg| (*cfg).clone()).unwrap_or_default();
4819    let grafts = crate::rev_parse::load_graft_parents(repo.git_dir());
4820    let diff_algorithm = config
4821        .get("diff.algorithm")
4822        .as_ref()
4823        .and_then(|name| parse_diff_algorithm_name(name))
4824        .unwrap_or(BlameDiffAlgorithm::Myers);
4825    let indent_heuristic = crate::diff::indent_heuristic_from_config(&config);
4826    let ignore_revs: HashSet<ObjectId> = options.ignore.as_set().clone();
4827
4828    let bounded_odb = options
4829        .line_range
4830        .as_ref()
4831        .map(|_| repo.odb().with_bounded_pack_cache());
4832    let odb = bounded_odb.as_ref().unwrap_or(repo.odb());
4833    let thread_policy = repo.thread_policy();
4834    let mut session = BlameSession::new(odb, None, false, &thread_policy);
4835    session.whitespace = options.whitespace;
4836    session.commit_graph = repo.odb().commit_graph_chain();
4837    let revision_label = blame_revision_label(options.start, start_oid);
4838    let overlay = load_blame_overlay(
4839        repo,
4840        start_oid,
4841        &revision_label,
4842        &file_path,
4843        &options.final_source,
4844        options.copy_detection,
4845    )?;
4846    let copy_depth = options.copy_detection.depth();
4847    session.cache_pair_maps = !ignore_revs.is_empty() || copy_depth > 0;
4848    let (lines, commits) = odb.with_pack_read_context(
4849        || -> Result<(Vec<BlameAttributedLine>, HashMap<ObjectId, BlameCommitInfo>)> {
4850            let raw = compute_blame_with_session(
4851                odb,
4852                start_oid,
4853                &revision_label,
4854                &file_path,
4855                &ignore_revs,
4856                diff_algorithm,
4857                None,
4858                false,
4859                copy_depth,
4860                options.first_parent_only,
4861                &grafts,
4862                indent_heuristic,
4863                options.line_range.clone(),
4864                overlay.as_ref(),
4865                &mut session,
4866            )?;
4867
4868            let lines: Vec<BlameAttributedLine> = raw
4869                .into_iter()
4870                .map(|line| BlameAttributedLine {
4871                    commit: line.oid,
4872                    original_line: line.orig_lineno,
4873                    final_line: line.final_lineno,
4874                    original_path: line.original_path,
4875                    content: line.content,
4876                    encoding_lossy: line.encoding_lossy,
4877                    content_bytes: line.content_bytes,
4878                    ignored: line.ignored,
4879                    unblamable: line.unblamable,
4880                    not_committed: line.not_committed,
4881                })
4882                .collect();
4883
4884            let mut commits = HashMap::new();
4885            for line in &lines {
4886                commits.entry(line.commit).or_insert_with(|| {
4887                    if line.not_committed {
4888                        BlameCommitInfo {
4889                            author: String::new(),
4890                            author_time: options.now.map_or(0, |now| now.unix_timestamp()),
4891                            summary: String::new(),
4892                        }
4893                    } else {
4894                        load_blame_commit_info(odb, line.commit)
4895                    }
4896                });
4897            }
4898            Ok((lines, commits))
4899        },
4900    )?;
4901
4902    Ok(BlameResult { lines, commits })
4903}
4904
4905/// Revision name used in "path not in revision" errors: `HEAD` unless a start was given.
4906fn blame_revision_label(start: Option<ObjectId>, start_oid: ObjectId) -> String {
4907    if start.is_some() {
4908        start_oid.to_hex()
4909    } else {
4910        "HEAD".into()
4911    }
4912}
4913
4914/// Load the uncommitted final text `source` selects as an overlay on `start_oid`.
4915///
4916/// Returns `None` for [`BlameFinalSource::Revision`] and when the text hashes to the blob
4917/// already committed at `start_oid`, so a clean file is blamed exactly like the revision.
4918fn load_blame_overlay(
4919    repo: &Repository,
4920    start_oid: ObjectId,
4921    revision_label: &str,
4922    file_path: &str,
4923    source: &BlameFinalSource,
4924    copy_detection: CopyDetection,
4925) -> Result<Option<BlameOverlay>> {
4926    let supplied = match source {
4927        BlameFinalSource::Revision => return Ok(None),
4928        BlameFinalSource::Worktree => None,
4929        BlameFinalSource::Contents(bytes) => Some(bytes.as_slice()),
4930    };
4931    let odb = repo.odb();
4932    let config = repo.config().map(|c| (*c).clone()).unwrap_or_default();
4933    let path_bytes = storage_bytes(file_path);
4934    let rel_match = crate::repo_relpath::as_utf8_for_matching(file_path);
4935    let conv = crate::crlf::ConversionConfig::from_config(&config);
4936    let attrs = repo.work_tree().map(|work_tree| {
4937        let rules = crate::crlf::load_gitattributes(work_tree);
4938        crate::crlf::get_file_attrs(&rules, rel_match.as_ref(), false, &config)
4939    });
4940    // Only some questions need the index, and a clean blame should not pay for loading it.
4941    let mut index = None;
4942    let bytes = match (supplied, repo.work_tree(), attrs.as_ref()) {
4943        (None, Some(work_tree), Some(attrs)) => {
4944            let abs = crate::repo_relpath::worktree_abs(work_tree, file_path);
4945            let meta = std::fs::symlink_metadata(&abs).map_err(|source| {
4946                crate::worktree::map_worktree_io(
4947                    rel_match.as_ref(),
4948                    crate::worktree::WorktreePathOperation::Metadata,
4949                    source,
4950                )
4951            })?;
4952            let index_entry = if crate::crlf::clean_uses_autocrlf_index_guard(attrs, &conv) {
4953                loaded_index(repo, &mut index)?.get(&path_bytes, 0)
4954            } else {
4955                None
4956            };
4957            crate::diff::worktree_blob_bytes(
4958                odb,
4959                &abs,
4960                &meta,
4961                &conv,
4962                attrs,
4963                rel_match.as_ref(),
4964                index_entry,
4965                None,
4966            )?
4967        }
4968        (None, _, _) => return Err(LibError::BlameWorktreeBareRepository),
4969        (Some(bytes), _, Some(attrs)) => {
4970            crate::crlf::convert_to_git(bytes, rel_match.as_ref(), &conv, attrs)
4971                .map_err(LibError::Filter)?
4972        }
4973        (Some(bytes), _, None) => bytes.to_vec(),
4974    };
4975
4976    let start_tree = get_blame_commit(odb, start_oid, &mut BlameCommitCache::default(), None)?.tree;
4977    let start_blob = resolve_path_in_tree_entry(odb, &start_tree, file_path)?;
4978    if let Some((blob, _)) = start_blob {
4979        if odb.hash(ObjectKind::Blob, &bytes)? == blob {
4980            return Ok(None);
4981        }
4982    }
4983    let mut parents = vec![start_oid];
4984    parents.extend(crate::state::read_merge_heads(repo.git_dir())?);
4985    if start_blob.is_none() {
4986        let staged = loaded_index(repo, &mut index)?;
4987        let mut known = (0..=3).any(|stage| staged.get(&path_bytes, stage).is_some());
4988        for parent in &parents[1..] {
4989            if known {
4990                break;
4991            }
4992            let tree = get_blame_commit(odb, *parent, &mut BlameCommitCache::default(), None)?.tree;
4993            known = resolve_path_in_tree_entry(odb, &tree, file_path)?.is_some();
4994        }
4995        if !known {
4996            return Err(blame_path_missing_in_revision(file_path, revision_label));
4997        }
4998    }
4999
5000    let copy_depth = copy_detection.depth();
5001    let search_all_paths =
5002        copy_depth == 0 || copy_depth >= 3 || (copy_depth == 2 && start_blob.is_none());
5003    let copy_paths = if search_all_paths {
5004        None
5005    } else {
5006        // Git's fake commit has the index as its tree, so `-C` searches staged changes.
5007        Some(
5008            crate::diff::diff_index_to_tree(
5009                odb,
5010                loaded_index(repo, &mut index)?,
5011                Some(&start_tree),
5012                true,
5013            )?
5014            .into_iter()
5015            .flat_map(|entry| [entry.old_path, entry.new_path])
5016            .flatten()
5017            .collect(),
5018        )
5019    };
5020    Ok(Some(BlameOverlay {
5021        lines: Arc::new(BlobLines::from_bytes(bytes)),
5022        parents,
5023        copy_paths,
5024    }))
5025}
5026
5027/// The repository index, read into `slot` on first use.
5028fn loaded_index<'a>(repo: &Repository, slot: &'a mut Option<Index>) -> Result<&'a Index> {
5029    if slot.is_none() {
5030        *slot = Some(repo.load_index()?);
5031    }
5032    Ok(slot.get_or_insert_with(Index::default))
5033}
5034
5035fn resolve_blame_start(repo: &Repository, start: Option<ObjectId>) -> Result<ObjectId> {
5036    if let Some(oid) = start {
5037        return crate::revwalk::rev_parse::peel_to_commit_for_merge_base(repo, oid);
5038    }
5039    use crate::state::{resolve_head, HeadState};
5040    match resolve_head(repo.git_dir())? {
5041        HeadState::Detached { oid } => Ok(oid),
5042        HeadState::Branch { oid: Some(oid), .. } => Ok(oid),
5043        HeadState::Branch { oid: None, .. } | HeadState::Invalid => Err(LibError::Message(
5044            "cannot blame: HEAD does not point to a commit".to_string(),
5045        )),
5046    }
5047}
5048
5049fn normalize_blame_path(repo: &Repository, path: &str) -> String {
5050    if let std::borrow::Cow::Owned(bytes) = storage_bytes(path) {
5051        // Stored paths already name a repository-relative file. Do not resolve the
5052        // ASCII encoding against the work tree or decode a literal prefix twice.
5053        return storage_string(&bytes);
5054    }
5055    let normalized = if let Some(work_tree) = repo.work_tree() {
5056        crate::pathspec::normalize_worktree_file_path_with_git_dir(
5057            path,
5058            work_tree,
5059            None,
5060            Some(repo.git_dir()),
5061        )
5062    } else {
5063        path.to_string()
5064    };
5065    // Normalization can expose a literal reserved prefix (for example after `./`).
5066    if normalized.starts_with(crate::repo_relpath::STORAGE_PREFIX) {
5067        storage_string(normalized.as_bytes())
5068    } else {
5069        normalized
5070    }
5071}
5072
5073/// Author and summary for a blamed commit, read leniently so a commit missing `author` or
5074/// `committer` (or with other malformed headers) still reports what it does have, the same
5075/// whether the object is loose or packed.
5076fn load_blame_commit_info(odb: &Odb, oid: ObjectId) -> BlameCommitInfo {
5077    let (author, message) = match read_object_for_blame(odb, &oid, None) {
5078        Ok(obj) if obj.kind == ObjectKind::Commit => commit_author_and_message_lenient(&obj.data),
5079        _ => (None, String::new()),
5080    };
5081    let author = author.unwrap_or_default();
5082    BlameCommitInfo {
5083        author_time: author_epoch_from_identity(&author),
5084        author: author.trim().to_string(),
5085        summary: message
5086            .lines()
5087            .find(|line| !line.trim().is_empty())
5088            .unwrap_or("")
5089            .trim()
5090            .to_string(),
5091    }
5092}
5093
5094fn author_epoch_from_identity(identity: &str) -> i64 {
5095    let parts: Vec<&str> = identity.rsplitn(3, ' ').collect();
5096    if parts.len() >= 2 {
5097        parts[1].parse().unwrap_or(0)
5098    } else {
5099        0
5100    }
5101}
5102
5103#[cfg(test)]
5104mod tests {
5105    use super::*;
5106
5107    #[test]
5108    fn blob_lines_preserves_interior_and_trailing_blank_lines() {
5109        let cases = [
5110            "alpha\n\nbeta\n\ngamma\n",
5111            "only\n",
5112            "\n",
5113            "a\n\n\nb\n",
5114            "head\n\n",
5115        ];
5116        for content in cases {
5117            let blob = BlobLines::from_content(content.to_owned());
5118            let expected: Vec<String> = content_lines(content)
5119                .into_iter()
5120                .map(str::to_owned)
5121                .collect();
5122            assert_eq!(blob.to_vec(), expected, "content={content:?}");
5123        }
5124    }
5125
5126    #[test]
5127    fn blob_lines_compare_records_without_changing_bodies() {
5128        let exact = crate::diff::WhitespaceMode::default();
5129        let ws = crate::diff::WhitespaceMode::ignore_all_space();
5130        for text in ["", "hello", "hello\n", "a\r\nb\r", "\n", "a\n\n"] {
5131            let lines = BlobLines::from_content(text.to_owned());
5132            assert_eq!(
5133                lines.comparison_refs(exact),
5134                text.split_inclusive('\n').collect::<Vec<_>>()
5135            );
5136            assert_eq!(lines.comparison_refs(ws), content_lines(text));
5137            assert_eq!(lines.comparison_refs(exact).concat(), text);
5138        }
5139    }
5140
5141    #[test]
5142    fn final_newline_is_changed_in_affix_and_exact_maps() {
5143        let exact = crate::diff::WhitespaceMode::default();
5144        let ws = crate::diff::WhitespaceMode::ignore_all_space();
5145        for (before, after) in [
5146            ("first\nlast", "first\nlast\n"),
5147            ("first\nlast\n", "first\nlast"),
5148        ] {
5149            let old = BlobLines::from_content(before.to_owned());
5150            let new = BlobLines::from_content(after.to_owned());
5151            assert_eq!(common_affix_len(&old, &new, exact), (1, 0));
5152            let pending = vec![TrackedLine {
5153                final_lineno: 2,
5154                current_idx: 1,
5155                ignored: false,
5156                source_path: None,
5157                origin: 0,
5158            }];
5159            let (affix, need) = partition_pending_by_affix(
5160                slide_safe_affix(&old, &new, exact),
5161                old.len(),
5162                new.len(),
5163                pending,
5164            );
5165            assert!(affix.is_empty());
5166            assert_eq!(need.len(), 1);
5167            for algorithm in [
5168                BlameDiffAlgorithm::Myers,
5169                BlameDiffAlgorithm::Histogram,
5170                BlameDiffAlgorithm::Patience,
5171                BlameDiffAlgorithm::Minimal,
5172            ] {
5173                let (map, flags) = build_line_map_with_changed_flags_owned(
5174                    &old,
5175                    &new,
5176                    slide_safe_affix(&old, &new, exact),
5177                    algorithm,
5178                    true,
5179                    true,
5180                    exact,
5181                );
5182                assert_eq!(map, vec![Some(0), None]);
5183                assert_eq!(flags, vec![false, true]);
5184                assert_eq!(
5185                    build_exact_relocate_line_map_ws(
5186                        &old.comparison_refs(exact),
5187                        &new.comparison_refs(exact),
5188                        &map,
5189                        exact
5190                    ),
5191                    map
5192                );
5193                let (map, flags) = build_line_map_with_changed_flags_owned(
5194                    &old,
5195                    &new,
5196                    slide_safe_affix(&old, &new, ws),
5197                    algorithm,
5198                    true,
5199                    true,
5200                    ws,
5201                );
5202                assert_eq!(map, vec![Some(0), Some(1)]);
5203                assert_eq!(flags, vec![false, false]);
5204            }
5205        }
5206    }
5207
5208    #[test]
5209    fn shuffle_commit_stops_blame_on_moved_include_line() {
5210        let old = [
5211            "",
5212            "#include \"cache.h\"",
5213            "#include \"builtin.h\"",
5214            "#include \"advice.h\"",
5215            "#include \"pathspec.h\"",
5216            "static int chmod_pathspec(struct pathspec_item *item, void *cb)",
5217            "{",
5218            "    return 0;",
5219            "}",
5220            "#include \"run-command.h\"",
5221            "#include \"builtin.h\"",
5222        ];
5223        let new = [
5224            "",
5225            "#include \"builtin.h\"",
5226            "#include \"advice.h\"",
5227            "#include \"pathspec.h\"",
5228            "#include \"run-command.h\"",
5229            "static int chmod_pathspec(struct pathspec_item *item, void *cb)",
5230            "{",
5231            "    return 0;",
5232            "}",
5233        ];
5234        let diff = build_line_map(&old, &new, BlameDiffAlgorithm::Myers, true);
5235        let map = build_exact_relocate_line_map(&old, &new, &diff);
5236        let (_, flags) = build_line_map_with_changed_flags(
5237            &old,
5238            &new,
5239            BlameDiffAlgorithm::Myers,
5240            true,
5241            true,
5242            crate::diff::WhitespaceMode::default(),
5243        );
5244        let stop = blame_stops_on_current_commit(&diff, &map, &flags);
5245        assert_eq!(diff[4], None);
5246        assert_eq!(
5247            map[4],
5248            Some(9),
5249            "exact relocate pairs moved text with old index"
5250        );
5251        assert!(
5252            stop[4],
5253            "moved include stops on shuffle commit: stop={stop:?} map={map:?} flags={flags:?}"
5254        );
5255    }
5256
5257    #[test]
5258    fn build_line_map_include_shuffle_matches_git_with_indent_heuristic() {
5259        let old = [
5260            "",
5261            "#include \"cache.h\"",
5262            "#include \"builtin.h\"",
5263            "#include \"advice.h\"",
5264            "static void foo(void)",
5265            "{",
5266            "}",
5267        ];
5268        let new = [
5269            "",
5270            "#include \"builtin.h\"",
5271            "#include \"advice.h\"",
5272            "static void foo(void)",
5273            "{",
5274            "}",
5275        ];
5276        let map = build_line_map(&old, &new, BlameDiffAlgorithm::Myers, true);
5277        assert_eq!(
5278            map,
5279            vec![Some(0), Some(2), Some(3), Some(4), Some(5), Some(6),],
5280            "git keeps matching includes through equal hunks"
5281        );
5282    }
5283
5284    #[test]
5285    fn build_line_map_maps_unchanged_lines_and_drops_replaced() {
5286        let old = ["alpha", "beta", "gamma"];
5287        let new = ["alpha", "BETA", "gamma"];
5288        let map = build_line_map(&old, &new, BlameDiffAlgorithm::Myers, false);
5289        // unchanged lines map back to their old index; the replaced line is None.
5290        assert_eq!(map, vec![Some(0), None, Some(2)]);
5291    }
5292
5293    #[test]
5294    fn build_line_map_slides_insertion_into_unchanged_suffix() {
5295        let old = [
5296            "PROG=a b c",
5297            "\t$(CC) -o cat-file",
5298            "",
5299            "read-cache.o: cache.h",
5300        ];
5301        let new = [
5302            "PROG=a b c fsck-cache",
5303            "\t$(CC) -o cat-file",
5304            "",
5305            "fsck-cache: fsck-cache.o",
5306            "\t$(CC) -o fsck-cache",
5307            "",
5308            "read-cache.o: cache.h",
5309        ];
5310        for algorithm in [BlameDiffAlgorithm::Myers, BlameDiffAlgorithm::Histogram] {
5311            for indent_heuristic in [true, false] {
5312                let map = build_line_map(&old, &new, algorithm, indent_heuristic);
5313                assert_eq!(
5314                    map,
5315                    vec![None, Some(1), Some(2), None, None, None, Some(3)],
5316                    "{algorithm:?} indent_heuristic={indent_heuristic}"
5317                );
5318            }
5319        }
5320        let old_lines = BlobLines::from_content(old.map(|l| format!("{l}\n")).concat());
5321        let new_lines = BlobLines::from_content(new.map(|l| format!("{l}\n")).concat());
5322        let exact = crate::diff::WhitespaceMode::default();
5323        let (map, flags) = build_line_map_with_changed_flags_owned(
5324            &old_lines,
5325            &new_lines,
5326            slide_safe_affix(&old_lines, &new_lines, exact),
5327            BlameDiffAlgorithm::Myers,
5328            true,
5329            true,
5330            exact,
5331        );
5332        assert_eq!(map, vec![None, Some(1), Some(2), None, None, None, Some(3)]);
5333        assert_eq!(flags, vec![true, false, false, true, true, true, false]);
5334        let pending = (0..new.len())
5335            .map(|i| TrackedLine {
5336                final_lineno: i + 1,
5337                current_idx: i,
5338                ignored: false,
5339                source_path: None,
5340                origin: 0,
5341            })
5342            .collect();
5343        let (affix, need) = partition_pending_by_affix(
5344            slide_safe_affix(&old_lines, &new_lines, exact),
5345            old.len(),
5346            new.len(),
5347            pending,
5348        );
5349        let affix: Vec<(usize, usize)> = affix
5350            .iter()
5351            .map(|t| (t.final_lineno, t.current_idx))
5352            .collect();
5353        assert_eq!(
5354            affix,
5355            vec![(7, 3)],
5356            "the trailing blank can slide, so it needs the diff"
5357        );
5358        assert_eq!(need.len(), 6);
5359    }
5360
5361    #[test]
5362    fn blame_tail_trim_follows_git_block_rule() {
5363        let exact = crate::diff::WhitespaceMode::default();
5364        let old_text = format!("a\n{}", "b\n".repeat(1000));
5365        let new_text = format!("a\n{}", "b\n".repeat(1001));
5366        let old = BlobLines::from_content(old_text.clone());
5367        let new = BlobLines::from_content(new_text.clone());
5368        // 2001 common tail bytes leave one 1 KiB block; the 511 two-byte lines after the line
5369        // it starts in are dropped.
5370        let affix = slide_safe_affix(&old, &new, exact);
5371        assert_eq!(blame_trimmed_tail_lines(&old, &new, affix, exact), 511);
5372        let old_refs: Vec<&str> = old_text.lines().collect();
5373        let new_refs: Vec<&str> = new_text.lines().collect();
5374        let slice_affix = slide_safe_affix(old_refs.as_slice(), new_refs.as_slice(), exact);
5375        assert_eq!(
5376            blame_trimmed_tail_lines(old_refs.as_slice(), new_refs.as_slice(), slice_affix, exact),
5377            511
5378        );
5379        let ws = crate::diff::WhitespaceMode::ignore_all_space();
5380        let ws_affix = slide_safe_affix(&old, &new, ws);
5381        assert_eq!(blame_trimmed_tail_lines(&old, &new, ws_affix, ws), 511);
5382
5383        let map = build_line_map(&old_refs, &new_refs, BlameDiffAlgorithm::Myers, true);
5384        let inserted: Vec<usize> = (0..map.len()).filter(|&i| map[i].is_none()).collect();
5385        assert_eq!(
5386            inserted,
5387            vec![490],
5388            "the insertion stops where git cut the tail"
5389        );
5390
5391        let short_old = BlobLines::from_content("a\nb\n".to_owned());
5392        let short_new = BlobLines::from_content("x\nb\n".to_owned());
5393        let affix = slide_safe_affix(&short_old, &short_new, exact);
5394        assert_eq!(
5395            blame_trimmed_tail_lines(&short_old, &short_new, affix, exact),
5396            0
5397        );
5398    }
5399
5400    /// Small deterministic generator for the window equivalence test.
5401    struct Lcg(u64);
5402
5403    impl Lcg {
5404        fn next(&mut self, bound: usize) -> usize {
5405            self.0 = self
5406                .0
5407                .wrapping_mul(6364136223846793005)
5408                .wrapping_add(1442695040888963407);
5409            ((self.0 >> 33) as usize) % bound
5410        }
5411    }
5412
5413    #[test]
5414    fn windowed_line_map_matches_compacting_git_view() {
5415        // Frequent lines sit near xdiff's frequency limit for these file sizes, and blank and
5416        // indented lines give the indent heuristic something to weigh.
5417        let pool = [
5418            "",
5419            "",
5420            "}",
5421            "\t}",
5422            "#",
5423            "\t\treturn 0;",
5424            "\tif (x) {",
5425            "\tx = 1;",
5426            "fn a() {",
5427        ];
5428        let line = |rng: &mut Lcg, i: usize| -> String {
5429            let pick = rng.next(pool.len() * 2);
5430            pool.get(pick)
5431                .map_or_else(|| format!("line {i} {}", rng.next(4)), |l| (*l).to_owned())
5432        };
5433        let mut rng = Lcg(7);
5434        for case in 0..400 {
5435            let len = 60 + rng.next(500);
5436            let old: Vec<String> = (0..len).map(|i| line(&mut rng, i)).collect();
5437            let mut new = old.clone();
5438            for _ in 0..1 + rng.next(4) {
5439                let at = rng.next(new.len());
5440                let n = 1 + rng.next(6);
5441                match rng.next(3) {
5442                    0 => {
5443                        for k in 0..n {
5444                            let l = line(&mut rng, k);
5445                            new.insert(at, l);
5446                        }
5447                    }
5448                    1 => {
5449                        new.drain(at..(at + n).min(new.len()));
5450                    }
5451                    _ => {
5452                        let end = (at + n).min(new.len());
5453                        for slot in &mut new[at..end] {
5454                            *slot = line(&mut rng, case);
5455                        }
5456                    }
5457                }
5458            }
5459            let old: Vec<&str> = old.iter().map(String::as_str).collect();
5460            let new: Vec<&str> = new.iter().map(String::as_str).collect();
5461            assert_window_matches_git_view(&old, &new, &format!("case {case}"));
5462        }
5463    }
5464
5465    /// A blank line stops the slide, so the indent heuristic reads the blank run before it.
5466    #[test]
5467    fn windowed_line_map_keeps_blank_context_for_indent_heuristic() {
5468        let block = ["\tif (x) {", "\t\ty();", "\t}"];
5469        let mut old = vec!["top"];
5470        old.extend(std::iter::repeat_n("", 25));
5471        old.extend(block);
5472        old.push("end");
5473        let mut new = old.clone();
5474        new.splice(29..29, block);
5475        assert_window_matches_git_view(&old, &new, "blank run");
5476    }
5477
5478    /// A `}` frequent only outside the window still counts as frequent.
5479    #[test]
5480    fn windowed_line_map_counts_lines_outside_the_window() {
5481        let version = |middle: [&'static str; 8]| -> Vec<String> {
5482            let mut lines = vec!["}".to_owned(); 60];
5483            lines.extend((0..30).map(|i| format!("head {i}")));
5484            lines.extend(middle.map(str::to_owned));
5485            lines.extend((0..30).map(|i| format!("tail {i}")));
5486            lines
5487        };
5488        let old = version(["u1", "u2", "u3", "u4", "}", "u5", "u6", "u7"]);
5489        let new = version(["v1", "v2", "v3", "v4", "}", "v5", "v6", "v7"]);
5490        let old: Vec<&str> = old.iter().map(String::as_str).collect();
5491        let new: Vec<&str> = new.iter().map(String::as_str).collect();
5492        assert_window_matches_git_view(&old, &new, "frequent brace");
5493        let map = build_line_map(&old, &new, BlameDiffAlgorithm::Myers, true);
5494        assert!(
5495            map[90..98].iter().all(Option::is_none),
5496            "{:?}",
5497            &map[88..100]
5498        );
5499    }
5500
5501    /// Windowed maps equal compacting all of Git's view of the files, for every algorithm.
5502    fn assert_window_matches_git_view(old: &[&str], new: &[&str], label: &str) {
5503        let exact = crate::diff::WhitespaceMode::default();
5504        for algorithm in [BlameDiffAlgorithm::Myers, BlameDiffAlgorithm::Histogram] {
5505            for indent_heuristic in [true, false] {
5506                let affix = slide_safe_affix(old, new, exact);
5507                let tail = blame_trimmed_tail_lines(old, new, affix, exact);
5508                let (old_v, new_v) = (&old[..old.len() - tail], &new[..new.len() - tail]);
5509                let ops = blame_compacted_line_ops(
5510                    old_v,
5511                    new_v,
5512                    std::iter::empty(),
5513                    0,
5514                    algorithm,
5515                    indent_heuristic,
5516                    exact,
5517                );
5518                let mut expected =
5519                    crate::diff_indent_heuristic::map_new_to_old_from_ops(&ops, new_v.len());
5520                expected.extend((old.len() - tail..old.len()).map(Some));
5521                let mut expected_flags =
5522                    crate::diff_indent_heuristic::new_line_changed_flags_from_ops(
5523                        &ops,
5524                        new_v.len(),
5525                    );
5526                expected_flags.resize(new.len(), false);
5527                let (map, flags) =
5528                    windowed_line_map(old, new, affix, algorithm, indent_heuristic, true, exact);
5529                assert_eq!(map, expected, "{label} {algorithm:?} {indent_heuristic}");
5530                assert_eq!(
5531                    flags, expected_flags,
5532                    "{label} {algorithm:?} {indent_heuristic}"
5533                );
5534            }
5535        }
5536    }
5537
5538    #[test]
5539    fn build_line_map_identity_for_equal_files() {
5540        let lines = ["one", "two", "three"];
5541        let map = build_line_map(&lines, &lines, BlameDiffAlgorithm::Histogram, false);
5542        assert_eq!(map, vec![Some(0), Some(1), Some(2)]);
5543    }
5544
5545    #[test]
5546    fn myers_does_not_map_case_changed_line_to_parent() {
5547        let par = ["alpha one", "beta two", " gamma three"];
5548        let cur = ["alpha ONE", "beta two", " gamma three"];
5549        let map = build_line_map(&par, &cur, BlameDiffAlgorithm::Myers, false);
5550        assert_eq!(map[0], None);
5551    }
5552
5553    #[test]
5554    fn reposition_blame_flags_rotated_head_line_only() {
5555        let par = ["a", "b", "c", "d"];
5556        let cur = ["d", "a", "b", "c"];
5557        let diff = build_line_map(&par, &cur, BlameDiffAlgorithm::Myers, false);
5558        let map = build_exact_relocate_line_map(&par, &cur, &diff);
5559        let xdiff = crate::diff::xdiff_new_line_changed_flags(&par, &cur, false);
5560        let blame = blame_stops_on_current_commit(&diff, &map, &xdiff);
5561        assert_eq!(blame, vec![true, false, false, false]);
5562    }
5563
5564    #[test]
5565    fn reposition_blame_swapped_inserted_line_in_three_line_file() {
5566        let par = ["alpha ONE", "inserted", " gamma three"];
5567        let cur = ["alpha ONE", " gamma three", "inserted"];
5568        let diff = build_line_map(&par, &cur, BlameDiffAlgorithm::Myers, false);
5569        let map = build_exact_relocate_line_map(&par, &cur, &diff);
5570        let xdiff = crate::diff::xdiff_new_line_changed_flags(&par, &cur, false);
5571        let blame = blame_stops_on_current_commit(&diff, &map, &xdiff);
5572        assert_eq!(blame, vec![false, false, true]);
5573    }
5574
5575    #[test]
5576    fn reposition_blame_dacb_permutation() {
5577        let par = ["a", "b", "c", "d"];
5578        let cur = ["d", "a", "c", "b"];
5579        let diff = build_line_map(&par, &cur, BlameDiffAlgorithm::Myers, false);
5580        let map = build_exact_relocate_line_map(&par, &cur, &diff);
5581        let xdiff = crate::diff::xdiff_new_line_changed_flags(&par, &cur, false);
5582        let blame = blame_stops_on_current_commit(&diff, &map, &xdiff);
5583        assert_eq!(blame, vec![true, false, false, true]);
5584    }
5585
5586    #[test]
5587    fn reposition_blame_flags_same_index_diff_insertion() {
5588        let par = ["a", "b", "c", "d"];
5589        let cur = ["a", "d", "c", "b"];
5590        let diff = build_line_map(&par, &cur, BlameDiffAlgorithm::Myers, false);
5591        let map = build_exact_relocate_line_map(&par, &cur, &diff);
5592        let xdiff = crate::diff::xdiff_new_line_changed_flags(&par, &cur, false);
5593        let blame = blame_stops_on_current_commit(&diff, &map, &xdiff);
5594        assert_eq!(blame, vec![false, false, true, true]);
5595    }
5596
5597    #[test]
5598    fn fuzzy_line_map_recovers_moved_unchanged_line() {
5599        let par = ["alpha ONE", "inserted", " gamma three"];
5600        let cur = ["alpha ONE", " gamma three", "inserted"];
5601        let map = build_exact_relocate_line_map(
5602            &par,
5603            &cur,
5604            &build_line_map(&par, &cur, BlameDiffAlgorithm::Myers, false),
5605        );
5606        assert_eq!(map[1], Some(2));
5607    }
5608
5609    #[test]
5610    fn partition_pending_by_affix_maps_prefix_and_suffix() {
5611        let old = BlobLines::from_content("a\nb\nc\nd\ne\n".to_owned());
5612        let new = BlobLines::from_content("a\nb\nx\nd\ne\n".to_owned());
5613        let pending = vec![TrackedLine {
5614            final_lineno: 1,
5615            current_idx: 0,
5616            ignored: false,
5617            source_path: None,
5618            origin: 0,
5619        }];
5620        let exact = crate::diff::WhitespaceMode::default();
5621        let (affix, need) = partition_pending_by_affix(
5622            slide_safe_affix(&old, &new, exact),
5623            old.len(),
5624            new.len(),
5625            pending,
5626        );
5627        assert!(need.is_empty());
5628        assert_eq!(affix[0].current_idx, 0);
5629
5630        let pending_mid = vec![TrackedLine {
5631            final_lineno: 3,
5632            current_idx: 2,
5633            ignored: false,
5634            source_path: None,
5635            origin: 0,
5636        }];
5637        let (affix, need) = partition_pending_by_affix(
5638            slide_safe_affix(&old, &new, exact),
5639            old.len(),
5640            new.len(),
5641            pending_mid,
5642        );
5643        assert!(affix.is_empty());
5644        assert_eq!(need.len(), 1);
5645    }
5646
5647    #[test]
5648    fn blame_result_hunks_group_contiguous_runs() {
5649        use crate::objects::ObjectId;
5650
5651        let c1 = ObjectId::from_hex("1111111111111111111111111111111111111111").unwrap();
5652        let c2 = ObjectId::from_hex("2222222222222222222222222222222222222222").unwrap();
5653        let result = BlameResult {
5654            lines: vec![
5655                BlameAttributedLine {
5656                    commit: c1,
5657                    original_line: 1,
5658                    final_line: 1,
5659                    original_path: "a.txt".to_owned(),
5660                    content: "one".to_owned(),
5661                    encoding_lossy: false,
5662                    content_bytes: None,
5663                    ignored: false,
5664                    unblamable: false,
5665                    not_committed: false,
5666                },
5667                BlameAttributedLine {
5668                    commit: c1,
5669                    original_line: 2,
5670                    final_line: 2,
5671                    original_path: "a.txt".to_owned(),
5672                    content: "two".to_owned(),
5673                    encoding_lossy: false,
5674                    content_bytes: None,
5675                    ignored: false,
5676                    unblamable: false,
5677                    not_committed: false,
5678                },
5679                BlameAttributedLine {
5680                    commit: c2,
5681                    original_line: 4,
5682                    final_line: 3,
5683                    original_path: "b.txt".to_owned(),
5684                    content: "three".to_owned(),
5685                    encoding_lossy: false,
5686                    content_bytes: None,
5687                    ignored: false,
5688                    unblamable: false,
5689                    not_committed: false,
5690                },
5691            ],
5692            commits: HashMap::new(),
5693        };
5694        let hunks = result.hunks();
5695        assert_eq!(hunks.len(), 2);
5696        assert_eq!(hunks[0].count, 2);
5697        assert_eq!(hunks[0].original_start, 1);
5698        assert_eq!(hunks[0].final_start, 1);
5699        assert_eq!(hunks[1].count, 1);
5700        assert_eq!(hunks[1].original_path, "b.txt");
5701    }
5702
5703    #[test]
5704    fn parse_diff_algorithm_name_known_and_unknown() {
5705        assert_eq!(
5706            parse_diff_algorithm_name("myers"),
5707            Some(BlameDiffAlgorithm::Myers)
5708        );
5709        assert_eq!(
5710            parse_diff_algorithm_name("histogram"),
5711            Some(BlameDiffAlgorithm::Histogram)
5712        );
5713        assert_eq!(parse_diff_algorithm_name("nope"), None);
5714    }
5715
5716    #[test]
5717    fn blame_file_resolves_nested_path_in_wide_root_tree() -> Result<()> {
5718        use crate::objects::{serialize_tree, tree_entry_cmp, ObjectKind, TreeEntry};
5719        use crate::repo::init_repository;
5720        use crate::RefStorageFormat;
5721        use std::fs;
5722        use tempfile::tempdir;
5723
5724        let dir = tempdir().map_err(crate::error::Error::Io)?;
5725        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5726        let hello = repo.odb().write(ObjectKind::Blob, b"hello\n")?;
5727        let mut sub_body = b"100644 inner.txt\0".to_vec();
5728        sub_body.extend_from_slice(hello.as_bytes());
5729        let sub_tree = repo.odb().write(ObjectKind::Tree, &sub_body)?;
5730        let filler = repo.odb().write(ObjectKind::Blob, b"1\n")?;
5731        let mut entries: Vec<TreeEntry> = (1..=60)
5732            .map(|i| TreeEntry {
5733                mode: 0o100644,
5734                name: format!("f{i}.txt").into_bytes(),
5735                oid: filler,
5736            })
5737            .collect();
5738        entries.push(TreeEntry {
5739            mode: 0o040000,
5740            name: b"sub".to_vec(),
5741            oid: sub_tree,
5742        });
5743        entries.sort_by(|a, b| {
5744            tree_entry_cmp(&a.name, a.mode == 0o040000, &b.name, b.mode == 0o040000)
5745        });
5746        let root_tree = repo
5747            .odb()
5748            .write(ObjectKind::Tree, &serialize_tree(&entries))?;
5749        let commit_body = format!(
5750            "tree {root_tree}\nauthor T <t@t.com> 1 +0000\ncommitter T <t@t.com> 1 +0000\n\ninit\n"
5751        );
5752        let tip = repo
5753            .odb()
5754            .write(ObjectKind::Commit, commit_body.as_bytes())?;
5755        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
5756            .map_err(crate::error::Error::Io)?;
5757        let result = blame_file(
5758            &repo,
5759            "sub/inner.txt",
5760            &BlameOptions {
5761                start: Some(tip),
5762                ..Default::default()
5763            },
5764        )?;
5765        assert_eq!(result.lines.len(), 1);
5766        assert_eq!(result.lines[0].content, "hello");
5767        Ok(())
5768    }
5769
5770    fn write_file_commit(
5771        repo: &Repository,
5772        path: &str,
5773        lines: &[String],
5774        parents: &[ObjectId],
5775        time: i64,
5776    ) -> Result<ObjectId> {
5777        use crate::objects::ObjectKind;
5778        let content: String = lines.iter().map(|l| format!("{l}\n")).collect();
5779        let blob = repo.odb().write(ObjectKind::Blob, content.as_bytes())?;
5780        let mut tree_body = format!("100644 {path}\0").into_bytes();
5781        tree_body.extend_from_slice(blob.as_bytes());
5782        let tree = repo.odb().write(ObjectKind::Tree, &tree_body)?;
5783        let mut body = format!("tree {tree}\n");
5784        for parent in parents {
5785            body.push_str(&format!("parent {parent}\n"));
5786        }
5787        body.push_str(&format!(
5788            "author T <t@e.com> {time} +0000\ncommitter T <t@e.com> {time} +0000\n\nc{time}\n"
5789        ));
5790        repo.odb().write(ObjectKind::Commit, body.as_bytes())
5791    }
5792
5793    /// Side branches that keep a mainline line's older text hand that line to the merge's
5794    /// second parent, whose history runs back into the mainline. Every pair must still be
5795    /// diffed once: suspects reaching the same commit are merged, not walked again.
5796    #[test]
5797    fn merge_ladder_diffs_each_parent_pair_once() -> Result<()> {
5798        use crate::repo::init_repository;
5799        use crate::RefStorageFormat;
5800
5801        let dir = tempfile::tempdir().map_err(crate::error::Error::Io)?;
5802        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5803        let path = "ladder.txt";
5804        let base: Vec<String> = (1..=40).map(|i| format!("base {i}")).collect();
5805        let root = write_file_commit(&repo, path, &base, &[], 1000)?;
5806        let mut tip = root;
5807        let mut tip_lines = base.clone();
5808        let mut main_commits = Vec::new();
5809        let mut side_commits = Vec::new();
5810        for k in 1..=8_usize {
5811            let t = 1000 + 10 * k as i64;
5812            let mut main_lines = tip_lines.clone();
5813            main_lines[k - 1] = format!("main {k}");
5814            main_lines[9 + k] = format!("kept main {k}");
5815            let main = write_file_commit(&repo, path, &main_lines, &[tip], t)?;
5816            let mut side_lines = tip_lines.clone();
5817            side_lines[19 + k] = format!("side {k}");
5818            let side = write_file_commit(&repo, path, &side_lines, &[tip], t + 1)?;
5819            // The merge keeps main's second edit but the side's (older) text of line `k`.
5820            let mut merged = side_lines.clone();
5821            merged[9 + k] = main_lines[9 + k].clone();
5822            tip = write_file_commit(&repo, path, &merged, &[main, side], t + 2)?;
5823            tip_lines = merged;
5824            main_commits.push(main);
5825            side_commits.push(side);
5826        }
5827
5828        let thread_policy = repo.thread_policy();
5829        let mut session = BlameSession::new(repo.odb(), None, false, &thread_policy);
5830        let lines = compute_blame_with_session(
5831            repo.odb(),
5832            tip,
5833            "tip",
5834            path,
5835            &HashSet::new(),
5836            BlameDiffAlgorithm::Myers,
5837            None,
5838            false,
5839            0,
5840            false,
5841            &HashMap::new(),
5842            false,
5843            None,
5844            None,
5845            &mut session,
5846        )?;
5847
5848        assert_eq!(lines.len(), 40);
5849        for line in &lines {
5850            let expected = match line.final_lineno {
5851                n @ 11..=18 => main_commits[n - 11],
5852                n @ 21..=28 => side_commits[n - 21],
5853                _ => root,
5854            };
5855            assert_eq!(line.oid, expected, "line {}", line.final_lineno);
5856        }
5857        let unique: HashSet<_> = session.diffed_pairs.iter().collect();
5858        assert_eq!(
5859            unique.len(),
5860            session.diffed_pairs.len(),
5861            "a parent/child pair was diffed more than once"
5862        );
5863        Ok(())
5864    }
5865
5866    #[test]
5867    fn copy_detection_from_count_maps_git_levels() -> Result<()> {
5868        assert_eq!(CopyDetection::from_count(0)?, CopyDetection::Off);
5869        assert_eq!(
5870            CopyDetection::from_count(1)?,
5871            CopyDetection::FromModifiedFiles
5872        );
5873        assert_eq!(
5874            CopyDetection::from_count(2)?,
5875            CopyDetection::FromCreatingCommit
5876        );
5877        assert_eq!(CopyDetection::from_count(3)?, CopyDetection::FromAnyCommit);
5878        assert!(CopyDetection::from_count(4).is_err());
5879        Ok(())
5880    }
5881
5882    #[test]
5883    fn blame_file_walks_linear_history() -> Result<()> {
5884        use crate::objects::ObjectKind;
5885        use crate::repo::init_repository;
5886        use crate::RefStorageFormat;
5887        use std::fs;
5888        use tempfile::tempdir;
5889
5890        let dir = tempdir().map_err(crate::error::Error::Io)?;
5891        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5892        let path = "trace.txt";
5893        let mut parent: Option<ObjectId> = None;
5894        let mut tip = repo.odb().write(ObjectKind::Blob, b"line0\n")?;
5895        for i in 1..=48_i32 {
5896            let content = format!("line{i}\n");
5897            fs::write(dir.path().join(path), &content).map_err(crate::error::Error::Io)?;
5898            tip = repo.odb().write(ObjectKind::Blob, content.as_bytes())?;
5899            let mut body = format!("100644 {path}\0").into_bytes();
5900            body.extend_from_slice(tip.as_bytes());
5901            let tree = repo.odb().write(ObjectKind::Tree, &body)?;
5902            let mut commit_body = format!("tree {tree}\n");
5903            if let Some(p) = parent {
5904                commit_body.push_str(&format!("parent {p}\n"));
5905            }
5906            commit_body.push_str(&format!(
5907                "author T <t@e.com> {} +0000\ncommitter T <t@e.com> {} +0000\n\nc{i}\n",
5908                100 + i,
5909                100 + i
5910            ));
5911            tip = repo
5912                .odb()
5913                .write(ObjectKind::Commit, commit_body.as_bytes())?;
5914            parent = Some(tip);
5915        }
5916        fs::write(repo.git_dir().join("refs/heads/main"), format!("{tip}\n"))
5917            .map_err(crate::error::Error::Io)?;
5918        for name in ["myers", "histogram", "minimal", "patience"] {
5919            let _ = parse_diff_algorithm_name(name);
5920        }
5921        let result = blame_file(
5922            &repo,
5923            path,
5924            &BlameOptions {
5925                start: Some(tip),
5926                line_range: Some(1..=10),
5927                ..Default::default()
5928            },
5929        )?;
5930        assert!(!result.lines.is_empty());
5931        Ok(())
5932    }
5933
5934    #[test]
5935    fn stored_path_lookup_preserves_raw_names_and_cache_keys() -> Result<()> {
5936        use crate::objects::{serialize_tree, TreeEntry};
5937        use crate::repo::init_repository;
5938        use crate::RefStorageFormat;
5939
5940        let dir = tempfile::tempdir()?;
5941        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
5942        let mut entries = Vec::new();
5943        let names: &[&[u8]] = &[
5944            b"ab",
5945            b"grit+pathhex:6162",
5946            b"raw\xfe",
5947            b"raw\xff",
5948            "raw\u{fffd}".as_bytes(),
5949        ];
5950        for name in names {
5951            let oid = repo.odb().write(ObjectKind::Blob, name)?;
5952            entries.push(TreeEntry {
5953                mode: 0o100644,
5954                name: name.to_vec(),
5955                oid,
5956            });
5957        }
5958        entries.sort_by(|a, b| a.name.cmp(&b.name));
5959        let subtree = repo
5960            .odb()
5961            .write(ObjectKind::Tree, &serialize_tree(&entries))?;
5962        let root = repo.odb().write(
5963            ObjectKind::Tree,
5964            &serialize_tree(&[TreeEntry {
5965                mode: 0o040000,
5966                name: b"dir\xff".to_vec(),
5967                oid: subtree,
5968            }]),
5969        )?;
5970        let policy = ThreadPolicy::default();
5971        let mut session = BlameSession::new(repo.odb(), None, false, &policy);
5972        for entry in entries {
5973            let raw = crate::repo_relpath::join_rel(b"dir\xff", &entry.name);
5974            let stored = storage_string(&raw);
5975            let expected = Some((entry.oid, entry.mode));
5976            assert_eq!(session.resolve_path_in_tree(root, &stored)?, expected);
5977            assert_eq!(
5978                resolve_path_in_tree_entry(repo.odb(), &root, &stored)?,
5979                expected
5980            );
5981            let count = session.path_entries.len();
5982            let uppercase = format!(
5983                "grit+pathhex:{}",
5984                stored
5985                    .strip_prefix("grit+pathhex:")
5986                    .unwrap_or_default()
5987                    .to_ascii_uppercase()
5988            );
5989            assert_eq!(session.resolve_path_in_tree(root, &uppercase)?, expected);
5990            assert_eq!(session.path_entries.len(), count);
5991        }
5992        Ok(())
5993    }
5994
5995    #[test]
5996    fn stored_path_bloom_keys_hash_raw_directory_prefixes() -> Result<()> {
5997        use crate::bloom::{bloom_key_hashes, BloomFilterSettings};
5998        use crate::repo::init_repository;
5999        use crate::RefStorageFormat;
6000
6001        let dir = tempfile::tempdir()?;
6002        let repo = init_repository(dir.path(), false, "main", None, RefStorageFormat::Files)?;
6003        let policy = ThreadPolicy::default();
6004        let mut session = BlameSession::new(repo.odb(), None, false, &policy);
6005        for version in [1, 2] {
6006            let settings = BloomFilterSettings {
6007                hash_version: version,
6008                ..Default::default()
6009            };
6010            for path in [
6011                b"dir\xff/raw\xfe.txt".as_slice(),
6012                b"grit+pathhex:6162/file",
6013                b"utf8/file",
6014            ] {
6015                let slash = path.iter().rposition(|&b| b == b'/').unwrap_or_default();
6016                let expected = vec![
6017                    bloom_key_hashes(path, &settings),
6018                    bloom_key_hashes(&path[..slash], &settings),
6019                ];
6020                let keys = session.bloom_keys_for_path(&storage_string(path), &settings);
6021                assert_eq!(keys.as_ref(), &expected);
6022            }
6023        }
6024        Ok(())
6025    }
6026}