Skip to main content

mkit_core/ops/blame/
mod.rs

1//! Blame.
2//!
3//! Attributes each line of a file to the commit that introduced it. The walk
4//! is **merge-aware** (git's default): it collects the file's ancestor
5//! subgraph from the head commit, processes commits oldest → newest (parents
6//! before children), and at a merge passes each line to the first parent
7//! that still contains it — so a line merged in from a side branch is
8//! credited to the commit that wrote it, not the merge.
9//! [`BlameOptions::first_parent`] restricts the walk to first parents,
10//! reproducing the older linear-history attribution. Reverse blame
11//! ([`blame_file_reverse`]) is first-parent only by definition.
12//!
13//! Line matching uses a simple LCS DP table. For typical source files
14//! (a few thousand lines) this is fine; binary blobs / generated code
15//! are not in scope.
16//!
17//! Output formatting (used by goldens) is `<short>\t<line_num>\t<text>`,
18//! where `<short>` is the 12-char prefix of the commit hash. See
19//! [`format_blame_text`].
20
21use std::collections::{HashMap, HashSet};
22use std::fmt::Write as _;
23use std::rc::Rc;
24use std::sync::Arc;
25
26use crate::hash::{self, Hash};
27use crate::object::{EntryMode, Identity, Object};
28use crate::store::ObjectStore;
29
30mod move_copy;
31mod walk;
32
33use walk::{WalkCtx, attribute_commit, build_file_dag, topo_order};
34
35/// Hard cap on the per-side line count fed to the LCS matcher. The DP
36/// table is O(m*n) u32 entries: at 100 000 lines × 100 000 lines this
37/// is ≈ 40 GiB, so we refuse anything past this limit rather than let
38/// an attacker-supplied blob drive the decoder into swap/OOM.
39pub const BLAME_MAX_LINES: usize = 100_000;
40
41/// Per-line blame attribution.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct BlameLine {
44    /// 1-based line number in the final blob.
45    pub line_num: usize,
46    /// 1-based line number in the origin commit's version of the file —
47    /// git porcelain's "original line number". Equals [`Self::line_num`]
48    /// unless lines were inserted/removed above this one after it was
49    /// introduced, or it was copied in from another file (then it is the
50    /// line number in that source).
51    pub orig_line_num: usize,
52    /// Commit that last touched this line.
53    pub commit_hash: Hash,
54    /// Author Identity of `commit_hash`, deep-copied from the commit
55    /// object so the result is self-contained.
56    pub author: Identity,
57    /// Commit timestamp.
58    pub timestamp: u64,
59    /// The origin commit is a file-history root (no relevant parent still
60    /// has the file) — git porcelain's `boundary` marker.
61    pub boundary: bool,
62    /// Source file path when this line was copied from **another** file
63    /// (`-C`); `None` when it lives in the blamed path. Feeds git
64    /// porcelain's `filename` field.
65    pub source_path: Option<String>,
66    /// Final line text (no trailing newline).
67    pub text: Vec<u8>,
68}
69
70/// Result of [`blame_file`]: per-line attributions in 1..=N order.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct BlameResult {
73    pub lines: Vec<BlameLine>,
74}
75
76/// Detect lines moved **within a file** (git `-M`). Each `On` state
77/// carries its own threshold, so there is no way to express an invalid
78/// "enabled but zero-threshold" state — [`Default`] is [`Off`].
79///
80/// [`Off`]: MoveDetection::Off
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
82pub enum MoveDetection {
83    /// No within-file move detection.
84    #[default]
85    Off,
86    /// Credit a moved block of at least `threshold` alphanumeric
87    /// characters to its origin.
88    On {
89        /// Minimum alphanumeric characters for a block to qualify.
90        threshold: usize,
91    },
92}
93
94impl MoveDetection {
95    /// git's default `-M` (threshold 20 alphanumeric characters).
96    pub const GIT_DEFAULT: Self = Self::On { threshold: 20 };
97}
98
99/// Detect lines copied **from other files** (git `-C`). `On` carries a
100/// search `level` (1 = files changed in the same commit; 2+ = every file
101/// in the parent commit) and a `threshold`. [`Default`] is [`Off`].
102///
103/// [`Off`]: CopyDetection::Off
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105pub enum CopyDetection {
106    /// No cross-file copy detection.
107    #[default]
108    Off,
109    /// Credit a copied block of at least `threshold` alphanumeric
110    /// characters to its origin, searching at the given `level`.
111    On {
112        /// Search breadth: 1 = files changed in the commit; 2+ = every
113        /// file in the parent commit.
114        level: u8,
115        /// Minimum alphanumeric characters for a block to qualify.
116        threshold: usize,
117    },
118}
119
120impl CopyDetection {
121    /// git's default `-C` at the given level (threshold 40).
122    #[must_use]
123    pub const fn git_default(level: u8) -> Self {
124        Self::On {
125            level,
126            threshold: 40,
127        }
128    }
129}
130
131/// Knobs controlling how [`blame_file_with`] attributes lines. The
132/// default (no `-w`, detection off, empty ignore set) reproduces
133/// [`blame_file`]'s exact-match behavior; this struct is the extension
134/// point for the blame parity work (`-w`, `-M`, `-C`, ignore-revs today;
135/// `--reverse` to follow).
136///
137/// Not `Copy`: [`Self::ignore_revs`] owns an `Arc`. It is passed by
138/// reference (`&BlameOptions`) on the hot path; the `Arc` lets copy-source
139/// blames share the same set without re-cloning it.
140#[derive(Debug, Clone, Default)]
141pub struct BlameOptions {
142    /// Ignore whitespace when matching a line against its parent
143    /// revision, so a whitespace-only edit (reindent, tab↔space, spacing
144    /// tweak) does not reattribute the line. Mirrors `git blame -w`,
145    /// which ignores *all* whitespace, not just runs of it.
146    pub ignore_whitespace: bool,
147    /// Within-file move detection (git `-M`).
148    pub moves: MoveDetection,
149    /// Cross-file copy detection (git `-C`). `On` implies move detection
150    /// even when [`Self::moves`] is [`MoveDetection::Off`] — git's `-C`
151    /// implies `-M`.
152    pub copies: CopyDetection,
153    /// Commits to skip during attribution, like `git blame --ignore-rev`
154    /// / `--ignore-revs-file`. When a line would be credited to a commit
155    /// in this set (a mass-reformat / license-header / rename "noise"
156    /// commit), blame falls through to the previous commit that actually
157    /// changed the line. A commit whose lines have no counterpart in its
158    /// parent (a genuine insertion) stays on the ignored commit, matching
159    /// git's default (no `blame.markUnblamableLines` marker).
160    ///
161    /// Behind an [`Arc`] so a `-C` copy-source blame can share the same set
162    /// (it inherits the active ignore-revs) with an `O(1)` refcount bump
163    /// rather than deep-cloning the whole set per source.
164    pub ignore_revs: Arc<HashSet<Hash>>,
165    /// Refine `--ignore-rev` fall-through with content matching instead of
166    /// git's positional per-hunk guess (mkit-only, opt-in; no-op unless
167    /// [`Self::ignore_revs`] is non-empty). git pairs a fallen-through line
168    /// with whatever line sits at the same offset in the hunk, because a
169    /// textual diff is all it has; mkit hashes line content, so it can
170    /// often identify the line's *true* surviving origin even when a
171    /// reformat/reorder moved it to a different offset — e.g. a
172    /// moved-and-reindented line matched to its real origin rather than to
173    /// whatever happens to sit at the same position.
174    ///
175    /// The refinement is **never worse than git's positional
176    /// `--ignore-rev`, by construction**: a line whose positional guess is
177    /// already a real parent line (`Some`) is only re-pointed when the
178    /// content evidence is a *genuine moved block* — a run of ≥ 2
179    /// file-adjacent lines matching contiguously — never for an isolated
180    /// single-line key coincidence (which could otherwise land an edited
181    /// line on an unrelated duplicate, strictly worse than positional). A
182    /// line the positional pass left unattributed (`None` — a genuine
183    /// insertion) may additionally be filled from a single exact-content
184    /// match anywhere in the parent, since that only ever improves on
185    /// "credited to the ignored commit". Trivial keys (blank lines,
186    /// `}`-only lines, sub-3-byte tokens) are never reattributed. When no
187    /// qualifying evidence exists the result is identical to the positional
188    /// default, so every line is attributed at least as well as plain
189    /// `--ignore-rev`.
190    ///
191    /// The default (`false`) keeps `--ignore-rev` byte-identical to git —
192    /// this is a documented divergence, not a change to the default.
193    pub ignore_rev_precise: bool,
194    /// Follow only each commit's first parent, like `git blame
195    /// --first-parent`. The default (`false`) is git's merge-aware walk:
196    /// at a merge, a line is credited to whichever parent's side actually
197    /// wrote it (the first parent that still contains it), so a line merged
198    /// in from a side branch is attributed to its authoring commit rather
199    /// than the merge. With `--first-parent`, the walk follows the
200    /// first-parent chain only, so such a line is credited to the merge
201    /// commit — the older linear-history behavior.
202    pub first_parent: bool,
203}
204
205impl BlameOptions {
206    /// The effective `-M` mode: explicit [`Self::moves`] if set, else the
207    /// git default when copy detection is on (git's `-C` implies `-M`),
208    /// else off.
209    fn effective_move(&self) -> MoveDetection {
210        match self.moves {
211            MoveDetection::On { .. } => self.moves,
212            MoveDetection::Off if matches!(self.copies, CopyDetection::On { .. }) => {
213                MoveDetection::GIT_DEFAULT
214            }
215            MoveDetection::Off => MoveDetection::Off,
216        }
217    }
218
219    /// Whether any move/copy detection is requested.
220    fn detection_enabled(&self) -> bool {
221        matches!(self.effective_move(), MoveDetection::On { .. })
222            || matches!(self.copies, CopyDetection::On { .. })
223    }
224
225    /// Whether `commit` is in the [`Self::ignore_revs`] skip set.
226    #[must_use]
227    fn is_ignored(&self, commit: &Hash) -> bool {
228        self.ignore_revs.contains(commit)
229    }
230}
231
232/// A line's resolved origin: the commit that introduced it, with the
233/// author/timestamp copied so the result is self-contained.
234#[derive(Clone)]
235struct Attribution {
236    commit_hash: Hash,
237    author: Identity,
238    timestamp: u64,
239    /// 1-based line number in the origin commit's version of the file
240    /// (git porcelain's original line number). Propagates unchanged as the
241    /// line is carried back through history.
242    orig_line_num: usize,
243    /// The origin commit is a file-history root (git porcelain `boundary`).
244    boundary: bool,
245    /// Set when this line was copied from another file (`-C`); the source
246    /// path. `None` for a line living in the blamed path.
247    source_path: Option<String>,
248}
249
250impl From<BlameLine> for Attribution {
251    fn from(l: BlameLine) -> Self {
252        Self {
253            commit_hash: l.commit_hash,
254            author: l.author,
255            timestamp: l.timestamp,
256            orig_line_num: l.orig_line_num,
257            boundary: l.boundary,
258            source_path: l.source_path,
259        }
260    }
261}
262
263/// Errors raised by this module.
264#[derive(Debug, thiserror::Error)]
265pub enum BlameError {
266    #[error("requested object is not a commit")]
267    NotACommit,
268    #[error("requested object is not a blob or chunked-blob")]
269    NotABlob,
270    #[error("file '{0}' was not found at any commit in history")]
271    FileNotFound(String),
272    /// `--reverse`: the requested `<start>` is not a first-parent ancestor
273    /// of `<end>`, so there is no forward chain to walk between them.
274    #[error("reverse blame: '{start}' is not a first-parent ancestor of '{end}'")]
275    ReverseRange { start: String, end: String },
276    /// Either side of the LCS input exceeded [`BLAME_MAX_LINES`].
277    /// Returned rather than allocating a DP table proportional to the
278    /// attacker-supplied line counts.
279    #[error("file has too many lines for blame ({lines} > {max})", max = BLAME_MAX_LINES)]
280    FileTooLarge { lines: usize },
281    #[error(transparent)]
282    Object(#[from] crate::object::MkitError),
283    #[error(transparent)]
284    Store(#[from] crate::store::StoreError),
285}
286
287/// Fallible-result alias for this module's operations.
288pub type BlameOutcome<T> = Result<T, BlameError>;
289
290/// Blame `file_path` at `head_hash` with default options (exact line
291/// matching). Convenience wrapper over [`blame_file_with`].
292///
293/// # Errors
294/// See [`blame_file_with`].
295pub fn blame_file(
296    store: &ObjectStore,
297    head_hash: Hash,
298    file_path: &str,
299) -> BlameOutcome<BlameResult> {
300    blame_file_with(store, head_hash, file_path, &BlameOptions::default())
301}
302
303/// Blame `file_path` at `head_hash`. Walks the file's ancestor subgraph in
304/// topological order, attributing each line to the commit that introduced it.
305/// The default is git's **merge-aware** walk (a line merged from a side branch
306/// is credited to the commit that wrote it); [`BlameOptions::first_parent`]
307/// restricts the walk to first parents. `opts` also tunes matching (`-w`,
308/// `-M`/`-C`, `--ignore-rev`).
309///
310/// # Errors
311/// - [`BlameError::FileNotFound`] if the file does not exist at `head_hash`.
312/// - [`BlameError::NotACommit`] if `head_hash` is not a commit object.
313/// - [`BlameError::FileTooLarge`] if any blob fed to the matcher has more than
314///   [`BLAME_MAX_LINES`] lines.
315///
316/// # Note
317/// The merge-aware walk processes the file's whole ancestor subgraph (every
318/// merge parent that still has the file), so unlike git's backward queue —
319/// which stops once every line is attributed — it can read the file's entire
320/// history (potentially `O(all file-touching commits)`) even after the head's
321/// lines are resolved. Per-commit attribution memos are `Rc`-shared and
322/// released as soon as a commit's children are done, so peak memory stays
323/// bounded; `--first-parent` is the escape hatch for very large histories. A
324/// future optimization could prune to line-owning commits the way git's
325/// backward blame queue does.
326///
327/// # Panics
328/// Never in practice: the head commit is the last node in topological order
329/// and is never another commit's parent, so its attribution memo is still
330/// present when the result is materialized.
331pub fn blame_file_with(
332    store: &ObjectStore,
333    head_hash: Hash,
334    file_path: &str,
335    opts: &BlameOptions,
336) -> BlameOutcome<BlameResult> {
337    // The head must contain the file (`FileNotFound` otherwise).
338    let Object::Commit(head_commit) = store.read_object(&head_hash)? else {
339        return Err(BlameError::NotACommit);
340    };
341    if find_blob_in_tree(store, head_commit.tree_hash, file_path)?.is_none() {
342        return Err(BlameError::FileNotFound(file_path.to_string()));
343    }
344
345    let (nodes, children) = build_file_dag(store, head_hash, file_path, opts.first_parent)?;
346    let order = topo_order(&nodes, head_hash);
347
348    let ctx = WalkCtx {
349        store,
350        opts,
351        nodes: &nodes,
352        file_path,
353    };
354    // The detector owns its own caches and is a no-op when detection is off.
355    let mut detector = move_copy::Detector::new(store, opts);
356    let mut memo: HashMap<Hash, Rc<[Attribution]>> = HashMap::with_capacity(nodes.len());
357    let mut remaining = children;
358
359    for &commit in &order {
360        let attrs = attribute_commit(&ctx, &memo, &mut detector, commit)?;
361        memo.insert(commit, attrs);
362        // Release a parent's memo once its last child has been attributed.
363        for &parent in &nodes[&commit].parents {
364            if let Some(left) = remaining.get_mut(&parent) {
365                *left -= 1;
366                if *left == 0 {
367                    memo.remove(&parent);
368                }
369            }
370        }
371    }
372
373    let head_attrs = memo
374        .get(&head_hash)
375        .expect("head is processed last and is never a parent, so never freed");
376    let final_lines = load_blob_lines(store, nodes[&head_hash].blob_hash)?;
377    let mut out = Vec::with_capacity(final_lines.len());
378    for (i, text) in final_lines.into_iter().enumerate() {
379        let a = &head_attrs[i];
380        out.push(BlameLine {
381            line_num: i + 1,
382            orig_line_num: a.orig_line_num,
383            commit_hash: a.commit_hash,
384            author: a.author.clone(),
385            timestamp: a.timestamp,
386            boundary: a.boundary,
387            source_path: a.source_path.clone(),
388            text,
389        });
390    }
391    Ok(BlameResult { lines: out })
392}
393
394/// One step of the reverse blame's forward chain: the commit and the blob
395/// the path resolves to there (`None` if the file is absent at that commit).
396struct ReverseEntry {
397    commit_hash: Hash,
398    blob: Option<Hash>,
399    author: Identity,
400    timestamp: u64,
401}
402
403impl From<&ReverseEntry> for Attribution {
404    fn from(e: &ReverseEntry) -> Self {
405        Self {
406            commit_hash: e.commit_hash,
407            author: e.author.clone(),
408            timestamp: e.timestamp,
409            // Reverse blame doesn't track porcelain origin fields; the
410            // final `BlameLine` fills sensible defaults (orig = final line,
411            // no boundary/copy). `-M`/`-C` are rejected under `--reverse`.
412            orig_line_num: 0,
413            boundary: false,
414            source_path: None,
415        }
416    }
417}
418
419/// Collect the first-parent chain from `end` down to `start`, returned
420/// **oldest-first** (`[0]` is `start`, last is `end`). Unlike forward blame
421/// this does not stop where the file disappears — a gap in the file's
422/// presence kills lines but must not truncate the walk before `start`.
423///
424/// # Errors
425/// [`BlameError::ReverseRange`] if `start` is not reached on `end`'s
426/// first-parent chain.
427fn collect_reverse_chain(
428    store: &ObjectStore,
429    start_hash: Hash,
430    end_hash: Hash,
431    file_path: &str,
432) -> BlameOutcome<Vec<ReverseEntry>> {
433    let mut chain: Vec<ReverseEntry> = Vec::new();
434    let mut current = Some(end_hash);
435    let mut reached_start = false;
436    while let Some(commit_hash) = current {
437        let Object::Commit(commit) = store.read_object(&commit_hash)? else {
438            return Err(BlameError::NotACommit);
439        };
440        let blob = find_blob_in_tree(store, commit.tree_hash, file_path)?;
441        chain.push(ReverseEntry {
442            commit_hash,
443            blob,
444            author: commit.author.clone(),
445            timestamp: commit.timestamp,
446        });
447        if commit_hash == start_hash {
448            reached_start = true;
449            break;
450        }
451        current = commit.parents.first().copied();
452    }
453    if !reached_start {
454        return Err(BlameError::ReverseRange {
455            start: hash::to_hex(&start_hash),
456            end: hash::to_hex(&end_hash),
457        });
458    }
459    // Reorder to oldest-first so the caller can walk it forward.
460    chain.reverse();
461    Ok(chain)
462}
463
464/// Reverse blame (`git blame --reverse <start>..<end>`): instead of "which
465/// commit introduced each line," answer "what is the **last** commit, in the
466/// range, in which each line of `<start>` still existed."
467///
468/// The lines blamed (and the text in the output) are `<start>`'s version of
469/// `file_path`. The range is followed along `<end>`'s **first-parent** chain
470/// down to `<start>` (mkit blame is first-parent only, like its forward
471/// pass), then walked **forward** (oldest → newest); each start line advances
472/// its attribution to every commit it survives into, and freezes at the last
473/// one before it is changed or removed. A line that does not survive even
474/// the first step stays on `<start>` itself (git prints such a line with a
475/// `^` boundary marker; mkit's tab format carries no `^`, matching its
476/// existing boundary-marker omission). `opts` tunes matching, so `-w`
477/// traces a line through a whitespace-only edit the same way it does for
478/// forward blame.
479///
480/// Independent of `-M`/`-C` and `--ignore-rev`: reverse blame walks line
481/// survival via the LCS matcher only, so those detection options do not
482/// apply here (the CLI rejects the combination).
483///
484/// An empty range (`start_hash == end_hash`) has no step to walk, so every
485/// line is attributed to `start`. git rejects an empty range outright; the
486/// CLI does too (`resolve_reverse_range`), so this only surfaces for direct
487/// core callers.
488///
489/// # Errors
490/// - [`BlameError::ReverseRange`] if `<start>` is not a first-parent
491///   ancestor of `<end>`.
492/// - [`BlameError::FileNotFound`] if `file_path` does not exist at `<start>`.
493/// - [`BlameError::NotACommit`] if either endpoint is not a commit.
494/// - [`BlameError::FileTooLarge`] if any blob on the chain exceeds
495///   [`BLAME_MAX_LINES`].
496pub fn blame_file_reverse(
497    store: &ObjectStore,
498    start_hash: Hash,
499    end_hash: Hash,
500    file_path: &str,
501    opts: &BlameOptions,
502) -> BlameOutcome<BlameResult> {
503    let chain = collect_reverse_chain(store, start_hash, end_hash, file_path)?;
504
505    // The blamed content is `start`'s version of the file.
506    let Some(start_blob) = chain[0].blob else {
507        return Err(BlameError::FileNotFound(file_path.to_string()));
508    };
509    let start_lines = load_blob_lines(store, start_blob)?;
510    check_line_count(start_lines.len())?;
511
512    // For each start line, track its index in the *current* commit's blob
513    // (`None` once the line is gone) and its last-seen attribution, which
514    // begins at `start` and advances forward as the line survives.
515    let mut cur_idx: Vec<Option<usize>> = (0..start_lines.len()).map(Some).collect();
516    let mut attributions: Vec<Attribution> = vec![Attribution::from(&chain[0]); start_lines.len()];
517    // Count of still-alive lines, so the dead-everything early-exit is O(1)
518    // per step instead of rescanning `cur_idx`.
519    let mut live = start_lines.len();
520
521    let mut prev_blob = start_blob;
522    let mut prev_lines = start_lines.clone();
523    for entry in &chain[1..] {
524        // Every start line is dead and a dead line never resurrects, so
525        // there is nothing left to attribute — stop walking the range.
526        if live == 0 {
527            break;
528        }
529        let newer_attr = Attribution::from(entry);
530        let Some(blob) = entry.blob else {
531            // File absent here: every still-alive line is last seen at the
532            // previous commit. Once dead a line never resurrects.
533            cur_idx.fill(None);
534            live = 0;
535            continue;
536        };
537        if blob == prev_blob {
538            // Unchanged file: every alive line survives and advances.
539            for (j, c) in cur_idx.iter().enumerate() {
540                if c.is_some() {
541                    attributions[j] = newer_attr.clone();
542                }
543            }
544            continue;
545        }
546        let new_lines = load_blob_lines(store, blob)?;
547        // `mapping[ni]` = the prev-blob index that new line `ni` came from;
548        // invert it to "where did each prev line go?".
549        let mapping = match_lines_with_options(&prev_lines, &new_lines, opts)?;
550        let mut prev_to_new: Vec<Option<usize>> = vec![None; prev_lines.len()];
551        for (ni, m) in mapping.iter().enumerate() {
552            if let Some(oi) = *m {
553                prev_to_new[oi] = Some(ni);
554            }
555        }
556        for (j, c) in cur_idx.iter_mut().enumerate() {
557            if let Some(p) = *c {
558                if let Some(q) = prev_to_new.get(p).copied().flatten() {
559                    // Line survives into this commit: advance attribution.
560                    *c = Some(q);
561                    attributions[j] = newer_attr.clone();
562                } else {
563                    // Line is gone here: it was last seen at the previous
564                    // commit, so its attribution stays put.
565                    *c = None;
566                    live -= 1;
567                }
568            }
569        }
570        prev_blob = blob;
571        prev_lines = new_lines;
572    }
573
574    let mut out = Vec::with_capacity(start_lines.len());
575    for (i, text) in start_lines.into_iter().enumerate() {
576        let a = &attributions[i];
577        out.push(BlameLine {
578            line_num: i + 1,
579            // Reverse blame has no origin-side line tracking; use the final
580            // line number so porcelain still emits a coherent header.
581            orig_line_num: i + 1,
582            commit_hash: a.commit_hash,
583            author: a.author.clone(),
584            timestamp: a.timestamp,
585            boundary: false,
586            source_path: None,
587            text,
588        });
589    }
590    Ok(BlameResult { lines: out })
591}
592
593/// Comparison key for a line under the active options: whitespace-stripped
594/// when `-w` is set (so move detection agrees with the `-w` matcher),
595/// otherwise the raw bytes.
596fn line_key(line: &[u8], ignore_whitespace: bool) -> Vec<u8> {
597    if ignore_whitespace {
598        strip_ws(line)
599    } else {
600        line.to_vec()
601    }
602}
603
604/// Walk a `/`-separated tree path and return the leaf blob hash, or
605/// `None` if any component is missing or has the wrong kind.
606///
607/// # Errors
608/// - [`BlameError::Store`] / [`BlameError::Object`] for store failures.
609pub fn find_blob_in_tree(
610    store: &ObjectStore,
611    tree_hash: Hash,
612    path: &str,
613) -> BlameOutcome<Option<Hash>> {
614    let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
615    if components.is_empty() {
616        return Ok(None);
617    }
618    let mut current_tree = tree_hash;
619    for (ci, component) in components.iter().enumerate() {
620        let obj = store.read_object(&current_tree)?;
621        let Object::Tree(tree) = obj else {
622            return Ok(None);
623        };
624        let is_last = ci == components.len() - 1;
625        let mut found_subtree = None;
626        let mut matched = false;
627        for entry in &tree.entries {
628            if entry.name.as_slice() == component.as_bytes() {
629                matched = true;
630                if is_last {
631                    return match entry.mode {
632                        EntryMode::Blob | EntryMode::Executable => Ok(Some(entry.object_hash)),
633                        _ => Ok(None),
634                    };
635                }
636                if entry.mode == EntryMode::Tree {
637                    found_subtree = Some(entry.object_hash);
638                    break;
639                }
640                return Ok(None);
641            }
642        }
643        if !matched {
644            return Ok(None);
645        }
646        if let Some(t) = found_subtree {
647            current_tree = t;
648        }
649    }
650    Ok(None)
651}
652
653/// Load a blob (or chunked-blob) and split into lines (no trailing
654/// newline preserved as a synthetic empty line).
655fn load_blob_lines(store: &ObjectStore, blob_hash: Hash) -> BlameOutcome<Vec<Vec<u8>>> {
656    let obj = store.read_object(&blob_hash)?;
657    let data: Vec<u8> = match obj {
658        Object::Blob(b) => b.data,
659        Object::ChunkedBlob(cb) => {
660            let mut buf: Vec<u8> = Vec::with_capacity(usize::try_from(cb.total_size).unwrap_or(0));
661            for ch in &cb.chunks {
662                let chunk_obj = store.read_object(ch)?;
663                let Object::Blob(b) = chunk_obj else {
664                    return Err(BlameError::NotABlob);
665                };
666                buf.extend_from_slice(&b.data);
667            }
668            cb.check_reassembled_size(buf.len())?;
669            buf
670        }
671        _ => return Err(BlameError::NotABlob),
672    };
673    Ok(split_lines(&data))
674}
675
676/// Whitespace-insensitive comparison key for a line: every ASCII
677/// whitespace byte removed. Matches `git blame -w` (ignore-all-space),
678/// which collapses `foo(a, b)`, `foo(a,b)`, and `    foo(a,  b)` to the
679/// same key so a whitespace-only edit doesn't reattribute the line.
680fn strip_ws(line: &[u8]) -> Vec<u8> {
681    // Rust's `is_ascii_whitespace` is space/\t/\n/\r/\x0C; git's xdiff
682    // `isspace` also treats vertical tab (\x0B) as whitespace, so strip it
683    // too to keep the parity claim exact. (\n is already stripped by the
684    // line split, but include it for completeness.)
685    line.iter()
686        .copied()
687        .filter(|b| !(b.is_ascii_whitespace() || *b == 0x0B))
688        .collect()
689}
690
691/// A content key with fewer than this many non-whitespace bytes is
692/// "trivial" — blank lines, lone `}`/`)`, one- or two-character tokens —
693/// and is never content-reattributed by `--ignore-rev-precise`, so such
694/// lines don't "teleport" to a coincidental duplicate.
695pub(super) const TRIVIAL_KEY_MIN_LEN: usize = 3;
696
697/// Whether a line's content key is trivial for `--ignore-rev-precise`.
698///
699/// The length is measured on the **whitespace-stripped** form regardless of
700/// the `-w` flag: `line_key` only strips whitespace under `-w`, so without
701/// it a reindented `"    }"` is 5 raw bytes and would clear a naive
702/// `key.len() < 3` guard — letting an indented brace teleport. Stripping for
703/// the length check alone (the matching key itself is untouched) keeps the
704/// guard honest either way.
705pub(super) fn is_trivial_key(key: &[u8]) -> bool {
706    strip_ws(key).len() < TRIVIAL_KEY_MIN_LEN
707}
708
709fn split_lines(data: &[u8]) -> Vec<Vec<u8>> {
710    if data.is_empty() {
711        return Vec::new();
712    }
713    let mut out: Vec<Vec<u8>> = data.split(|b| *b == b'\n').map(<[u8]>::to_vec).collect();
714    if data.last().copied() == Some(b'\n') && !out.is_empty() {
715        out.pop();
716    }
717    out
718}
719
720/// Line-correspondence matcher used by the blame replay: given the parent
721/// and child blob lines plus [`BlameOptions`], return for each child line
722/// the matched parent index, or `None` if it is new/changed.
723///
724/// This is the **single, size-checked** matcher and the one place that
725/// owns *matching policy* — the [`BLAME_MAX_LINES`] fast-fail (which is
726/// also the only guard against the O(m·n) DP-table blow-up), `-w`
727/// whitespace normalization, and the position-stable LCS tie-breaking —
728/// so [`blame_file_with`] only has to replay the mapping. It is the
729/// extension point for future matching modes.
730///
731/// # Errors
732/// - [`BlameError::FileTooLarge`] if either side exceeds [`BLAME_MAX_LINES`].
733fn match_lines_with_options(
734    old_lines: &[Vec<u8>],
735    new_lines: &[Vec<u8>],
736    opts: &BlameOptions,
737) -> BlameOutcome<Vec<Option<usize>>> {
738    // Size-check before the DP table (and before any derived per-line
739    // buffers) so an oversized blob fails fast.
740    check_line_count(old_lines.len())?;
741    check_line_count(new_lines.len())?;
742    if opts.ignore_whitespace {
743        // Match on whitespace-stripped keys so a whitespace-only edit
744        // pairs the lines; the caller still emits the raw bytes.
745        let old_keys: Vec<Vec<u8>> = old_lines.iter().map(|l| strip_ws(l)).collect();
746        let new_keys: Vec<Vec<u8>> = new_lines.iter().map(|l| strip_ws(l)).collect();
747        Ok(match_lines(&old_keys, &new_keys))
748    } else {
749        Ok(match_lines(old_lines, new_lines))
750    }
751}
752
753/// For a step whose `newer` commit is *ignored* (`git blame
754/// --ignore-rev`), map each new line to the parent line it should inherit
755/// blame from instead of crediting the ignored commit.
756///
757/// `mapping` is the LCS result (new index → matched old index, or `None`).
758/// The matched anchors split both sides into hunks; within each hunk — a
759/// maximal run of unmatched new lines bounded by anchors — the k-th
760/// unmatched new line is paired positionally with the k-th unmatched old
761/// line in the same hunk. A new line with no counterpart (the hunk added
762/// more lines than it removed) is left `None`, so the caller keeps it on
763/// the ignored commit. This reproduces git's `guess_line_blames` for the
764/// reformat case and its fall-through to unmatched insertions; verified
765/// field-by-field against real `git blame --ignore-rev`.
766fn ignore_fallthrough(mapping: &[Option<usize>], old_len: usize) -> Vec<Option<usize>> {
767    let n = mapping.len();
768    let mut fall: Vec<Option<usize>> = vec![None; n];
769    // `next_old` is the first old index not yet consumed by an anchor; the
770    // unmatched old lines of the current hunk are `[next_old, hunk_end)`,
771    // where `hunk_end` is the old index of the anchor closing the hunk (or
772    // `old_len` for a trailing hunk).
773    let mut next_old = 0usize;
774    let mut ni = 0usize;
775    while ni < n {
776        let Some(anchor_old) = mapping[ni] else {
777            // Start of an unmatched-new run; find where it ends.
778            let hunk_new_start = ni;
779            while ni < n && mapping[ni].is_none() {
780                ni += 1;
781            }
782            // The closing anchor's old index bounds this hunk's unmatched
783            // old lines (`old_len` for a trailing hunk). The loop exits a run
784            // only at a matched anchor, and LCS anchors are increasing, so
785            // this is always `>= next_old`.
786            let hunk_old_end = mapping.get(ni).copied().flatten().unwrap_or(old_len);
787            // Pair the unmatched old lines `[next_old, hunk_old_end)` with
788            // the unmatched new lines positionally; extras stay unpaired.
789            let mut oi = next_old;
790            let mut nj = hunk_new_start;
791            while nj < ni && oi < hunk_old_end {
792                fall[nj] = Some(oi);
793                nj += 1;
794                oi += 1;
795            }
796            next_old = hunk_old_end;
797            continue;
798        };
799        // Anchor: it consumes old index `anchor_old`; the next hunk's
800        // unmatched old lines begin just after it.
801        next_old = anchor_old + 1;
802        ni += 1;
803    }
804    fall
805}
806
807/// Reject a side whose line count would drive the O(m*n) DP table past
808/// [`BLAME_MAX_LINES`]. Shared by the matcher (and reused for the size-cap
809/// regression tests). Checked against the untrimmed lengths, which only
810/// ever bounds [`match_lines_core`]'s trimmed core tighter — see
811/// [`match_lines`].
812fn check_line_count(lines: usize) -> BlameOutcome<()> {
813    if lines > BLAME_MAX_LINES {
814        return Err(BlameError::FileTooLarge { lines });
815    }
816    Ok(())
817}
818
819/// LCS line matching. For each line in `new_lines`, returns the index
820/// in `old_lines` it corresponds to, or `None` for inserted/changed.
821///
822/// Trims the common **leading** run before handing the remainder to
823/// [`match_lines_core`]'s O(m*n) DP table. Blame replays a file's
824/// history one step at a time, and adjacent commits usually touch only
825/// a small, localized run near the end of a much larger unchanged file
826/// (the common "append a function", "add a log line" shape) — so this
827/// shrinks the DP table from the *whole file's* dimensions down to just
828/// the post-prefix remainder, often by orders of magnitude. The trim is
829/// exact, not a heuristic: a common prefix is, by construction, part of
830/// *every* LCS of the full sequences (each such line only ever pairs
831/// with its mirror position at the same index, and the DP's own first
832/// diagonal steps — `dp[i][i] = i` for `i` in the shared run — already
833/// force that pairing), so the result is byte-identical to running the
834/// DP over the untrimmed sequences.
835///
836/// Deliberately **not** symmetric on the trailing side. Trimming a
837/// common trailing run too looks equally safe at first glance, but
838/// isn't: this matcher's backtrack has a specific duplicate tie-break
839/// (prefer the *earliest* new line for a repeated key — see
840/// [`match_lines_core`]'s doc), and trailing-run elision can commit to
841/// a *different*, also-minimal alignment that violates it. Concretely,
842/// `old = ["a"]`, `new = ["b", "a", "a"]`: the real DP backtrack matches
843/// `old[0]` to the *first* `new` "a" (index 1); trimming the shared
844/// trailing "a" first and matching it directly instead leaves `old[0]`
845/// unmatched against the remaining `["b"]` and reattributes to the
846/// *second* "a" (index 2). Same failure mode already caught in
847/// `ops::diff`'s `myers_changed` for the analogous (Myers, not DP-table)
848/// case — see that function's doc for the general shape of the bug. See
849/// `proptest_match_lines_matches_unelided_core` below for the regression
850/// test (differential against [`match_lines_core`] run unelided on the
851/// same input).
852///
853/// NOTE: [`match_lines_core`] allocates an O(m*n) DP table over the
854/// trimmed remainder with no size guard, so it is kept private; all
855/// callers go through the size-checked [`match_lines_with_options`]
856/// entry point.
857#[must_use]
858fn match_lines<T: AsRef<[u8]>>(old_lines: &[T], new_lines: &[T]) -> Vec<Option<usize>> {
859    let m = old_lines.len();
860    let n = new_lines.len();
861
862    let max_prefix = m.min(n);
863    let mut prefix = 0;
864    while prefix < max_prefix && old_lines[prefix].as_ref() == new_lines[prefix].as_ref() {
865        prefix += 1;
866    }
867
868    let mid_mapping = match_lines_core(&old_lines[prefix..], &new_lines[prefix..]);
869
870    let mut mapping = Vec::with_capacity(n);
871    mapping.extend((0..prefix).map(Some));
872    mapping.extend(mid_mapping.into_iter().map(|o| o.map(|i| i + prefix)));
873    mapping
874}
875
876/// The O(m*n) DP core behind [`match_lines`]: plain LCS line matching
877/// with no prefix/suffix elision, over whatever slice it's given.
878#[must_use]
879fn match_lines_core<T: AsRef<[u8]>>(old_lines: &[T], new_lines: &[T]) -> Vec<Option<usize>> {
880    let m = old_lines.len();
881    let n = new_lines.len();
882    // dp is (m+1) x (n+1).
883    let mut dp = vec![vec![0u32; n + 1]; m + 1];
884    for i in 1..=m {
885        for j in 1..=n {
886            if old_lines[i - 1].as_ref() == new_lines[j - 1].as_ref() {
887                dp[i][j] = dp[i - 1][j - 1] + 1;
888            } else {
889                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
890            }
891        }
892    }
893    let mut mapping: Vec<Option<usize>> = vec![None; n];
894    let mut i = m;
895    let mut j = n;
896    // Reconstruct via the dp relations rather than a greedy diagonal-first
897    // rule. When `new[j-1]` isn't required for an optimal LCS
898    // (`dp[i][j] == dp[i][j-1]`), leave it unmatched so that an *earlier*
899    // equal line takes the match instead; likewise drop an unneeded
900    // `old[i-1]`. Only when neither can be dropped is it a true diagonal
901    // match. This keeps duplicate lines — and, under `-w`, lines that are
902    // only whitespace-equal — position-stable, so a unchanged line keeps
903    // its original commit while a genuinely new duplicate is attributed to
904    // the newer one (matching git).
905    while i > 0 && j > 0 {
906        if dp[i][j] == dp[i][j - 1] {
907            j -= 1;
908        } else if dp[i][j] == dp[i - 1][j] {
909            i -= 1;
910        } else {
911            mapping[j - 1] = Some(i - 1);
912            i -= 1;
913            j -= 1;
914        }
915    }
916    mapping
917}
918
919/// Pinned text formatting for goldens. Format:
920///
921/// ```text
922/// <short_hash>\t<line_num>\t<text>\n
923/// ```
924///
925/// where `<short_hash>` is the 12-character lowercase-hex prefix.
926#[must_use]
927pub fn format_blame_text(result: &BlameResult) -> String {
928    let mut out = String::new();
929    for line in &result.lines {
930        let hex = hash::to_hex(&line.commit_hash);
931        let short = &hex[..12];
932        let _ = write!(out, "{}\t{}\t", short, line.line_num);
933        out.push_str(&String::from_utf8_lossy(&line.text));
934        out.push('\n');
935    }
936    out
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use crate::object::{Commit, Identity, Tree, TreeEntry};
943    use crate::serialize;
944    use tempfile::TempDir;
945
946    fn fresh_store() -> (TempDir, ObjectStore) {
947        let dir = TempDir::new().unwrap();
948        let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
949        (dir, store)
950    }
951
952    fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
953        let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
954            data: data.to_vec(),
955        }))
956        .unwrap();
957        store.write(&bytes).unwrap()
958    }
959
960    fn put_single_file_tree(store: &ObjectStore, name: &str, blob: Hash) -> Hash {
961        let tree = Object::Tree(Tree {
962            entries: vec![TreeEntry {
963                name: name.as_bytes().to_vec(),
964                mode: EntryMode::Blob,
965                object_hash: blob,
966            }],
967        });
968        store.write(&serialize::serialize(&tree).unwrap()).unwrap()
969    }
970
971    fn put_file_commit(
972        store: &ObjectStore,
973        filename: &str,
974        content: &[u8],
975        parents: Vec<Hash>,
976        author_mid: u64,
977        ts: u64,
978    ) -> Hash {
979        let blob = put_blob(store, content);
980        let tree = put_single_file_tree(store, filename, blob);
981        let commit = Object::Commit(Commit::new_unannotated(
982            tree,
983            parents,
984            Identity::opaque(author_mid.to_le_bytes()),
985            [0u8; 32],
986            b"msg".to_vec(),
987            ts,
988            [0u8; 64],
989        ));
990        store
991            .write(&serialize::serialize(&commit).unwrap())
992            .unwrap()
993    }
994
995    /// Commit a set of `(filename, content)` files as one tree.
996    fn put_multi_file_commit(
997        store: &ObjectStore,
998        files: &[(&str, &[u8])],
999        parents: Vec<Hash>,
1000        author_mid: u64,
1001        ts: u64,
1002    ) -> Hash {
1003        let mut entries: Vec<TreeEntry> = files
1004            .iter()
1005            .map(|(name, content)| TreeEntry {
1006                name: name.as_bytes().to_vec(),
1007                mode: EntryMode::Blob,
1008                object_hash: put_blob(store, content),
1009            })
1010            .collect();
1011        // Tree entries are stored in name order; the store rejects any
1012        // other ordering on read.
1013        entries.sort_by(|a, b| a.name.cmp(&b.name));
1014        let tree = store
1015            .write(&serialize::serialize(&Object::Tree(Tree { entries })).unwrap())
1016            .unwrap();
1017        let commit = Object::Commit(Commit::new_unannotated(
1018            tree,
1019            parents,
1020            Identity::opaque(author_mid.to_le_bytes()),
1021            [0u8; 32],
1022            b"msg".to_vec(),
1023            ts,
1024            [0u8; 64],
1025        ));
1026        store
1027            .write(&serialize::serialize(&commit).unwrap())
1028            .unwrap()
1029    }
1030
1031    // A line with 22 alphanumeric characters — comfortably over git's
1032    // default -M threshold of 20, so a move of it is detected.
1033    const LONG_LINE: &[u8] = b"let quick_brown_fox_total = 1;";
1034    // Two lines, 42 alphanumeric characters total — over git's default -C
1035    // threshold of 40, so a copy of the block is detected.
1036    const BLOCK_A: &[u8] = b"fn handler_alpha() { compute(); }";
1037    const BLOCK_B: &[u8] = b"fn handler_bravo() { compute(); }";
1038
1039    /// SPEC-OBJECTS §7: "The concatenated length MUST equal `total_size`."
1040    /// Blame loads file content through chunked-blob reassembly, which
1041    /// must reject a manifest whose forged `total_size` disagrees with
1042    /// its (valid) chunks.
1043    #[test]
1044    fn blame_rejects_chunked_total_size_mismatch() {
1045        let (_d, store) = fresh_store();
1046        let chunk = put_blob(&store, b"one line\n");
1047        let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1048            total_size: 4096,
1049            chunk_size: 0,
1050            chunks: vec![chunk],
1051        });
1052        let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1053        let tree = put_single_file_tree(&store, "big.bin", cb_h);
1054        let commit = Object::Commit(Commit::new_unannotated(
1055            tree,
1056            vec![],
1057            Identity::opaque(1u64.to_le_bytes()),
1058            [0u8; 32],
1059            b"msg".to_vec(),
1060            100,
1061            [0u8; 64],
1062        ));
1063        let head = store
1064            .write(&serialize::serialize(&commit).unwrap())
1065            .unwrap();
1066        let err = blame_file(&store, head, "big.bin").unwrap_err();
1067        assert!(
1068            matches!(
1069                err,
1070                BlameError::Object(crate::object::MkitError::ChunkedBlobSizeMismatch {
1071                    expected: 4096,
1072                    actual: 9,
1073                })
1074            ),
1075            "expected ChunkedBlobSizeMismatch, got {err:?}"
1076        );
1077    }
1078
1079    #[test]
1080    fn blame_m_attributes_within_file_move_to_origin() {
1081        // A long line (>= the 20-char -M threshold) is moved to the end of
1082        // the file. Without -M the matcher calls it new (credit c_b); with
1083        // -M it inherits its origin (c_a), matching `git blame -M`.
1084        let (_d, store) = fresh_store();
1085        let v1 = [LONG_LINE, b"B", b"C", b""].join(&b'\n'); // trailing newline
1086        let v2 = [b"B" as &[u8], b"C", LONG_LINE, b""].join(&b'\n');
1087        let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1088        let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1089
1090        let plain = blame_file(&store, c_b, "f.txt").unwrap();
1091        assert_eq!(plain.lines[2].text, LONG_LINE);
1092        assert_eq!(
1093            plain.lines[2].commit_hash, c_b,
1094            "default: moved line is new"
1095        );
1096
1097        let opts = BlameOptions {
1098            moves: MoveDetection::On { threshold: 20 },
1099            ..Default::default()
1100        };
1101        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1102        assert_eq!(m.lines[2].text, LONG_LINE);
1103        assert_eq!(
1104            m.lines[2].commit_hash, c_a,
1105            "-M attributes the moved line to its origin"
1106        );
1107        assert!(
1108            m.lines.iter().all(|l| l.commit_hash == c_a),
1109            "every line predates c_b under -M"
1110        );
1111    }
1112
1113    #[test]
1114    fn blame_m_threshold_boundary_is_inclusive() {
1115        // Boundary: git's `-M<n>` is a *lower bound* (n-or-more alnum
1116        // chars), so a block of exactly the threshold is detected and one
1117        // char short is not. `exact` has exactly 20 alnum chars, `short` 19.
1118        // They are kept non-adjacent (separated by anchors / a new line) so
1119        // each is an independent single-line block, not one merged block.
1120        // Verified against real `git blame -M`.
1121        let (_d, store) = fresh_store();
1122        let exact: &[u8] = b"abcdefghijklmnopqrst"; // 20 alnum
1123        let short: &[u8] = b"abcdefghijklmnopqrs"; // 19 alnum
1124        let v1 = [exact, b"MID", short, b"B", b"C", b""].join(&b'\n');
1125        let v2 = [b"B" as &[u8], b"C", exact, b"NEWX", short, b""].join(&b'\n');
1126        let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1127        let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1128
1129        let opts = BlameOptions {
1130            moves: MoveDetection::On { threshold: 20 },
1131            ..Default::default()
1132        };
1133        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1134        // Child order: B, C, exact, NEWX, short.
1135        assert_eq!(m.lines[2].text, exact);
1136        assert_eq!(
1137            m.lines[2].commit_hash, c_a,
1138            "exactly-threshold (20) move is detected (>= is inclusive)"
1139        );
1140        assert_eq!(m.lines[4].text, short);
1141        assert_eq!(
1142            m.lines[4].commit_hash, c_b,
1143            "one char short of the threshold stays on the editing commit"
1144        );
1145    }
1146
1147    #[test]
1148    fn blame_m_ignores_moves_below_threshold() {
1149        // A short moved line (1 alnum char) is below the threshold, so even
1150        // with -M it stays on the editing commit — matching git, which does
1151        // not associate sub-threshold moves.
1152        let (_d, store) = fresh_store();
1153        let c_a = put_file_commit(&store, "f.txt", b"a\nB\nC\n", vec![], 1, 100);
1154        let c_b = put_file_commit(&store, "f.txt", b"B\nC\na\n", vec![c_a], 2, 200);
1155        let opts = BlameOptions {
1156            moves: MoveDetection::On { threshold: 20 },
1157            ..Default::default()
1158        };
1159        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1160        assert_eq!(m.lines[2].text, b"a");
1161        assert_eq!(
1162            m.lines[2].commit_hash, c_b,
1163            "a sub-threshold move is not detected"
1164        );
1165    }
1166
1167    #[test]
1168    fn blame_m_detects_sub_block_move_adjacent_to_new_line() {
1169        // Review P1: a moved block sitting next to a genuinely-new line.
1170        // Parent: LONG1, LONG2, B, C. Child: B, C, NEW, LONG1, LONG2.
1171        // git -M credits LONG1/LONG2 to the parent and keeps NEW on the
1172        // child; whole-run matching would miss it (the run NEW+LONG1+LONG2
1173        // isn't contiguous in the parent). Verified against real git.
1174        let (_d, store) = fresh_store();
1175        let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
1176        let v2 = [b"B" as &[u8], b"C", b"NEWLINE", LONG_LINE, BLOCK_A, b""].join(&b'\n');
1177        let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1178        let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1179
1180        let opts = BlameOptions {
1181            moves: MoveDetection::On { threshold: 20 },
1182            ..Default::default()
1183        };
1184        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1185        // Child order: B, C, NEWLINE, LONG1, BLOCK_A.
1186        assert_eq!(m.lines[2].text, b"NEWLINE");
1187        assert_eq!(
1188            m.lines[2].commit_hash, c_b,
1189            "the genuinely-new line stays on c_b"
1190        );
1191        assert_eq!(m.lines[3].text, LONG_LINE);
1192        assert_eq!(
1193            m.lines[3].commit_hash, c_a,
1194            "the moved block reverts to c_a"
1195        );
1196        assert_eq!(
1197            m.lines[4].commit_hash, c_a,
1198            "…including the second moved line"
1199        );
1200    }
1201
1202    #[test]
1203    fn blame_w_c_detects_copy_with_whitespace_change() {
1204        // Review P1: a block copied into a new file *with a reindent*.
1205        // Under plain -C the changed whitespace hides the copy; under
1206        // -w -C it must still be credited to the origin commit. Verified
1207        // against real `git blame -w -C`.
1208        let (_d, store) = fresh_store();
1209        let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1210        let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
1211        // b.txt copies the block but reindents each line.
1212        let reindented = {
1213            let mut v = Vec::new();
1214            v.extend_from_slice(b"    ");
1215            v.extend_from_slice(BLOCK_A);
1216            v.push(b'\n');
1217            v.extend_from_slice(b"    ");
1218            v.extend_from_slice(BLOCK_B);
1219            v.push(b'\n');
1220            v
1221        };
1222        let c_b = put_multi_file_commit(
1223            &store,
1224            &[("a.txt", b"zzz\n"), ("b.txt", &reindented)],
1225            vec![c_a],
1226            2,
1227            200,
1228        );
1229
1230        // Plain -C: the reindent hides the copy; lines stay on c_b.
1231        let plain_c = BlameOptions {
1232            copies: CopyDetection::On {
1233                level: 1,
1234                threshold: 40,
1235            },
1236            ..Default::default()
1237        };
1238        let r = blame_file_with(&store, c_b, "b.txt", &plain_c).unwrap();
1239        assert!(
1240            r.lines.iter().all(|l| l.commit_hash == c_b),
1241            "without -w a reindented copy is not detected"
1242        );
1243
1244        // -w -C: normalized keys see through the reindent → credit c_a.
1245        let w_c = BlameOptions {
1246            ignore_whitespace: true,
1247            copies: CopyDetection::On {
1248                level: 1,
1249                threshold: 40,
1250            },
1251            ..Default::default()
1252        };
1253        let r = blame_file_with(&store, c_b, "b.txt", &w_c).unwrap();
1254        assert!(
1255            r.lines.iter().all(|l| l.commit_hash == c_a),
1256            "-w -C credits a reindented copy to its origin"
1257        );
1258    }
1259
1260    #[test]
1261    fn blame_c_attributes_copy_from_other_file_to_origin() {
1262        // c_a has a.txt = block + `zzz`; c_b removes the block from a.txt
1263        // and adds it to a brand-new b.txt (both files change in c_b).
1264        // Blaming b.txt with -C must credit the block to c_a, exercising the
1265        // boundary pass (b.txt has no parent version).
1266        let (_d, store) = fresh_store();
1267        let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1268        let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
1269        let bfile = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1270        let c_b = put_multi_file_commit(
1271            &store,
1272            &[("a.txt", b"zzz\n"), ("b.txt", &bfile)],
1273            vec![c_a],
1274            2,
1275            200,
1276        );
1277
1278        let plain = blame_file(&store, c_b, "b.txt").unwrap();
1279        assert_eq!(
1280            plain.lines[0].commit_hash, c_b,
1281            "default: copied block is new"
1282        );
1283
1284        let opts = BlameOptions {
1285            copies: CopyDetection::On {
1286                level: 1,
1287                threshold: 40,
1288            },
1289            ..Default::default()
1290        };
1291        let c = blame_file_with(&store, c_b, "b.txt", &opts).unwrap();
1292        assert_eq!(c.lines[0].text, BLOCK_A);
1293        assert_eq!(c.lines[1].text, BLOCK_B);
1294        assert!(
1295            c.lines.iter().all(|l| l.commit_hash == c_a),
1296            "-C credits the copied block to its origin commit"
1297        );
1298    }
1299
1300    #[test]
1301    fn blame_c_level1_skips_unchanged_files_until_level2() {
1302        // dst.txt copies a block verbatim from src.txt, which is NOT
1303        // modified in the copying commit. git `-C` (level 1) only searches
1304        // files changed in the commit, so it misses this; `-C -C` (level 2)
1305        // searches every parent file and finds it.
1306        let (_d, store) = fresh_store();
1307        let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1308        let c_a = put_multi_file_commit(&store, &[("src.txt", &block)], vec![], 1, 100);
1309        let c_b = put_multi_file_commit(
1310            &store,
1311            &[("src.txt", &block), ("dst.txt", &block)],
1312            vec![c_a],
1313            2,
1314            200,
1315        );
1316
1317        let l1 = BlameOptions {
1318            copies: CopyDetection::On {
1319                level: 1,
1320                threshold: 40,
1321            },
1322            ..Default::default()
1323        };
1324        let r1 = blame_file_with(&store, c_b, "dst.txt", &l1).unwrap();
1325        assert!(
1326            r1.lines.iter().all(|l| l.commit_hash == c_b),
1327            "-C level 1 ignores the unchanged source file"
1328        );
1329
1330        let l2 = BlameOptions {
1331            copies: CopyDetection::On {
1332                level: 2,
1333                threshold: 40,
1334            },
1335            ..Default::default()
1336        };
1337        let r2 = blame_file_with(&store, c_b, "dst.txt", &l2).unwrap();
1338        assert!(
1339            r2.lines.iter().all(|l| l.commit_hash == c_a),
1340            "-C -C searches every parent file and finds the source"
1341        );
1342    }
1343
1344    #[test]
1345    fn blame_w_c_credits_copy_through_prior_whitespace_edit() {
1346        // Review P1: the copy *source* must be blamed with the active `-w`,
1347        // so a copied block traces through a prior whitespace-only edit in
1348        // the source file. d1 = indented block; d2 dedents it (ws-only); d3
1349        // copies it to b.txt. `git blame -w -C` credits d1, not the d2
1350        // reformat. (With BlameOptions::default() for the source blame this
1351        // wrongly credited d2.) Verified against real git.
1352        let (_d, store) = fresh_store();
1353        let indented = {
1354            let mut v = Vec::new();
1355            for b in [BLOCK_A, BLOCK_B] {
1356                v.extend_from_slice(b"    ");
1357                v.extend_from_slice(b);
1358                v.push(b'\n');
1359            }
1360            v.extend_from_slice(b"zzz\n");
1361            v
1362        };
1363        let d1 = put_multi_file_commit(&store, &[("a.txt", &indented)], vec![], 1, 100);
1364        let dedented = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1365        let d2 = put_multi_file_commit(&store, &[("a.txt", &dedented)], vec![d1], 2, 200);
1366        let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1367        let d3 = put_multi_file_commit(
1368            &store,
1369            &[("a.txt", b"zzz\n"), ("b.txt", &block)],
1370            vec![d2],
1371            3,
1372            300,
1373        );
1374
1375        let opts = BlameOptions {
1376            ignore_whitespace: true,
1377            copies: CopyDetection::On {
1378                level: 1,
1379                threshold: 40,
1380            },
1381            ..Default::default()
1382        };
1383        let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
1384        assert!(
1385            r.lines.iter().all(|l| l.commit_hash == d1),
1386            "the source blame keeps -w → credits the original, not the reformat"
1387        );
1388    }
1389
1390    #[test]
1391    fn blame_c_credits_copy_through_prior_same_file_move() {
1392        // Review P1: the copy *source* must be blamed with the implied `-M`,
1393        // so a copied block traces through a prior same-file move in the
1394        // source. d1 = block then X,Y; d2 moves the block below X,Y; d3
1395        // copies it to b.txt. `git blame -C` credits d1, not the d2 move.
1396        // (With BlameOptions::default() this wrongly credited d2.) Verified
1397        // against real git.
1398        let (_d, store) = fresh_store();
1399        let v1 = [BLOCK_A, BLOCK_B, b"X", b"Y", b""].join(&b'\n');
1400        let d1 = put_multi_file_commit(&store, &[("a.txt", &v1)], vec![], 1, 100);
1401        let v2 = [b"X" as &[u8], b"Y", BLOCK_A, BLOCK_B, b""].join(&b'\n');
1402        let d2 = put_multi_file_commit(&store, &[("a.txt", &v2)], vec![d1], 2, 200);
1403        let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1404        let d3 = put_multi_file_commit(
1405            &store,
1406            &[("a.txt", b"X\nY\n"), ("b.txt", &block)],
1407            vec![d2],
1408            3,
1409            300,
1410        );
1411
1412        let opts = BlameOptions {
1413            copies: CopyDetection::On {
1414                level: 1,
1415                threshold: 40,
1416            },
1417            ..Default::default()
1418        };
1419        let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
1420        assert!(
1421            r.lines.iter().all(|l| l.commit_hash == d1),
1422            "the source blame keeps implied -M → credits the original, not the move"
1423        );
1424    }
1425
1426    #[test]
1427    fn blame_c_alone_implies_within_file_m() {
1428        // Review test gap: `-C` with `moves: Off` must still detect a
1429        // within-file move (git's `-C` implies `-M`), via effective_move()
1430        // returning GIT_DEFAULT. A long block moved within the *blamed*
1431        // file is credited to its origin with only copies set.
1432        let (_d, store) = fresh_store();
1433        let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
1434        let v2 = [b"B" as &[u8], b"C", LONG_LINE, BLOCK_A, b""].join(&b'\n');
1435        let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1436        let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1437
1438        let opts = BlameOptions {
1439            copies: CopyDetection::On {
1440                level: 1,
1441                threshold: 40,
1442            },
1443            ..Default::default() // moves: Off — the implication is under test
1444        };
1445        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1446        assert_eq!(m.lines[2].text, LONG_LINE);
1447        assert_eq!(
1448            m.lines[2].commit_hash, c_a,
1449            "-C implies -M, so the within-file move reverts to its origin"
1450        );
1451    }
1452
1453    #[test]
1454    fn blame_m_many_single_line_moves_no_stack_overflow() {
1455        // Review P1 (#2): N independent single-line moves used to recurse N
1456        // deep. With the work-stack it must just complete. Reverse 400 long,
1457        // distinct lines: each is its own move block, so all revert to c_a.
1458        let (_d, store) = fresh_store();
1459        let lines: Vec<String> = (0..400)
1460            .map(|i| format!("let unique_symbol_number_{i:05} = compute({i});"))
1461            .collect();
1462        let mut v1 = lines.join("\n");
1463        v1.push('\n');
1464        let mut rev: Vec<&str> = lines.iter().map(String::as_str).collect();
1465        rev.reverse();
1466        let mut v2 = rev.join("\n");
1467        v2.push('\n');
1468        let c_a = put_file_commit(&store, "f.txt", v1.as_bytes(), vec![], 1, 100);
1469        let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
1470
1471        let opts = BlameOptions {
1472            moves: MoveDetection::On { threshold: 20 },
1473            ..Default::default()
1474        };
1475        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1476        assert_eq!(m.lines.len(), 400);
1477        assert!(
1478            m.lines.iter().all(|l| l.commit_hash == c_a),
1479            "every reordered long line is a move → all revert to c_a"
1480        );
1481    }
1482
1483    #[test]
1484    fn blame_m_large_new_block_terminates() {
1485        // Review P1 (#1): a large genuinely-new block with no move/copy
1486        // match must not blow up the (previously cubic) search. 3000 new,
1487        // distinct lines that appear in no source: all stay on the editing
1488        // commit, and the call returns promptly via the source key-index.
1489        use std::fmt::Write as _;
1490        let (_d, store) = fresh_store();
1491        let c_a = put_file_commit(&store, "f.txt", b"seed\n", vec![], 1, 100);
1492        let mut v2 = String::from("seed\n");
1493        for i in 0..3000 {
1494            let _ = writeln!(v2, "brand_new_distinct_line_number_{i:06}");
1495        }
1496        let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
1497
1498        let opts = BlameOptions {
1499            moves: MoveDetection::On { threshold: 20 },
1500            ..Default::default()
1501        };
1502        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1503        assert_eq!(m.lines.len(), 3001);
1504        assert_eq!(m.lines[0].commit_hash, c_a, "the seed line is unchanged");
1505        assert!(
1506            m.lines[1..].iter().all(|l| l.commit_hash == c_b),
1507            "the large new block stays on the editing commit"
1508        );
1509    }
1510
1511    #[test]
1512    fn blame_single_commit_attributes_all_lines_to_it() {
1513        let (_d, store) = fresh_store();
1514        let c = put_file_commit(&store, "f.txt", b"l1\nl2\nl3\n", vec![], 42, 1000);
1515        let r = blame_file(&store, c, "f.txt").unwrap();
1516        assert_eq!(r.lines.len(), 3);
1517        for (i, line) in r.lines.iter().enumerate() {
1518            assert_eq!(line.line_num, i + 1);
1519            assert_eq!(line.commit_hash, c);
1520            assert_eq!(line.timestamp, 1000);
1521            assert_eq!(line.author.kind, crate::object::IdentityKind::Opaque);
1522        }
1523        assert_eq!(r.lines[0].text, b"l1");
1524        assert_eq!(r.lines[1].text, b"l2");
1525        assert_eq!(r.lines[2].text, b"l3");
1526    }
1527
1528    #[test]
1529    fn strip_ws_removes_all_whitespace() {
1530        assert_eq!(strip_ws(b"  foo(a,  b)\t"), b"foo(a,b)".to_vec());
1531        assert_eq!(strip_ws(b"abc"), b"abc".to_vec());
1532        assert_eq!(strip_ws(b" \t "), b"".to_vec());
1533        // Vertical tab (\x0B) and form feed (\x0C) are whitespace to git's
1534        // xdiff `isspace`; strip both for parity.
1535        assert_eq!(strip_ws(b"a\x0Bb\x0Cc"), b"abc".to_vec());
1536    }
1537
1538    #[test]
1539    fn blame_w_ignores_whitespace_only_change() {
1540        let (_d, store) = fresh_store();
1541        let c_a = put_file_commit(&store, "f.txt", b"foo(a, b)\nkeep\n", vec![], 1, 100);
1542        let c_b = put_file_commit(&store, "f.txt", b"foo(a,b)\nkeep\n", vec![c_a], 2, 200);
1543
1544        // Default: the whitespace-only edit reattributes line 1 to c_b.
1545        let plain = blame_file(&store, c_b, "f.txt").unwrap();
1546        assert_eq!(plain.lines[0].commit_hash, c_b);
1547
1548        // -w: line 1 keeps c_a, but output still shows the current bytes.
1549        let opts = BlameOptions {
1550            ignore_whitespace: true,
1551            ..Default::default()
1552        };
1553        let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1554        assert_eq!(
1555            w.lines[0].commit_hash, c_a,
1556            "a whitespace-only change must not steal blame"
1557        );
1558        assert_eq!(
1559            w.lines[0].text, b"foo(a,b)",
1560            "output keeps the current bytes"
1561        );
1562        assert_eq!(w.lines[1].commit_hash, c_a);
1563    }
1564
1565    #[test]
1566    fn blame_w_still_attributes_real_content_change() {
1567        let (_d, store) = fresh_store();
1568        let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1569        let c_b = put_file_commit(&store, "f.txt", b"a\nB CHANGED\n", vec![c_a], 2, 200);
1570        let opts = BlameOptions {
1571            ignore_whitespace: true,
1572            ..Default::default()
1573        };
1574        let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1575        assert_eq!(w.lines[0].commit_hash, c_a);
1576        assert_eq!(
1577            w.lines[1].commit_hash, c_b,
1578            "a non-whitespace change is still attributed normally under -w"
1579        );
1580    }
1581
1582    #[test]
1583    fn blame_w_keeps_position_for_whitespace_equal_duplicate() {
1584        // Regression (PR #464 review P1): old `ab`, new `ab` + `a b`.
1585        // Stripping whitespace collapses both new lines to the key `ab`,
1586        // so a position-blind LCS would pair the *second* new line with
1587        // the old one and report line 1 as new — the reverse of git.
1588        // `git blame -w` keeps line 1 on the original commit and line 2 on
1589        // the new one; assert mkit matches.
1590        let (_d, store) = fresh_store();
1591        let c_a = put_file_commit(&store, "f.txt", b"ab\n", vec![], 1, 100);
1592        let c_b = put_file_commit(&store, "f.txt", b"ab\na b\n", vec![c_a], 2, 200);
1593        let opts = BlameOptions {
1594            ignore_whitespace: true,
1595            ..Default::default()
1596        };
1597        let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1598        assert_eq!(w.lines.len(), 2);
1599        assert_eq!(w.lines[0].commit_hash, c_a, "unchanged line 1 keeps c_a");
1600        assert_eq!(w.lines[1].commit_hash, c_b, "added line 2 is c_b");
1601        assert_eq!(w.lines[1].text, b"a b", "output keeps the current bytes");
1602    }
1603
1604    #[test]
1605    fn blame_w_blank_line_duplicate_is_position_stable() {
1606        // The same duplicate-key hazard with blank lines (review P1 calls
1607        // it out explicitly): inserting a second blank line must credit the
1608        // *added* blank to the new commit and leave the original blank on
1609        // its commit, matching `git blame -w` (verified against real git).
1610        let (_d, store) = fresh_store();
1611        let c_a = put_file_commit(&store, "f.txt", b"x\n\ny\n", vec![], 1, 100);
1612        let c_b = put_file_commit(&store, "f.txt", b"x\n\n\ny\n", vec![c_a], 2, 200);
1613        let opts = BlameOptions {
1614            ignore_whitespace: true,
1615            ..Default::default()
1616        };
1617        let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1618        assert_eq!(w.lines.len(), 4);
1619        assert_eq!(w.lines[0].commit_hash, c_a, "x");
1620        assert_eq!(w.lines[1].commit_hash, c_a, "original blank");
1621        assert_eq!(w.lines[2].commit_hash, c_b, "added blank is new");
1622        assert_eq!(w.lines[3].commit_hash, c_a, "y");
1623    }
1624
1625    #[test]
1626    fn blame_duplicate_line_addition_is_position_stable() {
1627        // Same stability property without `-w`: appending a genuine
1628        // duplicate of an existing line must attribute the *new* (second)
1629        // occurrence to the newer commit, not the first.
1630        let (_d, store) = fresh_store();
1631        let c_a = put_file_commit(&store, "f.txt", b"x\n", vec![], 1, 100);
1632        let c_b = put_file_commit(&store, "f.txt", b"x\nx\n", vec![c_a], 2, 200);
1633        let r = blame_file(&store, c_b, "f.txt").unwrap();
1634        assert_eq!(r.lines[0].commit_hash, c_a, "original line keeps c_a");
1635        assert_eq!(r.lines[1].commit_hash, c_b, "appended duplicate is c_b");
1636    }
1637
1638    #[test]
1639    fn blame_two_commits_with_modified_middle() {
1640        let (_d, store) = fresh_store();
1641        let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 42, 1000);
1642        let c_b = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![c_a], 42, 2000);
1643        let r = blame_file(&store, c_b, "f.txt").unwrap();
1644        assert_eq!(r.lines.len(), 3);
1645        assert_eq!(r.lines[0].commit_hash, c_a);
1646        assert_eq!(r.lines[1].commit_hash, c_b);
1647        assert_eq!(r.lines[2].commit_hash, c_a);
1648        assert_eq!(r.lines[1].text, b"MOD");
1649    }
1650
1651    #[test]
1652    fn blame_three_commits_progressive_changes() {
1653        let (_d, store) = fresh_store();
1654        let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1655        let c_b = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![c_a], 2, 200);
1656        let c_c = put_file_commit(&store, "f.txt", b"a\nX\nc\n", vec![c_b], 3, 300);
1657        let r = blame_file(&store, c_c, "f.txt").unwrap();
1658        assert_eq!(r.lines.len(), 3);
1659        assert_eq!(r.lines[0].commit_hash, c_a, "a from A");
1660        assert_eq!(r.lines[1].commit_hash, c_c, "X from C");
1661        assert_eq!(r.lines[2].commit_hash, c_b, "c from B");
1662    }
1663
1664    #[test]
1665    fn blame_tracks_additions() {
1666        let (_d, store) = fresh_store();
1667        let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1668        let c_b = put_file_commit(&store, "f.txt", b"a\nNEW\nb\n", vec![c_a], 1, 200);
1669        let r = blame_file(&store, c_b, "f.txt").unwrap();
1670        assert_eq!(r.lines.len(), 3);
1671        assert_eq!(r.lines[0].commit_hash, c_a);
1672        assert_eq!(r.lines[1].commit_hash, c_b);
1673        assert_eq!(r.lines[2].commit_hash, c_a);
1674    }
1675
1676    #[test]
1677    fn blame_tracks_deletions() {
1678        let (_d, store) = fresh_store();
1679        let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
1680        let c_b = put_file_commit(&store, "f.txt", b"a\nc\n", vec![c_a], 1, 200);
1681        let r = blame_file(&store, c_b, "f.txt").unwrap();
1682        assert_eq!(r.lines.len(), 2);
1683        assert_eq!(r.lines[0].commit_hash, c_a);
1684        assert_eq!(r.lines[1].commit_hash, c_a);
1685    }
1686
1687    #[test]
1688    fn blame_file_not_found_returns_error() {
1689        let (_d, store) = fresh_store();
1690        let c = put_file_commit(&store, "real.txt", b"x\n", vec![], 1, 100);
1691        let err = blame_file(&store, c, "missing.txt").unwrap_err();
1692        assert!(matches!(err, BlameError::FileNotFound(_)));
1693    }
1694
1695    #[test]
1696    fn lcs_identical_lines() {
1697        let lines: Vec<&[u8]> = vec![b"a", b"b", b"c"];
1698        let m = match_lines(&lines, &lines);
1699        assert_eq!(m, vec![Some(0), Some(1), Some(2)]);
1700    }
1701
1702    #[test]
1703    fn lcs_completely_different() {
1704        let old: Vec<&[u8]> = vec![b"a", b"b", b"c"];
1705        let new: Vec<&[u8]> = vec![b"x", b"y", b"z"];
1706        let m = match_lines(&old, &new);
1707        assert_eq!(m, vec![None, None, None]);
1708    }
1709
1710    #[test]
1711    fn lcs_duplicate_key_matches_earliest_new_line() {
1712        // One old line, two identical new lines: the matcher must pair the
1713        // *first* new line (the unchanged one) and leave the second as an
1714        // insertion, so blame keeps the original on line 1. A greedy
1715        // diagonal-first backtrack would instead pair the last occurrence.
1716        let old: Vec<&[u8]> = vec![b"ab"];
1717        let new: Vec<&[u8]> = vec![b"ab", b"ab"];
1718        assert_eq!(match_lines(&old, &new), vec![Some(0), None]);
1719    }
1720
1721    #[test]
1722    fn lcs_trailing_run_elision_would_be_unsound() {
1723        // Regression test for the counterexample in `match_lines`'s doc:
1724        // trimming a common *trailing* run before matching would pair
1725        // `old[0]` to the second "a", not the first. `match_lines` must
1726        // still get this right since it only elides the leading run.
1727        let old: Vec<&[u8]> = vec![b"a"];
1728        let new: Vec<&[u8]> = vec![b"b", b"a", b"a"];
1729        assert_eq!(match_lines(&old, &new), vec![None, Some(0), None]);
1730    }
1731
1732    proptest::proptest! {
1733        /// `match_lines` (leading-run elided) must produce byte-identical
1734        /// mappings to `match_lines_core` (the unmodified O(m*n) DP) run
1735        /// directly on the same, un-elided `old`/`new` — including which
1736        /// occurrence of a duplicated line gets the match. A tiny 3-symbol
1737        /// alphabet maximizes duplicate lines in a short random sequence,
1738        /// which is exactly what the trailing-elision counterexample
1739        /// (`lcs_trailing_run_elision_would_be_unsound`) needed to surface,
1740        /// so a broad random sweep at this alphabet size is a meaningful
1741        /// check that the leading-only version doesn't have a sibling bug.
1742        #[test]
1743        fn proptest_match_lines_matches_unelided_core(
1744            old in proptest::collection::vec(0u8..3, 0..12),
1745            new in proptest::collection::vec(0u8..3, 0..12),
1746        ) {
1747            let old_lines: Vec<[u8; 1]> = old.iter().map(|b| [*b]).collect();
1748            let new_lines: Vec<[u8; 1]> = new.iter().map(|b| [*b]).collect();
1749            let elided = match_lines(&old_lines, &new_lines);
1750            let reference = match_lines_core(&old_lines, &new_lines);
1751            proptest::prop_assert_eq!(elided, reference);
1752        }
1753    }
1754
1755    #[test]
1756    fn split_lines_handles_trailing_newline() {
1757        assert_eq!(
1758            split_lines(b"a\nb\nc\n"),
1759            vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
1760        );
1761    }
1762
1763    #[test]
1764    fn split_lines_handles_no_trailing_newline() {
1765        assert_eq!(
1766            split_lines(b"a\nb\nc"),
1767            vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
1768        );
1769    }
1770
1771    #[test]
1772    fn split_lines_empty() {
1773        assert!(split_lines(b"").is_empty());
1774    }
1775
1776    #[test]
1777    fn find_blob_in_nested_tree() {
1778        let (_d, store) = fresh_store();
1779        let blob = put_blob(&store, b"hello\n");
1780        let inner = Object::Tree(Tree {
1781            entries: vec![TreeEntry {
1782                name: b"main.txt".to_vec(),
1783                mode: EntryMode::Blob,
1784                object_hash: blob,
1785            }],
1786        });
1787        let inner_h = store.write(&serialize::serialize(&inner).unwrap()).unwrap();
1788        let outer = Object::Tree(Tree {
1789            entries: vec![TreeEntry {
1790                name: b"src".to_vec(),
1791                mode: EntryMode::Tree,
1792                object_hash: inner_h,
1793            }],
1794        });
1795        let outer_h = store.write(&serialize::serialize(&outer).unwrap()).unwrap();
1796        let found = find_blob_in_tree(&store, outer_h, "src/main.txt").unwrap();
1797        assert_eq!(found, Some(blob));
1798        let missing = find_blob_in_tree(&store, outer_h, "src/none.txt").unwrap();
1799        assert_eq!(missing, None);
1800    }
1801
1802    /// Convenience: a `BlameOptions` that ignores the given commits with
1803    /// `--ignore-rev-precise` on, `-w` (content matching needs `-w` to
1804    /// agree with the matcher the same way `-M`/`-C` do).
1805    fn ignoring_precise(revs: &[Hash]) -> BlameOptions {
1806        BlameOptions {
1807            ignore_revs: Arc::new(revs.iter().copied().collect()),
1808            ignore_rev_precise: true,
1809            ignore_whitespace: true,
1810            ..Default::default()
1811        }
1812    }
1813
1814    /// Convenience: a `BlameOptions` that ignores the given commits.
1815    fn ignoring(revs: &[Hash]) -> BlameOptions {
1816        BlameOptions {
1817            ignore_revs: Arc::new(revs.iter().copied().collect()),
1818            ..Default::default()
1819        }
1820    }
1821
1822    #[test]
1823    fn blame_ignore_rev_falls_through_reformat() {
1824        // A reformat commit (changes a line's bytes only) is ignored, so
1825        // its line falls through to the commit that owns the parent line —
1826        // but the output still shows the reformatted bytes. Mirrors
1827        // `git blame --ignore-rev <reformat>` (verified against real git).
1828        let (_d, store) = fresh_store();
1829        let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\ngamma\n", vec![], 1, 100);
1830        let c_b = put_file_commit(
1831            &store,
1832            "f.txt",
1833            b"alpha\n  beta  \ngamma\n",
1834            vec![c_a],
1835            2,
1836            200,
1837        );
1838
1839        // Without ignore: the reformat steals line 2.
1840        let plain = blame_file(&store, c_b, "f.txt").unwrap();
1841        assert_eq!(plain.lines[1].commit_hash, c_b);
1842
1843        let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
1844        assert_eq!(
1845            r.lines[1].commit_hash, c_a,
1846            "ignored reformat falls through to the original commit"
1847        );
1848        assert_eq!(r.lines[1].text, b"  beta  ", "output keeps current bytes");
1849        assert!(
1850            r.lines.iter().all(|l| l.commit_hash == c_a),
1851            "no line is credited to the ignored commit"
1852        );
1853    }
1854
1855    #[test]
1856    fn blame_ignore_rev_keeps_genuine_insertion() {
1857        // A line genuinely *added* by the ignored commit has no counterpart
1858        // in the parent, so git leaves it on the ignored commit (no
1859        // `blame.markUnblamableLines` marker by default). Verified against
1860        // real `git blame --ignore-rev`.
1861        let (_d, store) = fresh_store();
1862        let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\n", vec![], 1, 100);
1863        let c_b = put_file_commit(
1864            &store,
1865            "f.txt",
1866            b"alpha\nbeta\nBRANDNEW\ngamma\n",
1867            vec![c_a],
1868            2,
1869            200,
1870        );
1871
1872        let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
1873        assert_eq!(r.lines[0].commit_hash, c_a, "alpha");
1874        assert_eq!(r.lines[1].commit_hash, c_a, "beta");
1875        assert_eq!(
1876            r.lines[2].commit_hash, c_b,
1877            "a genuine insertion stays on the ignored commit"
1878        );
1879        assert_eq!(r.lines[3].commit_hash, c_b, "…and the second insertion");
1880    }
1881
1882    #[test]
1883    fn blame_ignore_rev_pairs_changed_lines_to_distinct_origins() {
1884        // Two adjacent changed lines in the ignored commit pair positionally
1885        // with their two parent lines, which have *different* origins: the
1886        // first parent line came from c2, the second from c1. git credits
1887        // each fallen-through line to its own parent's origin (verified).
1888        let (_d, store) = fresh_store();
1889        let c1 = put_file_commit(&store, "f.txt", b"L1\nL2\nL3\nL4\n", vec![], 1, 100);
1890        let c2 = put_file_commit(&store, "f.txt", b"L1\nL2x\nL3\nL4\n", vec![c1], 2, 200);
1891        let c3 = put_file_commit(&store, "f.txt", b"L1\nL2y\nL3y\nL4\n", vec![c2], 3, 300);
1892
1893        let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c3])).unwrap();
1894        assert_eq!(r.lines[1].commit_hash, c2, "L2y inherits L2x's origin (c2)");
1895        assert_eq!(r.lines[2].commit_hash, c1, "L3y inherits L3's origin (c1)");
1896        assert!(r.lines.iter().all(|l| l.commit_hash != c3));
1897    }
1898
1899    #[test]
1900    fn blame_ignore_rev_unequal_hunk_more_added() {
1901        // 1 line removed, 2 added in the ignored commit: the *first* added
1902        // line pairs with the removed line (falls through); the extra added
1903        // line stays on the ignored commit. Verified against real git.
1904        let (_d, store) = fresh_store();
1905        let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
1906        let c2 = put_file_commit(&store, "f.txt", b"L1\nMIDa\nMIDb\nL3\n", vec![c1], 2, 200);
1907
1908        let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1909        assert_eq!(r.lines[1].commit_hash, c1, "MIDa falls through to c1");
1910        assert_eq!(r.lines[2].commit_hash, c2, "MIDb has no pair → stays on c2");
1911    }
1912
1913    #[test]
1914    fn blame_ignore_rev_unequal_hunk_more_removed() {
1915        // 2 removed, 1 added: the single added line pairs with the *first*
1916        // removed line; the extra removed line simply vanishes. Verified.
1917        let (_d, store) = fresh_store();
1918        let c1 = put_file_commit(&store, "f.txt", b"L1\nM1\nM2\nL3\n", vec![], 1, 100);
1919        let c2 = put_file_commit(&store, "f.txt", b"L1\nMERGED\nL3\n", vec![c1], 2, 200);
1920
1921        let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1922        assert_eq!(r.lines[1].commit_hash, c1, "MERGED falls through to c1");
1923    }
1924
1925    #[test]
1926    fn blame_ignore_root_commit_keeps_its_lines() {
1927        // Ignoring the oldest commit in the walk: its lines have no parent
1928        // version of the file to fall through to, so they stay on it —
1929        // matching `git blame --ignore-rev <root>`.
1930        let (_d, store) = fresh_store();
1931        let root = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1932        let c2 = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![root], 2, 200);
1933
1934        let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[root])).unwrap();
1935        assert_eq!(r.lines[0].commit_hash, root, "a stays on the ignored root");
1936        assert_eq!(r.lines[1].commit_hash, root, "b stays on the ignored root");
1937        assert_eq!(r.lines[2].commit_hash, c2, "c is unaffected");
1938    }
1939
1940    #[test]
1941    fn blame_ignore_multiple_revs_chains_through() {
1942        // Two stacked reformats, both ignored: line falls through both to
1943        // the original author.
1944        let (_d, store) = fresh_store();
1945        let c1 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![], 1, 100);
1946        let c2 = put_file_commit(&store, "f.txt", b"keep\n x \n", vec![c1], 2, 200);
1947        let c3 = put_file_commit(&store, "f.txt", b"keep\n  x  \n", vec![c2], 3, 300);
1948
1949        let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c2, c3])).unwrap();
1950        assert_eq!(
1951            r.lines[1].commit_hash, c1,
1952            "line falls through both ignored reformats to c1"
1953        );
1954    }
1955
1956    #[test]
1957    fn blame_ignore_rev_fallthrough_not_overwritten_by_move_detection() {
1958        // Review: `--ignore-rev` + `-M`. An ignored commit replaces the last
1959        // line (`x`) in place with a duplicate of the first line (`dup`,
1960        // >= 20 alnum). The trailing hunk is 1-for-1, so ignore-rev
1961        // fallthrough pairs the new line with the *replaced* parent line
1962        // (`x`, origin c1). But `dup`'s key also appears at line 0 of the
1963        // parent, so the move detector *would* credit it to `dup`'s origin
1964        // (c0). Fallthrough must win: detection runs only on lines it did
1965        // not resolve.
1966        let (_d, store) = fresh_store();
1967        let dup: &[u8] = b"dupaaaaaaaaaaaaaaaaa"; // 20 alnum
1968        let mid: &[u8] = b"MIDLINE";
1969        let x_v0: &[u8] = b"exoldbbbbbbbbbbbbbbb";
1970        let x_v1: &[u8] = b"exnewbbbbbbbbbbbbbbb";
1971        let c0 = put_file_commit(
1972            &store,
1973            "f.txt",
1974            &[dup, mid, x_v0, b""].join(&b'\n'),
1975            vec![],
1976            1,
1977            100,
1978        );
1979        // c1 changes the last line (origin → c1); `dup`/`mid` keep origin c0.
1980        let c1 = put_file_commit(
1981            &store,
1982            "f.txt",
1983            &[dup, mid, x_v1, b""].join(&b'\n'),
1984            vec![c0],
1985            2,
1986            200,
1987        );
1988        // c2 (ignored) replaces the last line with a duplicate of `dup`.
1989        let c2 = put_file_commit(
1990            &store,
1991            "f.txt",
1992            &[dup, mid, dup, b""].join(&b'\n'),
1993            vec![c1],
1994            3,
1995            300,
1996        );
1997
1998        let opts = BlameOptions {
1999            moves: MoveDetection::On { threshold: 20 },
2000            ignore_revs: Arc::new([c2].into_iter().collect()),
2001            ..Default::default()
2002        };
2003        let r = blame_file_with(&store, c2, "f.txt", &opts).unwrap();
2004        assert_eq!(
2005            r.lines[2].commit_hash, c1,
2006            "fallthrough (replaced parent line → c1) wins; -M does not overwrite it to c0"
2007        );
2008        // Sanity: plain --ignore-rev (no -M) gives the same line-2 origin.
2009        let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2010        assert_eq!(
2011            plain.lines[2].commit_hash, c1,
2012            "-M did not change the result"
2013        );
2014    }
2015
2016    // --- `--ignore-rev-precise` (#496) ---------------------------------
2017
2018    #[test]
2019    fn blame_ignore_rev_precise_reattributes_moved_reindented_lines() {
2020        // A reformat commit reorders three distinct-origin lines and
2021        // reindents them. Under -w the LCS matcher recognizes ZZZ as
2022        // unchanged content wherever it landed and matches it directly
2023        // (so it needs no fall-through at all: LCS itself is already
2024        // content-aware for an exact, if relocated, match). That leaves
2025        // YYY and XXX with NO positional counterpart in their own
2026        // hunk — git's `--ignore-rev` treats them as genuine insertions
2027        // and credits them to the noise commit itself.
2028        // `--ignore-rev-precise` searches the parent's unmatched lines
2029        // across the WHOLE file (not just the enclosing hunk), so it finds
2030        // YYY's and XXX's true origins even though the positional pass
2031        // found no local candidate for either. Not pinned against git — no
2032        // git equivalent exists; documented mkit-only divergence (#496).
2033        let (_d, store) = fresh_store();
2034        let c0 = put_file_commit(&store, "f.txt", b"keep\ntail\n", vec![], 1, 100);
2035        let c1 = put_file_commit(&store, "f.txt", b"keep\nXXX\ntail\n", vec![c0], 2, 200);
2036        let c2 = put_file_commit(&store, "f.txt", b"keep\nXXX\nYYY\ntail\n", vec![c1], 3, 300);
2037        let c3 = put_file_commit(
2038            &store,
2039            "f.txt",
2040            b"keep\nXXX\nYYY\nZZZ\ntail\n",
2041            vec![c2],
2042            4,
2043            400,
2044        );
2045        let c4 = put_file_commit(
2046            &store,
2047            "f.txt",
2048            b"keep\n  ZZZ\n  YYY\n  XXX\ntail\n",
2049            vec![c3],
2050            5,
2051            500,
2052        );
2053
2054        // Positional (git-identical) fall-through: YYY and XXX have no
2055        // in-hunk counterpart (ZZZ already consumed the only anchor
2056        // available between `keep` and `tail`), so git's default leaves
2057        // them on the ignored commit.
2058        let positional_opts = BlameOptions {
2059            ignore_whitespace: true,
2060            ignore_revs: Arc::new([c4].into_iter().collect()),
2061            ..Default::default()
2062        };
2063        let positional = blame_file_with(&store, c4, "f.txt", &positional_opts).unwrap();
2064        assert_eq!(
2065            positional.lines[1].commit_hash, c3,
2066            "ZZZ is recognized unchanged by the LCS matcher itself (needs no fall-through)"
2067        );
2068        assert_eq!(
2069            positional.lines[2].commit_hash, c4,
2070            "positional: YYY has no in-hunk counterpart, stays on the ignored commit"
2071        );
2072        assert_eq!(
2073            positional.lines[3].commit_hash, c4,
2074            "positional: XXX has no in-hunk counterpart, stays on the ignored commit"
2075        );
2076
2077        // Precise: content matching searches the whole parent file (not
2078        // just YYY/XXX's own, counterpart-less hunk) and finds each true
2079        // origin.
2080        let precise = blame_file_with(&store, c4, "f.txt", &ignoring_precise(&[c4])).unwrap();
2081        assert_eq!(
2082            precise.lines[1].commit_hash, c3,
2083            "ZZZ is unaffected by precise mode (already resolved by plain LCS)"
2084        );
2085        assert_eq!(
2086            precise.lines[2].commit_hash, c2,
2087            "precise: YYY correctly attributed to its true origin"
2088        );
2089        assert_eq!(
2090            precise.lines[3].commit_hash, c1,
2091            "precise: XXX correctly attributed to its true origin"
2092        );
2093        assert_eq!(precise.lines[0].commit_hash, c0, "keep is unaffected");
2094        assert_eq!(precise.lines[4].commit_hash, c0, "tail is unaffected");
2095    }
2096
2097    #[test]
2098    fn blame_ignore_rev_precise_unequal_hunk_surplus_stays_put() {
2099        // The ignored commit splits one line into three fabricated lines
2100        // with no content match anywhere in the parent. Both modes pair the
2101        // first split line positionally (the only candidate) and leave the
2102        // two surplus lines on the ignored commit — `--ignore-rev-precise`
2103        // must not invent a match where none exists.
2104        let (_d, store) = fresh_store();
2105        let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
2106        let c2 = put_file_commit(
2107            &store,
2108            "f.txt",
2109            b"L1\nMIDaaa\nMIDbbb\nMIDccc\nL3\n",
2110            vec![c1],
2111            2,
2112            200,
2113        );
2114
2115        let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2116        let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
2117        for r in [&plain, &precise] {
2118            assert_eq!(r.lines[1].commit_hash, c1, "MIDaaa falls through to c1");
2119            assert_eq!(
2120                r.lines[2].commit_hash, c2,
2121                "MIDbbb has no pair -> stays on c2"
2122            );
2123            assert_eq!(
2124                r.lines[3].commit_hash, c2,
2125                "MIDccc has no pair -> stays on c2"
2126            );
2127        }
2128    }
2129
2130    #[test]
2131    fn blame_ignore_rev_precise_no_match_falls_back_to_positional() {
2132        // A single fabricated line replacing a single parent line, with no
2133        // other candidate anywhere in the file: precise mode has nothing to
2134        // find, so it falls back to exactly the positional result.
2135        let (_d, store) = fresh_store();
2136        let c1 = put_file_commit(&store, "f.txt", b"L1\nA\nL3\n", vec![], 1, 100);
2137        let c2 = put_file_commit(&store, "f.txt", b"L1\nFABRICATED\nL3\n", vec![c1], 2, 200);
2138
2139        let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2140        let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
2141        assert_eq!(plain.lines[1].commit_hash, c1);
2142        assert_eq!(
2143            precise.lines[1].commit_hash, plain.lines[1].commit_hash,
2144            "no content candidate exists anywhere in the parent: precise matches positional"
2145        );
2146    }
2147
2148    #[test]
2149    fn precise_overrides_trivial_key_guard() {
2150        // Unit-test the helper directly (mirroring
2151        // `ignore_fallthrough_pairs_per_hunk`'s direct-call style), pinning
2152        // the trivial-key guard: a positional guess for a line whose key is
2153        // under 3 bytes is kept even when a perfect, unclaimed content match
2154        // sits elsewhere in the parent — while a longer key on the same call
2155        // is still correctly reattributed when it has no positional guess
2156        // (a `None` genuine-insertion slot the whole-file search can fill).
2157        //
2158        // new = ["ab" (trivial, 2 bytes), "REALZZZ" (7 bytes)]
2159        // old = ["REALZZZ", "ab", "FILLER"]           (all LCS-unmatched)
2160        // positional (`fall`): new0 -> old2 ("FILLER", arbitrary/wrong),
2161        //                       new1 -> None (no in-hunk counterpart).
2162        let mapping = vec![None, None];
2163        let fall = vec![Some(2), None];
2164        let new_lines = vec![b"ab".to_vec(), b"REALZZZ".to_vec()];
2165        let parent_lines = vec![b"REALZZZ".to_vec(), b"ab".to_vec(), b"FILLER".to_vec()];
2166        let matched = vec![false, false];
2167
2168        let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2169            mapping: &mapping,
2170            fall: &fall,
2171            new_lines: &new_lines,
2172            parent_lines: &parent_lines,
2173            matched: &matched,
2174            ignore_whitespace: false,
2175        });
2176        assert_eq!(
2177            out[0],
2178            Some(2),
2179            "trivial key 'ab' keeps its positional guess even though a perfect \
2180             unclaimed match ('ab' at old index 1) exists"
2181        );
2182        assert_eq!(
2183            out[1],
2184            Some(0),
2185            "non-trivial key 'REALZZZ' fills its None positional slot from its \
2186             true content match (old index 0)"
2187        );
2188    }
2189
2190    #[test]
2191    fn precise_overrides_never_teleports_edited_line_worse_than_positional() {
2192        // Regression for the #523 review counterexample proving the earlier
2193        // "never worse than positional" claim FALSE. The fix makes it true
2194        // by construction: a slot whose positional guess is already a real
2195        // parent line (`Some(j)`) is only re-pointed by a *genuine moved
2196        // block* (a run of >= 2 file-adjacent lines), never by an isolated
2197        // single-line key coincidence.
2198        //
2199        // parent = [A, foo, B1, B2, bar, C]  (old idx 0..=5)
2200        // new    = [A, bar, B1, B2, C]        (new idx 0..=4)
2201        // The ignored commit edits foo->bar (old idx 1) and deletes an
2202        // unrelated `bar` authored by commit X (old idx 4). LCS anchors
2203        // A/B1/B2/C, so mapping[bar]=None and the positional fall pairs the
2204        // edited `bar` with old idx 1 (`foo`) — the line's TRUE positional
2205        // predecessor. The only unmatched old `bar` is at old idx 4.
2206        //
2207        // Old behavior: content override sends new1 to old idx 4, blaming the
2208        // edited line on X — strictly worse than positional. New behavior:
2209        // that single-line coincidence cannot displace the filled positional
2210        // guess, so out[1] stays Some(1).
2211        let mapping = vec![Some(0), None, Some(2), Some(3), Some(5)];
2212        let fall = vec![None, Some(1), None, None, None];
2213        let new_lines = vec![
2214            b"A".to_vec(),
2215            b"bar".to_vec(),
2216            b"B1".to_vec(),
2217            b"B2".to_vec(),
2218            b"C".to_vec(),
2219        ];
2220        let parent_lines = vec![
2221            b"A".to_vec(),
2222            b"foo".to_vec(),
2223            b"B1".to_vec(),
2224            b"B2".to_vec(),
2225            b"bar".to_vec(),
2226            b"C".to_vec(),
2227        ];
2228        let matched = vec![false; new_lines.len()];
2229
2230        let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2231            mapping: &mapping,
2232            fall: &fall,
2233            new_lines: &new_lines,
2234            parent_lines: &parent_lines,
2235            matched: &matched,
2236            ignore_whitespace: false,
2237        });
2238        assert_eq!(
2239            out[1],
2240            Some(1),
2241            "edited `bar` keeps its positional predecessor (foo @ old idx 1)"
2242        );
2243        assert_ne!(
2244            out[1],
2245            Some(4),
2246            "must NOT teleport to the unrelated `bar` @ old idx 4 (commit X)"
2247        );
2248        assert_eq!(
2249            out,
2250            vec![None, Some(1), None, None, None],
2251            "no slot is attributed worse than the positional fall-through"
2252        );
2253    }
2254
2255    #[test]
2256    fn precise_overrides_reindented_brace_does_not_teleport_without_w() {
2257        // Regression for #523 finding #2: without `-w`, `line_key` keeps raw
2258        // bytes, so a reindented `"    }"` is a 5-byte key that clears a
2259        // naive `len < 3` guard and would teleport to any other same-indent
2260        // `"    }"` in the parent. The trivial-key guard now measures the
2261        // WHITESPACE-STRIPPED length regardless of `-w`, so `"    }"` -> `}`
2262        // (1 byte) is trivial and stays put.
2263        //
2264        // The brace is a genuine-insertion slot (fall = None — its positional
2265        // counterpart consumed by an anchor), which the never-worse rule
2266        // would otherwise let a single exact match fill; only the
2267        // stripped-length guard prevents the teleport here.
2268        let mapping = vec![None];
2269        let fall = vec![None];
2270        let new_lines = vec![b"    }".to_vec()];
2271        let parent_lines = vec![b"    }".to_vec()]; // unrelated same-indent brace
2272        let matched = vec![false];
2273
2274        let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2275            mapping: &mapping,
2276            fall: &fall,
2277            new_lines: &new_lines,
2278            parent_lines: &parent_lines,
2279            matched: &matched,
2280            ignore_whitespace: false,
2281        });
2282        assert_eq!(
2283            out[0], None,
2284            "an indented brace is trivial once whitespace-stripped; it must not \
2285             teleport to an unrelated brace even without -w"
2286        );
2287    }
2288
2289    #[test]
2290    fn ignore_fallthrough_pairs_per_hunk() {
2291        // Unit-test the pairing helper directly against the verified rules.
2292        // mapping: new→old, None = unmatched.
2293        // old=[0,1,2,3], new anchors at 0 and 3, two unmatched between →
2294        // pair new1↔old1, new2↔old2.
2295        let mapping = vec![Some(0), None, None, Some(3)];
2296        assert_eq!(
2297            super::ignore_fallthrough(&mapping, 4),
2298            vec![None, Some(1), Some(2), None]
2299        );
2300        // More added than removed: old hunk has one line, new hunk two →
2301        // first pairs, second unpaired.
2302        let mapping = vec![Some(0), None, None, Some(2)];
2303        assert_eq!(
2304            super::ignore_fallthrough(&mapping, 3),
2305            vec![None, Some(1), None, None]
2306        );
2307        // Trailing insertion with no removed lines → all unpaired.
2308        let mapping = vec![Some(0), Some(1), None, None];
2309        assert_eq!(
2310            super::ignore_fallthrough(&mapping, 2),
2311            vec![None, None, None, None]
2312        );
2313    }
2314
2315    #[test]
2316    fn reverse_attributes_each_line_to_last_commit_it_survived() {
2317        // Verified against `git blame --reverse c1..c4`: blames c1's lines
2318        // (keep, doomed, also); survivors go to the end, the removed line
2319        // freezes at the last commit it existed in.
2320        let (_d, store) = fresh_store();
2321        let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nalso\n", vec![], 1, 100);
2322        let c2 = put_file_commit(
2323            &store,
2324            "f.txt",
2325            b"keep\ndoomed\nalso\nextra\n",
2326            vec![c1],
2327            2,
2328            200,
2329        );
2330        let c3 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra\n", vec![c2], 3, 300);
2331        let c4 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra2\n", vec![c3], 4, 400);
2332
2333        let r = blame_file_reverse(&store, c1, c4, "f.txt", &BlameOptions::default()).unwrap();
2334        assert_eq!(r.lines.len(), 3, "blames the start (c1) version's 3 lines");
2335        assert_eq!(r.lines[0].text, b"keep");
2336        assert_eq!(r.lines[0].commit_hash, c4, "keep survives to the end");
2337        assert_eq!(r.lines[1].text, b"doomed");
2338        assert_eq!(r.lines[1].commit_hash, c2, "doomed last existed in c2");
2339        assert_eq!(r.lines[2].text, b"also");
2340        assert_eq!(r.lines[2].commit_hash, c4, "also survives to the end");
2341    }
2342
2343    #[test]
2344    fn reverse_line_removed_immediately_stays_on_start() {
2345        // A start line removed in the very first included commit never
2346        // survives a step, so it is attributed to `start` itself (git marks
2347        // it with `^`). Verified against real git.
2348        let (_d, store) = fresh_store();
2349        let c1 = put_file_commit(&store, "f.txt", b"keep\ngone\n", vec![], 1, 100);
2350        let c2 = put_file_commit(&store, "f.txt", b"keep\n", vec![c1], 2, 200);
2351        let c3 = put_file_commit(&store, "f.txt", b"keep\nnew\n", vec![c2], 3, 300);
2352
2353        let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2354        assert_eq!(r.lines[0].commit_hash, c3, "keep survives to the end");
2355        assert_eq!(
2356            r.lines[1].commit_hash, c1,
2357            "gone never survived a step → stays on start"
2358        );
2359        assert_eq!(r.lines[1].text, b"gone");
2360    }
2361
2362    #[test]
2363    fn reverse_modified_line_freezes_before_the_edit() {
2364        // A line modified every commit: the start version last exists in
2365        // start (it is changed in the next commit). Verified against git.
2366        let (_d, store) = fresh_store();
2367        let c1 = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![], 1, 100);
2368        let c2 = put_file_commit(&store, "f.txt", b"a\nMOD2\nc\n", vec![c1], 2, 200);
2369        let c3 = put_file_commit(&store, "f.txt", b"a\nMOD3\nc\n", vec![c2], 3, 300);
2370
2371        let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2372        assert_eq!(r.lines[0].commit_hash, c3, "a survives");
2373        assert_eq!(r.lines[1].commit_hash, c1, "MOD changed in c2 → last in c1");
2374        assert_eq!(r.lines[2].commit_hash, c3, "c survives");
2375    }
2376
2377    #[test]
2378    fn reverse_unchanged_commit_advances_attribution() {
2379        // A commit that does not touch the file still counts as a commit the
2380        // line existed in, so attribution advances through it.
2381        let (_d, store) = fresh_store();
2382        let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
2383        let c2 = put_file_commit(&store, "f.txt", b"a\n", vec![c1], 2, 200); // identical
2384        let c3 = put_file_commit(&store, "f.txt", b"b\n", vec![c2], 3, 300); // a removed
2385
2386        let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2387        assert_eq!(
2388            r.lines[0].commit_hash, c2,
2389            "a last existed in the unchanged c2, gone by c3"
2390        );
2391    }
2392
2393    #[test]
2394    fn reverse_open_end_walks_to_provided_end() {
2395        // Sanity that the range is honored: stopping the range at c2 freezes
2396        // every still-living line at c2.
2397        let (_d, store) = fresh_store();
2398        let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\n", vec![], 1, 100);
2399        let c2 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nx\n", vec![c1], 2, 200);
2400        let _c3 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![c2], 3, 300);
2401
2402        let r = blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap();
2403        assert!(
2404            r.lines.iter().all(|l| l.commit_hash == c2),
2405            "with the range ending at c2 both start lines last exist in c2"
2406        );
2407    }
2408
2409    #[test]
2410    fn reverse_traces_through_whitespace_edit_under_w() {
2411        // `-w`: a whitespace-only reformat should not count as the line
2412        // disappearing, so the line survives past the reformat.
2413        let (_d, store) = fresh_store();
2414        let c1 = put_file_commit(&store, "f.txt", b"foo(a, b)\n", vec![], 1, 100);
2415        let c2 = put_file_commit(&store, "f.txt", b"foo(a,b)\n", vec![c1], 2, 200); // ws-only
2416        let c3 = put_file_commit(&store, "f.txt", b"changed\n", vec![c2], 3, 300);
2417
2418        // Without -w the reformat in c2 "ends" the original line at c1.
2419        let plain = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2420        assert_eq!(
2421            plain.lines[0].commit_hash, c1,
2422            "ws edit ends the line at c1"
2423        );
2424
2425        let w = BlameOptions {
2426            ignore_whitespace: true,
2427            ..Default::default()
2428        };
2429        let rw = blame_file_reverse(&store, c1, c3, "f.txt", &w).unwrap();
2430        assert_eq!(
2431            rw.lines[0].commit_hash, c2,
2432            "-w traces the line through the reformat → last exists in c2"
2433        );
2434    }
2435
2436    #[test]
2437    fn reverse_start_not_ancestor_errors() {
2438        let (_d, store) = fresh_store();
2439        let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
2440        // A sibling commit not on c1's first-parent chain.
2441        let other = put_file_commit(&store, "f.txt", b"z\n", vec![], 9, 900);
2442        let err =
2443            blame_file_reverse(&store, other, c1, "f.txt", &BlameOptions::default()).unwrap_err();
2444        assert!(
2445            matches!(err, BlameError::ReverseRange { .. }),
2446            "got {err:?}"
2447        );
2448    }
2449
2450    #[test]
2451    fn reverse_missing_path_in_start_errors() {
2452        let (_d, store) = fresh_store();
2453        let c1 = put_file_commit(&store, "other.txt", b"x\n", vec![], 1, 100);
2454        let c2 = put_file_commit(&store, "f.txt", b"y\n", vec![c1], 2, 200);
2455        let err =
2456            blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap_err();
2457        assert!(matches!(err, BlameError::FileNotFound(_)), "got {err:?}");
2458    }
2459
2460    // ---- merge-aware walk (#458) -----------------------------------------
2461    // All scenarios verified against real `git blame` / `git blame
2462    // --first-parent` (2.50.1); mkit hashes differ, so assert by commit.
2463
2464    /// base → {main adds main-line, feature adds feature-line} → merge.
2465    /// Returns (base, main, feat, merge). P1 of the merge is `main`.
2466    fn diamond_distinct(store: &ObjectStore) -> (Hash, Hash, Hash, Hash) {
2467        let base = put_file_commit(store, "f.txt", b"base1\nbase2\n", vec![], 1, 100);
2468        let feat = put_file_commit(
2469            store,
2470            "f.txt",
2471            b"base1\nbase2\nfeature\n",
2472            vec![base],
2473            2,
2474            200,
2475        );
2476        let main = put_file_commit(store, "f.txt", b"main\nbase1\nbase2\n", vec![base], 3, 300);
2477        let merge = put_file_commit(
2478            store,
2479            "f.txt",
2480            b"main\nbase1\nbase2\nfeature\n",
2481            vec![main, feat],
2482            4,
2483            400,
2484        );
2485        (base, main, feat, merge)
2486    }
2487
2488    #[test]
2489    fn blame_merge_aware_credits_side_branch_lines() {
2490        // Default (merge-aware): the side-branch line is credited to the
2491        // commit that wrote it, not the merge.
2492        let (_d, store) = fresh_store();
2493        let (base, main, feat, merge) = diamond_distinct(&store);
2494        let r = blame_file(&store, merge, "f.txt").unwrap();
2495        assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
2496        assert_eq!(r.lines[1].commit_hash, base, "base1 → base");
2497        assert_eq!(r.lines[2].commit_hash, base, "base2 → base");
2498        assert_eq!(r.lines[3].commit_hash, feat, "feature → feature commit");
2499        assert!(
2500            r.lines.iter().all(|l| l.commit_hash != merge),
2501            "none → merge"
2502        );
2503    }
2504
2505    #[test]
2506    fn blame_first_parent_credits_merge_for_side_branch_line() {
2507        // `--first-parent`: the side branch is never followed, so the
2508        // feature line first appears (to that walk) at the merge.
2509        let (_d, store) = fresh_store();
2510        let (base, main, _feat, merge) = diamond_distinct(&store);
2511        let opts = BlameOptions {
2512            first_parent: true,
2513            ..Default::default()
2514        };
2515        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2516        assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
2517        assert_eq!(r.lines[1].commit_hash, base);
2518        assert_eq!(r.lines[3].commit_hash, merge, "feature line → merge");
2519    }
2520
2521    #[test]
2522    fn blame_merge_identical_line_goes_to_first_parent() {
2523        // Both branches add the SAME line; git credits the first parent.
2524        let (_d, store) = fresh_store();
2525        let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2526        let feat = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 2, 200);
2527        let main = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 3, 300);
2528        let merge = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![main, feat], 4, 400);
2529        let r = blame_file(&store, merge, "f.txt").unwrap();
2530        assert_eq!(r.lines[1].commit_hash, main, "shared line → first parent");
2531        assert!(r.lines.iter().all(|l| l.commit_hash != feat));
2532    }
2533
2534    #[test]
2535    fn blame_evil_merge_attributes_new_line_to_merge() {
2536        // The merge blob introduces a line present in neither parent: it is
2537        // introduced by the merge commit.
2538        let (_d, store) = fresh_store();
2539        let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2540        let feat = put_file_commit(&store, "f.txt", b"base\nfeat\n", vec![base], 2, 200);
2541        let main = put_file_commit(&store, "f.txt", b"main\nbase\n", vec![base], 3, 300);
2542        let merge = put_file_commit(
2543            &store,
2544            "f.txt",
2545            b"main\nbase\nfeat\nEVIL\n",
2546            vec![main, feat],
2547            4,
2548            400,
2549        );
2550        let r = blame_file(&store, merge, "f.txt").unwrap();
2551        assert_eq!(r.lines[0].commit_hash, main);
2552        assert_eq!(r.lines[1].commit_hash, base);
2553        assert_eq!(r.lines[2].commit_hash, feat);
2554        assert_eq!(r.lines[3].commit_hash, merge, "the evil line → merge");
2555    }
2556
2557    #[test]
2558    fn blame_octopus_merge_credits_each_branch() {
2559        // A 3-parent merge: each branch's line is credited to its commit.
2560        let (_d, store) = fresh_store();
2561        let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2562        let b1 = put_file_commit(&store, "f.txt", b"base\nb1\n", vec![base], 2, 200);
2563        let b2 = put_file_commit(&store, "f.txt", b"base\nb2\n", vec![base], 3, 300);
2564        let b3 = put_file_commit(&store, "f.txt", b"base\nb3\n", vec![base], 4, 400);
2565        let merge = put_file_commit(
2566            &store,
2567            "f.txt",
2568            b"base\nb1\nb2\nb3\n",
2569            vec![b1, b2, b3],
2570            5,
2571            500,
2572        );
2573        let r = blame_file(&store, merge, "f.txt").unwrap();
2574        assert_eq!(r.lines[0].commit_hash, base);
2575        assert_eq!(r.lines[1].commit_hash, b1);
2576        assert_eq!(r.lines[2].commit_hash, b2);
2577        assert_eq!(r.lines[3].commit_hash, b3);
2578    }
2579
2580    #[test]
2581    fn blame_m_merge_credits_move_from_second_parent() {
2582        // A long line L is written on the SECOND merge parent and the merge
2583        // moves it to the file's end. `git blame -M` credits the moved line
2584        // to the 2nd-parent commit that wrote it, NOT the merge — the detector
2585        // must run against the second parent, not the first only. (Pinned
2586        // against real `git blame -M`, git 2.50.1.)
2587        let (_d, store) = fresh_store();
2588        let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
2589        // First parent: an unrelated edit, no L.
2590        let p1 = put_file_commit(&store, "f.txt", b"X\nY\nZ\n", vec![base], 2, 200);
2591        // Second parent: writes L at the top.
2592        let v2 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
2593        let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2594        // Merge (p1 first, c2 second): L moved to the end.
2595        let vm = [b"X" as &[u8], b"Y", b"Z", LONG_LINE, b""].join(&b'\n');
2596        let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2597
2598        let opts = BlameOptions {
2599            moves: MoveDetection::On { threshold: 20 },
2600            ..Default::default()
2601        };
2602        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2603        // Child order: X, Y, Z, L.
2604        assert_eq!(r.lines[3].text, LONG_LINE);
2605        assert_eq!(
2606            r.lines[3].commit_hash, c2,
2607            "-M credits the move to the 2nd-parent origin, not the merge"
2608        );
2609        assert_eq!(r.lines[2].commit_hash, p1, "Z stays on the first parent");
2610    }
2611
2612    #[test]
2613    fn blame_m_merge_move_prefers_first_parent() {
2614        // Both parents independently wrote the SAME long line L at the top;
2615        // the merge moves L to the end so neither parent's matcher explains it
2616        // in place. `git blame -M` credits the FIRST parent (the merge-walk's
2617        // first-parent-wins tie-break also governs the move detector). Pinned
2618        // against real `git blame -M`.
2619        let (_d, store) = fresh_store();
2620        let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
2621        let v = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
2622        let p1 = put_file_commit(&store, "f.txt", &v, vec![base], 2, 200);
2623        let c2 = put_file_commit(&store, "f.txt", &v, vec![base], 3, 300);
2624        let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
2625        let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2626
2627        let opts = BlameOptions {
2628            moves: MoveDetection::On { threshold: 20 },
2629            ..Default::default()
2630        };
2631        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2632        // Child order: X, Y, L.
2633        assert_eq!(r.lines[2].text, LONG_LINE);
2634        assert_eq!(
2635            r.lines[2].commit_hash, p1,
2636            "-M move at a merge prefers the first parent on a tie"
2637        );
2638        assert!(
2639            r.lines.iter().all(|l| l.commit_hash != c2),
2640            "the second parent never wins the tie"
2641        );
2642    }
2643
2644    #[test]
2645    fn blame_ignore_rev_merge_falls_through_to_second_parent() {
2646        // An ignored merge resolves a modify/delete conflict by keeping a
2647        // NOISE version of the feature line. The first parent DELETED that
2648        // line (no positional counterpart in its conflicted hunk), so the
2649        // fall-through must cross to the SECOND parent that actually wrote the
2650        // content — `git blame --ignore-rev <merge>` credits the feature
2651        // commit, not the merge. (Pinned against real git 2.50.1.)
2652        let (_d, store) = fresh_store();
2653        let base = put_file_commit(&store, "f.txt", b"TOP\nMID\nBOT\n", vec![], 1, 100);
2654        // First parent: deletes MID.
2655        let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
2656        // Second parent: rewrites MID to real content.
2657        let v2 = [b"TOP" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"BOT", b""].join(&b'\n');
2658        let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2659        // Merge keeps a noise version of the feature line.
2660        let vm = [b"TOP" as &[u8], b"  REAL_CONTENT_OF_B_LINE  X", b"BOT", b""].join(&b'\n');
2661        let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2662
2663        let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
2664        assert_eq!(r.lines[1].text, b"  REAL_CONTENT_OF_B_LINE  X");
2665        assert_eq!(
2666            r.lines[1].commit_hash, c2,
2667            "ignored merge falls through across to the 2nd parent's origin"
2668        );
2669    }
2670
2671    #[test]
2672    fn blame_ignore_rev_merge_prefers_first_parent_counterpart() {
2673        // Both parents have a positional counterpart in the conflicted hunk;
2674        // the ignored merge's fall-through prefers the FIRST parent (git's
2675        // positional fall-through is first-parent-wins, the same tie-break the
2676        // merge walk uses elsewhere). Pinned against real git.
2677        let (_d, store) = fresh_store();
2678        let base = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
2679        let p1 = put_file_commit(
2680            &store,
2681            "f.txt",
2682            b"a\nMAIN_B_VERSION\nc\n",
2683            vec![base],
2684            2,
2685            200,
2686        );
2687        let v2 = [b"a" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"c", b""].join(&b'\n');
2688        let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2689        let vm = [b"a" as &[u8], b"  REAL_CONTENT_OF_B_LINE  X", b"c", b""].join(&b'\n');
2690        let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2691
2692        let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
2693        assert_eq!(
2694            r.lines[1].commit_hash, p1,
2695            "fall-through prefers the first parent on a positional tie"
2696        );
2697        assert!(
2698            r.lines.iter().all(|l| l.commit_hash != c2),
2699            "the second parent does not win when the first parent has a counterpart"
2700        );
2701    }
2702
2703    #[test]
2704    fn blame_ignore_rev_precise_merge_second_parent_composition() {
2705        // Composes `--ignore-rev-precise` with the per-parent merge walk
2706        // (`apply_ignore_fallthrough`'s loop over every relevant parent):
2707        // the FIRST parent lacks the swapped content entirely (no positional
2708        // counterpart at all, so it contributes nothing and the walk falls
2709        // through to the next parent — same shape as
2710        // `blame_ignore_rev_merge_falls_through_to_second_parent`), and the
2711        // SECOND parent has the same moved-and-reindented reformat as
2712        // `blame_ignore_rev_precise_reattributes_moved_reindented_lines`
2713        // (ZZZ is recognized unchanged by plain LCS; YYY and XXX have no
2714        // in-hunk counterpart and are genuine insertions under the
2715        // positional default). Precise mode's whole-file search must run
2716        // against the SECOND parent's file (the one that actually has the
2717        // content), not the first parent's, which never pairs anything at
2718        // all. Tokens are >= 3 bytes so the trivial-key guard doesn't apply.
2719        let (_d, store) = fresh_store();
2720        let base = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![], 1, 100);
2721        // First (ignored merge's first) parent: never had XXX/YYY/ZZZ at all.
2722        let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
2723        // Second parent: builds up XXX, YYY, ZZZ with distinct origins.
2724        let c_x = put_file_commit(&store, "f.txt", b"TOP\nXXX\nBOT\n", vec![base], 3, 300);
2725        let c_y = put_file_commit(&store, "f.txt", b"TOP\nXXX\nYYY\nBOT\n", vec![c_x], 4, 400);
2726        let c_z = put_file_commit(
2727            &store,
2728            "f.txt",
2729            b"TOP\nXXX\nYYY\nZZZ\nBOT\n",
2730            vec![c_y],
2731            5,
2732            500,
2733        );
2734        // Ignored merge: reorders + reindents XXX/YYY/ZZZ (same shape as the
2735        // non-merge reindent test).
2736        let merge = put_file_commit(
2737            &store,
2738            "f.txt",
2739            b"TOP\n  ZZZ\n  YYY\n  XXX\nBOT\n",
2740            vec![p1, c_z],
2741            6,
2742            600,
2743        );
2744
2745        let positional_opts = BlameOptions {
2746            ignore_whitespace: true,
2747            ignore_revs: Arc::new([merge].into_iter().collect()),
2748            ..Default::default()
2749        };
2750        let positional = blame_file_with(&store, merge, "f.txt", &positional_opts).unwrap();
2751        assert_eq!(
2752            positional.lines[1].commit_hash, c_z,
2753            "ZZZ is recognized unchanged by the LCS matcher itself, against the 2nd parent"
2754        );
2755        assert_eq!(
2756            positional.lines[2].commit_hash, merge,
2757            "positional: YYY has no in-hunk counterpart on either parent, stays on the merge"
2758        );
2759        assert_eq!(
2760            positional.lines[3].commit_hash, merge,
2761            "positional: XXX has no in-hunk counterpart on either parent, stays on the merge"
2762        );
2763
2764        let precise = blame_file_with(&store, merge, "f.txt", &ignoring_precise(&[merge])).unwrap();
2765        assert_eq!(
2766            precise.lines[1].commit_hash, c_z,
2767            "ZZZ is unaffected by precise mode (already resolved by plain LCS)"
2768        );
2769        assert_eq!(
2770            precise.lines[2].commit_hash, c_y,
2771            "precise: YYY correctly attributed to its true origin via the 2nd parent's whole file"
2772        );
2773        assert_eq!(
2774            precise.lines[3].commit_hash, c_x,
2775            "precise: XXX correctly attributed to its true origin via the 2nd parent's whole file"
2776        );
2777        assert!(
2778            positional.lines.iter().all(|l| l.commit_hash != p1)
2779                && precise.lines.iter().all(|l| l.commit_hash != p1),
2780            "the content-less first parent never wins"
2781        );
2782    }
2783
2784    #[test]
2785    fn blame_c_merge_credits_copy_from_second_parent() {
2786        // `-C` merge parity: the blamed file already EXISTS in the parents and
2787        // a merge appends a block that lives in `src.txt` on the SECOND parent.
2788        // `git blame -C -C <merge> -- b.txt` credits the SECOND parent (`c2`),
2789        // enumerating that parent's tree to find the copy source — it does NOT
2790        // credit the merge. Pinned against real git 2.50.1:
2791        //   base: b.txt="hello"; p1 adds m.txt; c2 adds src.txt with the block;
2792        //   merge(p1,c2) appends the block to b.txt
2793        //   => `git blame -C -C` credits c2/src.txt for the appended lines.
2794        let (_d, store) = fresh_store();
2795        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2796        let p1 = put_multi_file_commit(
2797            &store,
2798            &[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
2799            vec![base],
2800            2,
2801            200,
2802        );
2803        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2804        let c2 = put_multi_file_commit(
2805            &store,
2806            &[("b.txt", b"hello\n"), ("src.txt", &src)],
2807            vec![base],
2808            3,
2809            300,
2810        );
2811        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2812        let merge = put_multi_file_commit(
2813            &store,
2814            &[
2815                ("b.txt", &bmerge),
2816                ("m.txt", b"main only\n"),
2817                ("src.txt", &src),
2818            ],
2819            vec![p1, c2],
2820            4,
2821            400,
2822        );
2823
2824        let opts = BlameOptions {
2825            copies: CopyDetection::On {
2826                level: 2,
2827                threshold: 40,
2828            },
2829            ..Default::default()
2830        };
2831        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2832        // Child order: hello, BLOCK_A, BLOCK_B.
2833        assert_eq!(r.lines[1].text, BLOCK_A);
2834        assert_eq!(
2835            r.lines[1].commit_hash, c2,
2836            "a modified file's appended block is copied across to the second parent's tree (git credits c2)"
2837        );
2838        assert_eq!(
2839            r.lines[2].commit_hash, c2,
2840            "the whole copied block is credited to c2"
2841        );
2842        // The unchanged first line stays on its own origin, not the merge.
2843        assert_ne!(r.lines[1].commit_hash, merge);
2844    }
2845
2846    #[test]
2847    fn blame_c_merge_credits_copy_from_third_octopus_parent() {
2848        // `-C -C` searches EVERY relevant merge parent's tree, not just the
2849        // first two. Octopus merge(p1, p2, p3): the copy source lives only in
2850        // `src.txt` on the THIRD parent; real git credits p3 for the appended
2851        // block (pinned against git 2.50.1). Guards against a first-/second-
2852        // parent-only search.
2853        let (_d, store) = fresh_store();
2854        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2855        let p1 = put_multi_file_commit(
2856            &store,
2857            &[("b.txt", b"hello\n"), ("x.txt", b"x only\n")],
2858            vec![base],
2859            2,
2860            200,
2861        );
2862        let p2 = put_multi_file_commit(
2863            &store,
2864            &[("b.txt", b"hello\n"), ("y.txt", b"y only\n")],
2865            vec![base],
2866            3,
2867            300,
2868        );
2869        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2870        let p3 = put_multi_file_commit(
2871            &store,
2872            &[("b.txt", b"hello\n"), ("src.txt", &src)],
2873            vec![base],
2874            4,
2875            400,
2876        );
2877        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2878        let merge = put_multi_file_commit(
2879            &store,
2880            &[
2881                ("b.txt", &bmerge),
2882                ("x.txt", b"x only\n"),
2883                ("y.txt", b"y only\n"),
2884                ("src.txt", &src),
2885            ],
2886            vec![p1, p2, p3],
2887            5,
2888            500,
2889        );
2890
2891        let opts = BlameOptions {
2892            copies: CopyDetection::On {
2893                level: 2,
2894                threshold: 40,
2895            },
2896            ..Default::default()
2897        };
2898        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2899        assert_eq!(r.lines[1].text, BLOCK_A);
2900        assert_eq!(
2901            r.lines[1].commit_hash, p3,
2902            "the copied block is traced to the third octopus parent's tree"
2903        );
2904    }
2905
2906    #[test]
2907    fn blame_c_merge_source_only_in_merge_tree_credits_merge() {
2908        // Counterpart guard: when the copy source (`src.txt`) is introduced by
2909        // the MERGE itself and no parent's tree holds the block, real git
2910        // credits the merge — the cross-parent search must not manufacture a
2911        // parent credit. Pinned against git 2.50.1.
2912        let (_d, store) = fresh_store();
2913        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2914        let p1 = put_multi_file_commit(
2915            &store,
2916            &[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
2917            vec![base],
2918            2,
2919            200,
2920        );
2921        let c2 = put_multi_file_commit(
2922            &store,
2923            &[("b.txt", b"hello\n"), ("o.txt", b"other\n")],
2924            vec![base],
2925            3,
2926            300,
2927        );
2928        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2929        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2930        // src.txt exists only in the merge's own tree (no parent has it).
2931        let merge = put_multi_file_commit(
2932            &store,
2933            &[
2934                ("b.txt", &bmerge),
2935                ("m.txt", b"main only\n"),
2936                ("o.txt", b"other\n"),
2937                ("src.txt", &src),
2938            ],
2939            vec![p1, c2],
2940            4,
2941            400,
2942        );
2943
2944        let opts = BlameOptions {
2945            copies: CopyDetection::On {
2946                level: 2,
2947                threshold: 40,
2948            },
2949            ..Default::default()
2950        };
2951        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2952        assert_eq!(r.lines[1].text, BLOCK_A);
2953        assert_eq!(
2954            r.lines[1].commit_hash, merge,
2955            "no parent holds the source, so the appended block stays on the merge (git parity)"
2956        );
2957    }
2958
2959    #[test]
2960    fn blame_c_merge_copy_tie_prefers_deduped_second_parent() {
2961        // Real `git blame -C -C` recipe (git 2.50.1):
2962        //   base: b.txt="hello"
2963        //   p1  = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")
2964        //   c2  = base + s2.txt(BLOCK_A,BLOCK_B,"zzz")   (same block, 2nd parent)
2965        //   merge(p1,c2): b.txt="hello"+BLOCK_A+BLOCK_B  (both s1.txt,s2.txt kept)
2966        //   $ git blame -C -C -l b.txt
2967        //   -> lines 2-3 credited to c2/s2.txt, NOT p1 (confirmed independent of
2968        //      file name: "aaa.txt" on c2 still beats "zzz.txt" on p1).
2969        // Mechanism (see move_copy's module note): p1 keeps its porigin, so
2970        // its -C candidates are only files MODIFIED between p1 and the merge
2971        // — s1.txt is unchanged, hence invisible. c2's b.txt blob is
2972        // identical to p1's, so c2's porigin is deduped and c2 gets the
2973        // whole-tree search, which finds s2.txt.
2974        let (_d, store) = fresh_store();
2975        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2976        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2977        let p1 = put_multi_file_commit(
2978            &store,
2979            &[("b.txt", b"hello\n"), ("s1.txt", &src)],
2980            vec![base],
2981            2,
2982            200,
2983        );
2984        let c2 = put_multi_file_commit(
2985            &store,
2986            &[("b.txt", b"hello\n"), ("s2.txt", &src)],
2987            vec![base],
2988            3,
2989            300,
2990        );
2991        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2992        let merge = put_multi_file_commit(
2993            &store,
2994            &[("b.txt", &bmerge), ("s1.txt", &src), ("s2.txt", &src)],
2995            vec![p1, c2],
2996            4,
2997            400,
2998        );
2999
3000        let opts = BlameOptions {
3001            copies: CopyDetection::On {
3002                level: 2,
3003                threshold: 40,
3004            },
3005            ..Default::default()
3006        };
3007        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3008        assert_eq!(r.lines[1].text, BLOCK_A);
3009        assert_eq!(
3010            r.lines[1].commit_hash, c2,
3011            "a copy tie across parents resolves to the non-first parent (git parity)"
3012        );
3013        assert_eq!(r.lines[2].commit_hash, c2);
3014        assert!(
3015            r.lines.iter().all(|l| l.commit_hash != p1),
3016            "the first parent never wins an interior -C tie"
3017        );
3018    }
3019
3020    #[test]
3021    fn blame_c_merge_copy_tie_octopus_prefers_first_non_first_parent() {
3022        // Real git recipe (git 2.50.1) — a 3-way octopus tie where the FIRST
3023        // parent has NO candidate at all and parents 2 and 3 both do:
3024        //   base: b.txt="hello"
3025        //   p1  = base + pm.txt("p1 only")            (no candidate)
3026        //   c2  = base + s2.txt(BLOCK_A,BLOCK_B,"zzz") (candidate)
3027        //   c3  = base + s3.txt(BLOCK_A,BLOCK_B,"zzz") (same candidate)
3028        //   merge(p1,c2,c3): b.txt="hello"+BLOCK_A+BLOCK_B
3029        //   $ git blame -C -C -l b.txt  -> credited to c2 (the SECOND parent,
3030        //   i.e. the FIRST of the two tied non-first parents), NOT c3 (the
3031        //   literal last parent). This disproves plain "last-parent-wins".
3032        //   Mechanism: p1 keeps its porigin (modified-files channel only —
3033        //   pm.txt has no block); c2's and c3's identical b.txt blobs are
3034        //   deduped to porigin-less, so both get whole-tree searches in
3035        //   parent order and c2, searched first, claims the block.
3036        let (_d, store) = fresh_store();
3037        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
3038        let p1 = put_multi_file_commit(
3039            &store,
3040            &[("b.txt", b"hello\n"), ("pm.txt", b"p1 only\n")],
3041            vec![base],
3042            2,
3043            200,
3044        );
3045        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3046        let c2 = put_multi_file_commit(
3047            &store,
3048            &[("b.txt", b"hello\n"), ("s2.txt", &src)],
3049            vec![base],
3050            3,
3051            300,
3052        );
3053        let c3 = put_multi_file_commit(
3054            &store,
3055            &[("b.txt", b"hello\n"), ("s3.txt", &src)],
3056            vec![base],
3057            4,
3058            400,
3059        );
3060        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3061        let merge = put_multi_file_commit(
3062            &store,
3063            &[("b.txt", &bmerge), ("s2.txt", &src), ("s3.txt", &src)],
3064            vec![p1, c2, c3],
3065            5,
3066            500,
3067        );
3068
3069        let opts = BlameOptions {
3070            copies: CopyDetection::On {
3071                level: 2,
3072                threshold: 40,
3073            },
3074            ..Default::default()
3075        };
3076        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3077        assert_eq!(r.lines[1].text, BLOCK_A);
3078        assert_eq!(
3079            r.lines[1].commit_hash, c2,
3080            "the first NON-first parent (in order) wins the octopus tie, not the literal last parent"
3081        );
3082        assert_eq!(r.lines[2].commit_hash, c2);
3083    }
3084
3085    #[test]
3086    fn blame_c_merge_copy_source_only_on_first_parent_stays_on_merge() {
3087        // Counterpart guard, no tie involved: the block's ONLY candidate
3088        // source lives on the FIRST parent (p1's s1.txt); the second parent
3089        // has an unrelated file and no candidate at all. Real git recipe
3090        // (git 2.50.1):
3091        //   base: b.txt="hello"
3092        //   p1  = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")
3093        //   c2  = base + m.txt("other unrelated content")
3094        //   merge(p1,c2): b.txt="hello"+BLOCK_A+BLOCK_B
3095        //   $ git blame -C -C -l b.txt -> credited to the MERGE commit
3096        //   itself, NOT p1, even though p1's candidate is uncontested.
3097        //   Mechanism: p1 keeps its porigin, so its -C candidates are only
3098        //   files MODIFIED between p1 and the merge — s1.txt is unchanged,
3099        //   hence invisible. c2 is deduped (same b.txt blob) and gets the
3100        //   whole-tree search, but c2's tree has no block. See
3101        //   blame_c_level1_merge_modified_source_credits_first_parent for
3102        //   the converse: a first-parent source that IS modified gets
3103        //   credited.
3104        let (_d, store) = fresh_store();
3105        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
3106        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3107        let p1 = put_multi_file_commit(
3108            &store,
3109            &[("b.txt", b"hello\n"), ("s1.txt", &src)],
3110            vec![base],
3111            2,
3112            200,
3113        );
3114        let c2 = put_multi_file_commit(
3115            &store,
3116            &[
3117                ("b.txt", b"hello\n"),
3118                ("m.txt", b"other unrelated content\n"),
3119            ],
3120            vec![base],
3121            3,
3122            300,
3123        );
3124        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3125        let merge = put_multi_file_commit(
3126            &store,
3127            &[
3128                ("b.txt", &bmerge),
3129                ("s1.txt", &src),
3130                ("m.txt", b"other unrelated content\n"),
3131            ],
3132            vec![p1, c2],
3133            4,
3134            400,
3135        );
3136
3137        let opts = BlameOptions {
3138            copies: CopyDetection::On {
3139                level: 2,
3140                threshold: 40,
3141            },
3142            ..Default::default()
3143        };
3144        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3145        assert_eq!(r.lines[1].text, BLOCK_A);
3146        assert_eq!(
3147            r.lines[1].commit_hash, merge,
3148            "an uncontested -C source on the first parent is never traced (git parity)"
3149        );
3150        assert_eq!(r.lines[2].commit_hash, merge);
3151    }
3152
3153    #[test]
3154    fn blame_c_merge_unmodified_first_parent_source_with_fileless_second_stays_on_merge() {
3155        // Modify/delete merge where the SECOND parent deleted the blamed
3156        // file: the filtered DAG sees a single relevant parent, but the
3157        // commit is still a true two-parent merge and p1's unchanged
3158        // s1.txt must stay invisible (modified-files channel). Guards the
3159        // old bug where `is_merge` was keyed on the FILTERED parent list,
3160        // making this shape look linear and tracing the block to p1.
3161        // Real git recipe (git 2.50.1):
3162        //   base: b.txt="hello", sbase.txt
3163        //   p1  = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")   (keeps b.txt)
3164        //   p2  = base - b.txt                            (deleted)
3165        //   merge(p1,p2): b.txt="hello"+BLOCK_A+BLOCK_B; s1.txt unchanged
3166        //   $ git blame -C -C b.txt -> block stays on the MERGE.
3167        let (_d, store) = fresh_store();
3168        let base = put_multi_file_commit(
3169            &store,
3170            &[
3171                ("b.txt", b"hello\n"),
3172                ("sbase.txt", b"source header line\n"),
3173            ],
3174            vec![],
3175            1,
3176            100,
3177        );
3178        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3179        let p1 = put_multi_file_commit(
3180            &store,
3181            &[
3182                ("b.txt", b"hello\n"),
3183                ("sbase.txt", b"source header line\n"),
3184                ("s1.txt", &src),
3185            ],
3186            vec![base],
3187            2,
3188            200,
3189        );
3190        let p2 = put_multi_file_commit(
3191            &store,
3192            &[("sbase.txt", b"source header line\n")],
3193            vec![base],
3194            3,
3195            300,
3196        );
3197        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3198        let merge = put_multi_file_commit(
3199            &store,
3200            &[
3201                ("b.txt", &bmerge),
3202                ("sbase.txt", b"source header line\n"),
3203                ("s1.txt", &src),
3204            ],
3205            vec![p1, p2],
3206            4,
3207            400,
3208        );
3209
3210        let opts = BlameOptions {
3211            copies: CopyDetection::On {
3212                level: 2,
3213                threshold: 40,
3214            },
3215            ..Default::default()
3216        };
3217        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3218        assert_eq!(r.lines[1].text, BLOCK_A);
3219        assert_eq!(
3220            r.lines[1].commit_hash, merge,
3221            "a fileless second parent does not make the merge linear: p1's \
3222             unchanged source stays invisible and the block stays on the merge (git parity)"
3223        );
3224        assert_eq!(r.lines[2].commit_hash, merge);
3225    }
3226
3227    #[test]
3228    fn blame_c_merge_file_deleting_parent_supplies_copy_source() {
3229        // A parent that DELETED the blamed file is still `-C -C` searched —
3230        // porigin-less parents get the whole-tree channel. Guards the old
3231        // bug where detection iterated only the file-bearing (filtered)
3232        // parents and p2's tree was never offered.
3233        // Real git recipe (git 2.50.1):
3234        //   base: f.txt="hello", s.txt="source header line"
3235        //   p1  = base                                    (unchanged)
3236        //   p2  = base - f.txt; s.txt gains BLOCK_A+BLOCK_B
3237        //   merge(p1,p2): f.txt="hello"+BLOCK_A+BLOCK_B; s.txt = p2's
3238        //   $ git blame -C -C f.txt -> block credited to p2 (via s.txt).
3239        let (_d, store) = fresh_store();
3240        let base = put_multi_file_commit(
3241            &store,
3242            &[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
3243            vec![],
3244            1,
3245            100,
3246        );
3247        let p1 = put_multi_file_commit(
3248            &store,
3249            &[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
3250            vec![base],
3251            2,
3252            200,
3253        );
3254        let src = [
3255            b"source header line" as &[u8],
3256            BLOCK_A,
3257            BLOCK_B,
3258            b"zzz",
3259            b"",
3260        ]
3261        .join(&b'\n');
3262        let p2 = put_multi_file_commit(&store, &[("s.txt", &src)], vec![base], 3, 300);
3263        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3264        let merge = put_multi_file_commit(
3265            &store,
3266            &[("f.txt", &bmerge), ("s.txt", &src)],
3267            vec![p1, p2],
3268            4,
3269            400,
3270        );
3271
3272        let opts = BlameOptions {
3273            copies: CopyDetection::On {
3274                level: 2,
3275                threshold: 40,
3276            },
3277            ..Default::default()
3278        };
3279        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
3280        assert_eq!(r.lines[1].text, BLOCK_A);
3281        assert_eq!(
3282            r.lines[1].commit_hash, p2,
3283            "the parent that deleted the blamed file is whole-tree searched \
3284             and its source claims the block (git parity)"
3285        );
3286        assert_eq!(r.lines[2].commit_hash, p2);
3287    }
3288
3289    #[test]
3290    fn blame_c_merge_fileless_first_parent_unmodified_second_source_stays_on_merge() {
3291        // Octopus where the FIRST parent deleted the blamed file and the
3292        // second parent's tree holds the block in a source UNCHANGED at
3293        // the merge. p1 is porigin-less (whole tree — no block there); p2
3294        // is the first file-bearing parent so it KEEPS its porigin and only
3295        // its modified files are candidates — s2.txt is unchanged, hence
3296        // invisible; p3's b.txt blob dedups against p2's (whole tree — no
3297        // block). Guards the old bug where the filtered index made p2 look
3298        // like "the first parent" for the wrong reason (and, on the fixed
3299        // real-parent model, would have wrongly whole-tree-searched p2).
3300        // Real git recipe (git 2.50.1):
3301        //   base: b.txt="hello", s2.txt="source header line"
3302        //   p1  = base - b.txt
3303        //   p2  = base with s2.txt = BLOCK_A+BLOCK_B+... (gains the block)
3304        //   p3  = base + o.txt
3305        //   merge(p1,p2,p3): b.txt="hello"+BLOCK; s2.txt = p2's; o.txt kept
3306        //   $ git blame -C -C b.txt -> block stays on the MERGE.
3307        let (_d, store) = fresh_store();
3308        let base = put_multi_file_commit(
3309            &store,
3310            &[("b.txt", b"hello\n"), ("s2.txt", b"source header line\n")],
3311            vec![],
3312            1,
3313            100,
3314        );
3315        let p1 = put_multi_file_commit(
3316            &store,
3317            &[("s2.txt", b"source header line\n")],
3318            vec![base],
3319            2,
3320            200,
3321        );
3322        let src = [
3323            b"source header line" as &[u8],
3324            BLOCK_A,
3325            BLOCK_B,
3326            b"zzz",
3327            b"",
3328        ]
3329        .join(&b'\n');
3330        let p2 = put_multi_file_commit(
3331            &store,
3332            &[("b.txt", b"hello\n"), ("s2.txt", &src)],
3333            vec![base],
3334            3,
3335            300,
3336        );
3337        let p3 = put_multi_file_commit(
3338            &store,
3339            &[
3340                ("b.txt", b"hello\n"),
3341                ("s2.txt", b"source header line\n"),
3342                ("o.txt", b"other\n"),
3343            ],
3344            vec![base],
3345            4,
3346            400,
3347        );
3348        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3349        let merge = put_multi_file_commit(
3350            &store,
3351            &[("b.txt", &bmerge), ("s2.txt", &src), ("o.txt", b"other\n")],
3352            vec![p1, p2, p3],
3353            5,
3354            500,
3355        );
3356
3357        let opts = BlameOptions {
3358            copies: CopyDetection::On {
3359                level: 2,
3360                threshold: 40,
3361            },
3362            ..Default::default()
3363        };
3364        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3365        assert_eq!(r.lines[1].text, BLOCK_A);
3366        assert_eq!(
3367            r.lines[1].commit_hash, merge,
3368            "the first file-bearing parent keeps its porigin even when the real \
3369             first parent is fileless; its unchanged source stays invisible (git parity)"
3370        );
3371        assert_eq!(r.lines[2].commit_hash, merge);
3372    }
3373
3374    #[test]
3375    fn blame_c_level1_merge_modified_source_credits_first_parent() {
3376        // Plain `-C` (level 1) at a true merge: the source file IS modified
3377        // between the first parent and the merge (the block moved out of
3378        // s1.txt into b.txt), so it is a modified-files-channel candidate
3379        // and the FIRST parent gets the credit — there is no first-parent
3380        // carve-out in git, at any level. Guards the old bug where the
3381        // carve-out unconditionally zeroed the first parent's copy search.
3382        // Real git recipe (git 2.50.1), same result with -C and -C -C:
3383        //   base: b.txt="hello", s1.txt="source header line"
3384        //   p1  = base with s1.txt gaining BLOCK_A+BLOCK_B
3385        //   p2  = base + o.txt
3386        //   merge(p1,p2): b.txt="hello"+BLOCK; s1.txt back to base's (block
3387        //   moved out); o.txt kept
3388        //   $ git blame -C b.txt -> block credited to P1 (via s1.txt).
3389        let (_d, store) = fresh_store();
3390        let base = put_multi_file_commit(
3391            &store,
3392            &[("b.txt", b"hello\n"), ("s1.txt", b"source header line\n")],
3393            vec![],
3394            1,
3395            100,
3396        );
3397        let src = [
3398            b"source header line" as &[u8],
3399            BLOCK_A,
3400            BLOCK_B,
3401            b"zzz",
3402            b"",
3403        ]
3404        .join(&b'\n');
3405        let p1 = put_multi_file_commit(
3406            &store,
3407            &[("b.txt", b"hello\n"), ("s1.txt", &src)],
3408            vec![base],
3409            2,
3410            200,
3411        );
3412        let p2 = put_multi_file_commit(
3413            &store,
3414            &[
3415                ("b.txt", b"hello\n"),
3416                ("s1.txt", b"source header line\n"),
3417                ("o.txt", b"other\n"),
3418            ],
3419            vec![base],
3420            3,
3421            300,
3422        );
3423        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3424        let merge = put_multi_file_commit(
3425            &store,
3426            &[
3427                ("b.txt", &bmerge),
3428                ("s1.txt", b"source header line\n"),
3429                ("o.txt", b"other\n"),
3430            ],
3431            vec![p1, p2],
3432            4,
3433            400,
3434        );
3435
3436        for level in [1u8, 2] {
3437            let opts = BlameOptions {
3438                copies: CopyDetection::On {
3439                    level,
3440                    threshold: 40,
3441                },
3442                ..Default::default()
3443            };
3444            let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3445            assert_eq!(r.lines[1].text, BLOCK_A);
3446            assert_eq!(
3447                r.lines[1].commit_hash, p1,
3448                "a source modified between the first parent and the merge is a \
3449                 level-{level} candidate and credits the first parent (git parity)"
3450            );
3451        }
3452    }
3453
3454    #[test]
3455    fn blame_c_boundary_first_parent_mode_still_searches_first_parent() {
3456        // `--first-parent -C -C` at a merge boundary: the real parent list
3457        // is truncated to the first parent (git's first_scapegoat does the
3458        // same), which is porigin-less for a newly-added file and therefore
3459        // whole-tree searched — the source on p1 is credited exactly as in
3460        // the merge-aware walk. Real git recipe (git 2.50.1):
3461        //   base: x.txt
3462        //   p1  = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")
3463        //   p2  = base + o.txt
3464        //   merge(p1,p2): + n.txt = BLOCK_A+BLOCK_B   (new file)
3465        //   $ git blame --first-parent -C -C n.txt -> credited to P1.
3466        let (_d, store) = fresh_store();
3467        let base = put_multi_file_commit(&store, &[("x.txt", b"x\n")], vec![], 1, 100);
3468        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3469        let p1 = put_multi_file_commit(
3470            &store,
3471            &[("x.txt", b"x\n"), ("s1.txt", &src)],
3472            vec![base],
3473            2,
3474            200,
3475        );
3476        let p2 = put_multi_file_commit(
3477            &store,
3478            &[("x.txt", b"x\n"), ("o.txt", b"other\n")],
3479            vec![base],
3480            3,
3481            300,
3482        );
3483        let newf = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3484        let merge = put_multi_file_commit(
3485            &store,
3486            &[
3487                ("x.txt", b"x\n"),
3488                ("s1.txt", &src),
3489                ("o.txt", b"other\n"),
3490                ("n.txt", &newf),
3491            ],
3492            vec![p1, p2],
3493            4,
3494            400,
3495        );
3496
3497        let opts = BlameOptions {
3498            copies: CopyDetection::On {
3499                level: 2,
3500                threshold: 40,
3501            },
3502            first_parent: true,
3503            ..Default::default()
3504        };
3505        let r = blame_file_with(&store, merge, "n.txt", &opts).unwrap();
3506        assert_eq!(r.lines[0].text, BLOCK_A);
3507        assert_eq!(
3508            r.lines[0].commit_hash, p1,
3509            "--first-parent truncates the boundary search to the real first \
3510             parent, which is still whole-tree searched (git parity)"
3511        );
3512        assert_eq!(r.lines[1].commit_hash, p1);
3513    }
3514
3515    #[test]
3516    fn blame_c_merge_mixed_within_file_move_beats_copy_on_tie() {
3517        // Real git recipe (git 2.50.1), `-M -C -C`: a length-1 tie between an
3518        // within-file `-M` move source on the FIRST parent and a cross-file
3519        // `-C` copy source on the second parent for the SAME moved line:
3520        //   base: f.txt="X\nY\n"
3521        //   p1  = f.txt=LONG_LINE+"X\nY\n"           (own prior version: -M source)
3522        //   c2  = base f.txt (unchanged) + other.txt=LONG_LINE+"other stuff\n" (-C source)
3523        //   merge(p1,c2): f.txt="X\nY\n"+LONG_LINE   (moved to the end)
3524        //   $ git blame -M -C -C -l f.txt -> LONG_LINE credited to p1 (the
3525        //   `-M` move), not c2's `-C` copy. `-M` is unaffected by `-C`'s
3526        //   first-parent carve-out and keeps its own first-parent-wins tie.
3527        let (_d, store) = fresh_store();
3528        let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
3529        let v1 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
3530        let p1 = put_file_commit(&store, "f.txt", &v1, vec![base], 2, 200);
3531        let c2 = put_multi_file_commit(
3532            &store,
3533            &[
3534                ("f.txt", b"X\nY\n"),
3535                ("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
3536            ],
3537            vec![base],
3538            3,
3539            300,
3540        );
3541        let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
3542        let merge = put_multi_file_commit(
3543            &store,
3544            &[
3545                ("f.txt", &vm),
3546                ("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
3547            ],
3548            vec![p1, c2],
3549            4,
3550            400,
3551        );
3552
3553        let opts = BlameOptions {
3554            moves: MoveDetection::On { threshold: 20 },
3555            copies: CopyDetection::On {
3556                level: 2,
3557                threshold: 40,
3558            },
3559            ..Default::default()
3560        };
3561        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
3562        assert_eq!(r.lines[2].text, LONG_LINE);
3563        assert_eq!(
3564            r.lines[2].commit_hash, p1,
3565            "-M's within-file move on the first parent still wins the tie over -C's copy on the second"
3566        );
3567    }
3568
3569    #[test]
3570    fn blame_c_merge_boundary_copy_from_second_parent() {
3571        // -C merge residual #2, closed. Real git recipe (git 2.50.1): the
3572        // blamed file (`b.txt`) is ADDED by the merge — no parent contains
3573        // it at all — and its sole copy source lives on the SECOND parent:
3574        //   base: base.txt="base"
3575        //   p1  = base + m.txt("main only")            (no candidate)
3576        //   c2  = base + src.txt(BLOCK_A,BLOCK_B,"zzz") (candidate)
3577        //   merge(p1,c2): adds b.txt=BLOCK_A+BLOCK_B (new path, no parent has it)
3578        //   $ git blame -C -C -l b.txt -> credited to c2/src.txt.
3579        let (_d, store) = fresh_store();
3580        let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3581        let p1 = put_multi_file_commit(
3582            &store,
3583            &[("base.txt", b"base\n"), ("m.txt", b"main only\n")],
3584            vec![base],
3585            2,
3586            200,
3587        );
3588        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3589        let c2 = put_multi_file_commit(
3590            &store,
3591            &[("base.txt", b"base\n"), ("src.txt", &src)],
3592            vec![base],
3593            3,
3594            300,
3595        );
3596        let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3597        let merge = put_multi_file_commit(
3598            &store,
3599            &[
3600                ("base.txt", b"base\n"),
3601                ("m.txt", b"main only\n"),
3602                ("src.txt", &src),
3603                ("b.txt", &bnew),
3604            ],
3605            vec![p1, c2],
3606            4,
3607            400,
3608        );
3609
3610        let opts = BlameOptions {
3611            copies: CopyDetection::On {
3612                level: 2,
3613                threshold: 40,
3614            },
3615            ..Default::default()
3616        };
3617        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3618        assert_eq!(r.lines[0].text, BLOCK_A);
3619        assert_eq!(
3620            r.lines[0].commit_hash, c2,
3621            "a boundary -C source on a non-first parent is traced (git parity)"
3622        );
3623        assert_eq!(r.lines[1].commit_hash, c2);
3624    }
3625
3626    #[test]
3627    fn blame_c_merge_boundary_copy_octopus_third_parent() {
3628        // Real git recipe (git 2.50.1): boundary case (file wholly new),
3629        // 3-way octopus merge, source only on the THIRD parent:
3630        //   base: base.txt="base"
3631        //   p1 = base + pm.txt("p1 only")   (no candidate)
3632        //   c2 = base + cm.txt("c2 only")   (no candidate)
3633        //   c3 = base + s3.txt(BLOCK_A,BLOCK_B,"zzz")  (candidate)
3634        //   merge(p1,c2,c3): adds b.txt=BLOCK_A+BLOCK_B (new path)
3635        //   $ git blame -C -C -l b.txt -> credited to c3. Guards the
3636        //   boundary search against stopping after the first two parents.
3637        let (_d, store) = fresh_store();
3638        let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3639        let p1 = put_multi_file_commit(
3640            &store,
3641            &[("base.txt", b"base\n"), ("pm.txt", b"p1 only\n")],
3642            vec![base],
3643            2,
3644            200,
3645        );
3646        let c2 = put_multi_file_commit(
3647            &store,
3648            &[("base.txt", b"base\n"), ("cm.txt", b"c2 only\n")],
3649            vec![base],
3650            3,
3651            300,
3652        );
3653        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3654        let c3 = put_multi_file_commit(
3655            &store,
3656            &[("base.txt", b"base\n"), ("s3.txt", &src)],
3657            vec![base],
3658            4,
3659            400,
3660        );
3661        let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3662        let merge = put_multi_file_commit(
3663            &store,
3664            &[
3665                ("base.txt", b"base\n"),
3666                ("pm.txt", b"p1 only\n"),
3667                ("cm.txt", b"c2 only\n"),
3668                ("s3.txt", &src),
3669                ("b.txt", &bnew),
3670            ],
3671            vec![p1, c2, c3],
3672            5,
3673            500,
3674        );
3675
3676        let opts = BlameOptions {
3677            copies: CopyDetection::On {
3678                level: 2,
3679                threshold: 40,
3680            },
3681            ..Default::default()
3682        };
3683        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3684        assert_eq!(r.lines[0].text, BLOCK_A);
3685        assert_eq!(
3686            r.lines[0].commit_hash, c3,
3687            "the boundary search walks every real parent, not just the first two"
3688        );
3689        assert_eq!(r.lines[1].commit_hash, c3);
3690    }
3691
3692    #[test]
3693    fn blame_c_merge_boundary_copy_tie_prefers_first_parent() {
3694        // The boundary case's tie-break is the OPPOSITE of the interior
3695        // case's: real git recipe (git 2.50.1), both parents have the SAME
3696        // candidate for a wholly-new file:
3697        //   base: base.txt="base"
3698        //   p1 = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")
3699        //   c2 = base + s2.txt(BLOCK_A,BLOCK_B,"zzz")  (same block)
3700        //   merge(p1,c2): adds b.txt=BLOCK_A+BLOCK_B (new path)
3701        //   $ git blame -C -C -l b.txt -> credited to p1 (the FIRST parent),
3702        //   unlike the interior tie (which excludes the first parent
3703        //   entirely). The boundary search includes every real parent,
3704        //   first-found-wins in natural order, so the first parent CAN win.
3705        let (_d, store) = fresh_store();
3706        let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3707        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3708        let p1 = put_multi_file_commit(
3709            &store,
3710            &[("base.txt", b"base\n"), ("s1.txt", &src)],
3711            vec![base],
3712            2,
3713            200,
3714        );
3715        let c2 = put_multi_file_commit(
3716            &store,
3717            &[("base.txt", b"base\n"), ("s2.txt", &src)],
3718            vec![base],
3719            3,
3720            300,
3721        );
3722        let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3723        let merge = put_multi_file_commit(
3724            &store,
3725            &[
3726                ("base.txt", b"base\n"),
3727                ("s1.txt", &src),
3728                ("s2.txt", &src),
3729                ("b.txt", &bnew),
3730            ],
3731            vec![p1, c2],
3732            4,
3733            400,
3734        );
3735
3736        let opts = BlameOptions {
3737            copies: CopyDetection::On {
3738                level: 2,
3739                threshold: 40,
3740            },
3741            ..Default::default()
3742        };
3743        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3744        assert_eq!(r.lines[0].text, BLOCK_A);
3745        assert_eq!(
3746            r.lines[0].commit_hash, p1,
3747            "the boundary copy tie prefers the first parent (git parity) — unlike the interior tie"
3748        );
3749    }
3750
3751    #[test]
3752    fn match_lines_rejects_oversize_inputs() {
3753        // G13 regression: the LCS DP table allocation is O(m*n). For
3754        // attacker-controlled blobs with millions of lines this means
3755        // gigabytes of heap. Cap both dimensions with BLAME_MAX_LINES
3756        // and return a FileTooLarge error rather than over-allocating.
3757        let n = super::BLAME_MAX_LINES + 1;
3758        let opts = BlameOptions::default();
3759        let old: Vec<Vec<u8>> = vec![b"x".to_vec(); n];
3760        let new: Vec<Vec<u8>> = vec![b"y".to_vec(); 1];
3761        let err = super::match_lines_with_options(&old, &new, &opts).unwrap_err();
3762        assert!(
3763            matches!(err, BlameError::FileTooLarge { lines } if lines == n),
3764            "got {err:?}"
3765        );
3766
3767        let old2: Vec<Vec<u8>> = vec![b"a".to_vec(); 1];
3768        let new2: Vec<Vec<u8>> = vec![b"b".to_vec(); n];
3769        let err2 = super::match_lines_with_options(&old2, &new2, &opts).unwrap_err();
3770        assert!(
3771            matches!(err2, BlameError::FileTooLarge { lines } if lines == n),
3772            "got {err2:?}"
3773        );
3774    }
3775}