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).
810fn check_line_count(lines: usize) -> BlameOutcome<()> {
811    if lines > BLAME_MAX_LINES {
812        return Err(BlameError::FileTooLarge { lines });
813    }
814    Ok(())
815}
816
817/// LCS line matching. For each line in `new_lines`, returns the index
818/// in `old_lines` it corresponds to, or `None` for inserted/changed.
819///
820/// NOTE: This function allocates an O(m*n) DP table with no size guard,
821/// so it is kept private; all callers go through the size-checked
822/// [`match_lines_with_options`] entry point.
823#[must_use]
824fn match_lines<T: AsRef<[u8]>>(old_lines: &[T], new_lines: &[T]) -> Vec<Option<usize>> {
825    let m = old_lines.len();
826    let n = new_lines.len();
827    // dp is (m+1) x (n+1).
828    let mut dp = vec![vec![0u32; n + 1]; m + 1];
829    for i in 1..=m {
830        for j in 1..=n {
831            if old_lines[i - 1].as_ref() == new_lines[j - 1].as_ref() {
832                dp[i][j] = dp[i - 1][j - 1] + 1;
833            } else {
834                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
835            }
836        }
837    }
838    let mut mapping: Vec<Option<usize>> = vec![None; n];
839    let mut i = m;
840    let mut j = n;
841    // Reconstruct via the dp relations rather than a greedy diagonal-first
842    // rule. When `new[j-1]` isn't required for an optimal LCS
843    // (`dp[i][j] == dp[i][j-1]`), leave it unmatched so that an *earlier*
844    // equal line takes the match instead; likewise drop an unneeded
845    // `old[i-1]`. Only when neither can be dropped is it a true diagonal
846    // match. This keeps duplicate lines — and, under `-w`, lines that are
847    // only whitespace-equal — position-stable, so a unchanged line keeps
848    // its original commit while a genuinely new duplicate is attributed to
849    // the newer one (matching git).
850    while i > 0 && j > 0 {
851        if dp[i][j] == dp[i][j - 1] {
852            j -= 1;
853        } else if dp[i][j] == dp[i - 1][j] {
854            i -= 1;
855        } else {
856            mapping[j - 1] = Some(i - 1);
857            i -= 1;
858            j -= 1;
859        }
860    }
861    mapping
862}
863
864/// Pinned text formatting for goldens. Format:
865///
866/// ```text
867/// <short_hash>\t<line_num>\t<text>\n
868/// ```
869///
870/// where `<short_hash>` is the 12-character lowercase-hex prefix.
871#[must_use]
872pub fn format_blame_text(result: &BlameResult) -> String {
873    let mut out = String::new();
874    for line in &result.lines {
875        let hex = hash::to_hex(&line.commit_hash);
876        let short = &hex[..12];
877        let _ = write!(out, "{}\t{}\t", short, line.line_num);
878        out.push_str(&String::from_utf8_lossy(&line.text));
879        out.push('\n');
880    }
881    out
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887    use crate::object::{Commit, Identity, Tree, TreeEntry};
888    use crate::serialize;
889    use tempfile::TempDir;
890
891    fn fresh_store() -> (TempDir, ObjectStore) {
892        let dir = TempDir::new().unwrap();
893        let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
894        (dir, store)
895    }
896
897    fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
898        let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
899            data: data.to_vec(),
900        }))
901        .unwrap();
902        store.write(&bytes).unwrap()
903    }
904
905    fn put_single_file_tree(store: &ObjectStore, name: &str, blob: Hash) -> Hash {
906        let tree = Object::Tree(Tree {
907            entries: vec![TreeEntry {
908                name: name.as_bytes().to_vec(),
909                mode: EntryMode::Blob,
910                object_hash: blob,
911            }],
912        });
913        store.write(&serialize::serialize(&tree).unwrap()).unwrap()
914    }
915
916    fn put_file_commit(
917        store: &ObjectStore,
918        filename: &str,
919        content: &[u8],
920        parents: Vec<Hash>,
921        author_mid: u64,
922        ts: u64,
923    ) -> Hash {
924        let blob = put_blob(store, content);
925        let tree = put_single_file_tree(store, filename, blob);
926        let commit = Object::Commit(Commit::new_unannotated(
927            tree,
928            parents,
929            Identity::opaque(author_mid.to_le_bytes()),
930            [0u8; 32],
931            b"msg".to_vec(),
932            ts,
933            [0u8; 64],
934        ));
935        store
936            .write(&serialize::serialize(&commit).unwrap())
937            .unwrap()
938    }
939
940    /// Commit a set of `(filename, content)` files as one tree.
941    fn put_multi_file_commit(
942        store: &ObjectStore,
943        files: &[(&str, &[u8])],
944        parents: Vec<Hash>,
945        author_mid: u64,
946        ts: u64,
947    ) -> Hash {
948        let mut entries: Vec<TreeEntry> = files
949            .iter()
950            .map(|(name, content)| TreeEntry {
951                name: name.as_bytes().to_vec(),
952                mode: EntryMode::Blob,
953                object_hash: put_blob(store, content),
954            })
955            .collect();
956        // Tree entries are stored in name order; the store rejects any
957        // other ordering on read.
958        entries.sort_by(|a, b| a.name.cmp(&b.name));
959        let tree = store
960            .write(&serialize::serialize(&Object::Tree(Tree { entries })).unwrap())
961            .unwrap();
962        let commit = Object::Commit(Commit::new_unannotated(
963            tree,
964            parents,
965            Identity::opaque(author_mid.to_le_bytes()),
966            [0u8; 32],
967            b"msg".to_vec(),
968            ts,
969            [0u8; 64],
970        ));
971        store
972            .write(&serialize::serialize(&commit).unwrap())
973            .unwrap()
974    }
975
976    // A line with 22 alphanumeric characters — comfortably over git's
977    // default -M threshold of 20, so a move of it is detected.
978    const LONG_LINE: &[u8] = b"let quick_brown_fox_total = 1;";
979    // Two lines, 42 alphanumeric characters total — over git's default -C
980    // threshold of 40, so a copy of the block is detected.
981    const BLOCK_A: &[u8] = b"fn handler_alpha() { compute(); }";
982    const BLOCK_B: &[u8] = b"fn handler_bravo() { compute(); }";
983
984    /// SPEC-OBJECTS §7: "The concatenated length MUST equal `total_size`."
985    /// Blame loads file content through chunked-blob reassembly, which
986    /// must reject a manifest whose forged `total_size` disagrees with
987    /// its (valid) chunks.
988    #[test]
989    fn blame_rejects_chunked_total_size_mismatch() {
990        let (_d, store) = fresh_store();
991        let chunk = put_blob(&store, b"one line\n");
992        let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
993            total_size: 4096,
994            chunk_size: 0,
995            chunks: vec![chunk],
996        });
997        let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
998        let tree = put_single_file_tree(&store, "big.bin", cb_h);
999        let commit = Object::Commit(Commit::new_unannotated(
1000            tree,
1001            vec![],
1002            Identity::opaque(1u64.to_le_bytes()),
1003            [0u8; 32],
1004            b"msg".to_vec(),
1005            100,
1006            [0u8; 64],
1007        ));
1008        let head = store
1009            .write(&serialize::serialize(&commit).unwrap())
1010            .unwrap();
1011        let err = blame_file(&store, head, "big.bin").unwrap_err();
1012        assert!(
1013            matches!(
1014                err,
1015                BlameError::Object(crate::object::MkitError::ChunkedBlobSizeMismatch {
1016                    expected: 4096,
1017                    actual: 9,
1018                })
1019            ),
1020            "expected ChunkedBlobSizeMismatch, got {err:?}"
1021        );
1022    }
1023
1024    #[test]
1025    fn blame_m_attributes_within_file_move_to_origin() {
1026        // A long line (>= the 20-char -M threshold) is moved to the end of
1027        // the file. Without -M the matcher calls it new (credit c_b); with
1028        // -M it inherits its origin (c_a), matching `git blame -M`.
1029        let (_d, store) = fresh_store();
1030        let v1 = [LONG_LINE, b"B", b"C", b""].join(&b'\n'); // trailing newline
1031        let v2 = [b"B" as &[u8], b"C", LONG_LINE, b""].join(&b'\n');
1032        let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1033        let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1034
1035        let plain = blame_file(&store, c_b, "f.txt").unwrap();
1036        assert_eq!(plain.lines[2].text, LONG_LINE);
1037        assert_eq!(
1038            plain.lines[2].commit_hash, c_b,
1039            "default: moved line is new"
1040        );
1041
1042        let opts = BlameOptions {
1043            moves: MoveDetection::On { threshold: 20 },
1044            ..Default::default()
1045        };
1046        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1047        assert_eq!(m.lines[2].text, LONG_LINE);
1048        assert_eq!(
1049            m.lines[2].commit_hash, c_a,
1050            "-M attributes the moved line to its origin"
1051        );
1052        assert!(
1053            m.lines.iter().all(|l| l.commit_hash == c_a),
1054            "every line predates c_b under -M"
1055        );
1056    }
1057
1058    #[test]
1059    fn blame_m_threshold_boundary_is_inclusive() {
1060        // Boundary: git's `-M<n>` is a *lower bound* (n-or-more alnum
1061        // chars), so a block of exactly the threshold is detected and one
1062        // char short is not. `exact` has exactly 20 alnum chars, `short` 19.
1063        // They are kept non-adjacent (separated by anchors / a new line) so
1064        // each is an independent single-line block, not one merged block.
1065        // Verified against real `git blame -M`.
1066        let (_d, store) = fresh_store();
1067        let exact: &[u8] = b"abcdefghijklmnopqrst"; // 20 alnum
1068        let short: &[u8] = b"abcdefghijklmnopqrs"; // 19 alnum
1069        let v1 = [exact, b"MID", short, b"B", b"C", b""].join(&b'\n');
1070        let v2 = [b"B" as &[u8], b"C", exact, b"NEWX", short, b""].join(&b'\n');
1071        let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1072        let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1073
1074        let opts = BlameOptions {
1075            moves: MoveDetection::On { threshold: 20 },
1076            ..Default::default()
1077        };
1078        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1079        // Child order: B, C, exact, NEWX, short.
1080        assert_eq!(m.lines[2].text, exact);
1081        assert_eq!(
1082            m.lines[2].commit_hash, c_a,
1083            "exactly-threshold (20) move is detected (>= is inclusive)"
1084        );
1085        assert_eq!(m.lines[4].text, short);
1086        assert_eq!(
1087            m.lines[4].commit_hash, c_b,
1088            "one char short of the threshold stays on the editing commit"
1089        );
1090    }
1091
1092    #[test]
1093    fn blame_m_ignores_moves_below_threshold() {
1094        // A short moved line (1 alnum char) is below the threshold, so even
1095        // with -M it stays on the editing commit — matching git, which does
1096        // not associate sub-threshold moves.
1097        let (_d, store) = fresh_store();
1098        let c_a = put_file_commit(&store, "f.txt", b"a\nB\nC\n", vec![], 1, 100);
1099        let c_b = put_file_commit(&store, "f.txt", b"B\nC\na\n", vec![c_a], 2, 200);
1100        let opts = BlameOptions {
1101            moves: MoveDetection::On { threshold: 20 },
1102            ..Default::default()
1103        };
1104        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1105        assert_eq!(m.lines[2].text, b"a");
1106        assert_eq!(
1107            m.lines[2].commit_hash, c_b,
1108            "a sub-threshold move is not detected"
1109        );
1110    }
1111
1112    #[test]
1113    fn blame_m_detects_sub_block_move_adjacent_to_new_line() {
1114        // Review P1: a moved block sitting next to a genuinely-new line.
1115        // Parent: LONG1, LONG2, B, C. Child: B, C, NEW, LONG1, LONG2.
1116        // git -M credits LONG1/LONG2 to the parent and keeps NEW on the
1117        // child; whole-run matching would miss it (the run NEW+LONG1+LONG2
1118        // isn't contiguous in the parent). Verified against real git.
1119        let (_d, store) = fresh_store();
1120        let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
1121        let v2 = [b"B" as &[u8], b"C", b"NEWLINE", LONG_LINE, BLOCK_A, b""].join(&b'\n');
1122        let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1123        let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1124
1125        let opts = BlameOptions {
1126            moves: MoveDetection::On { threshold: 20 },
1127            ..Default::default()
1128        };
1129        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1130        // Child order: B, C, NEWLINE, LONG1, BLOCK_A.
1131        assert_eq!(m.lines[2].text, b"NEWLINE");
1132        assert_eq!(
1133            m.lines[2].commit_hash, c_b,
1134            "the genuinely-new line stays on c_b"
1135        );
1136        assert_eq!(m.lines[3].text, LONG_LINE);
1137        assert_eq!(
1138            m.lines[3].commit_hash, c_a,
1139            "the moved block reverts to c_a"
1140        );
1141        assert_eq!(
1142            m.lines[4].commit_hash, c_a,
1143            "…including the second moved line"
1144        );
1145    }
1146
1147    #[test]
1148    fn blame_w_c_detects_copy_with_whitespace_change() {
1149        // Review P1: a block copied into a new file *with a reindent*.
1150        // Under plain -C the changed whitespace hides the copy; under
1151        // -w -C it must still be credited to the origin commit. Verified
1152        // against real `git blame -w -C`.
1153        let (_d, store) = fresh_store();
1154        let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1155        let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
1156        // b.txt copies the block but reindents each line.
1157        let reindented = {
1158            let mut v = Vec::new();
1159            v.extend_from_slice(b"    ");
1160            v.extend_from_slice(BLOCK_A);
1161            v.push(b'\n');
1162            v.extend_from_slice(b"    ");
1163            v.extend_from_slice(BLOCK_B);
1164            v.push(b'\n');
1165            v
1166        };
1167        let c_b = put_multi_file_commit(
1168            &store,
1169            &[("a.txt", b"zzz\n"), ("b.txt", &reindented)],
1170            vec![c_a],
1171            2,
1172            200,
1173        );
1174
1175        // Plain -C: the reindent hides the copy; lines stay on c_b.
1176        let plain_c = BlameOptions {
1177            copies: CopyDetection::On {
1178                level: 1,
1179                threshold: 40,
1180            },
1181            ..Default::default()
1182        };
1183        let r = blame_file_with(&store, c_b, "b.txt", &plain_c).unwrap();
1184        assert!(
1185            r.lines.iter().all(|l| l.commit_hash == c_b),
1186            "without -w a reindented copy is not detected"
1187        );
1188
1189        // -w -C: normalized keys see through the reindent → credit c_a.
1190        let w_c = BlameOptions {
1191            ignore_whitespace: true,
1192            copies: CopyDetection::On {
1193                level: 1,
1194                threshold: 40,
1195            },
1196            ..Default::default()
1197        };
1198        let r = blame_file_with(&store, c_b, "b.txt", &w_c).unwrap();
1199        assert!(
1200            r.lines.iter().all(|l| l.commit_hash == c_a),
1201            "-w -C credits a reindented copy to its origin"
1202        );
1203    }
1204
1205    #[test]
1206    fn blame_c_attributes_copy_from_other_file_to_origin() {
1207        // c_a has a.txt = block + `zzz`; c_b removes the block from a.txt
1208        // and adds it to a brand-new b.txt (both files change in c_b).
1209        // Blaming b.txt with -C must credit the block to c_a, exercising the
1210        // boundary pass (b.txt has no parent version).
1211        let (_d, store) = fresh_store();
1212        let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1213        let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
1214        let bfile = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1215        let c_b = put_multi_file_commit(
1216            &store,
1217            &[("a.txt", b"zzz\n"), ("b.txt", &bfile)],
1218            vec![c_a],
1219            2,
1220            200,
1221        );
1222
1223        let plain = blame_file(&store, c_b, "b.txt").unwrap();
1224        assert_eq!(
1225            plain.lines[0].commit_hash, c_b,
1226            "default: copied block is new"
1227        );
1228
1229        let opts = BlameOptions {
1230            copies: CopyDetection::On {
1231                level: 1,
1232                threshold: 40,
1233            },
1234            ..Default::default()
1235        };
1236        let c = blame_file_with(&store, c_b, "b.txt", &opts).unwrap();
1237        assert_eq!(c.lines[0].text, BLOCK_A);
1238        assert_eq!(c.lines[1].text, BLOCK_B);
1239        assert!(
1240            c.lines.iter().all(|l| l.commit_hash == c_a),
1241            "-C credits the copied block to its origin commit"
1242        );
1243    }
1244
1245    #[test]
1246    fn blame_c_level1_skips_unchanged_files_until_level2() {
1247        // dst.txt copies a block verbatim from src.txt, which is NOT
1248        // modified in the copying commit. git `-C` (level 1) only searches
1249        // files changed in the commit, so it misses this; `-C -C` (level 2)
1250        // searches every parent file and finds it.
1251        let (_d, store) = fresh_store();
1252        let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1253        let c_a = put_multi_file_commit(&store, &[("src.txt", &block)], vec![], 1, 100);
1254        let c_b = put_multi_file_commit(
1255            &store,
1256            &[("src.txt", &block), ("dst.txt", &block)],
1257            vec![c_a],
1258            2,
1259            200,
1260        );
1261
1262        let l1 = BlameOptions {
1263            copies: CopyDetection::On {
1264                level: 1,
1265                threshold: 40,
1266            },
1267            ..Default::default()
1268        };
1269        let r1 = blame_file_with(&store, c_b, "dst.txt", &l1).unwrap();
1270        assert!(
1271            r1.lines.iter().all(|l| l.commit_hash == c_b),
1272            "-C level 1 ignores the unchanged source file"
1273        );
1274
1275        let l2 = BlameOptions {
1276            copies: CopyDetection::On {
1277                level: 2,
1278                threshold: 40,
1279            },
1280            ..Default::default()
1281        };
1282        let r2 = blame_file_with(&store, c_b, "dst.txt", &l2).unwrap();
1283        assert!(
1284            r2.lines.iter().all(|l| l.commit_hash == c_a),
1285            "-C -C searches every parent file and finds the source"
1286        );
1287    }
1288
1289    #[test]
1290    fn blame_w_c_credits_copy_through_prior_whitespace_edit() {
1291        // Review P1: the copy *source* must be blamed with the active `-w`,
1292        // so a copied block traces through a prior whitespace-only edit in
1293        // the source file. d1 = indented block; d2 dedents it (ws-only); d3
1294        // copies it to b.txt. `git blame -w -C` credits d1, not the d2
1295        // reformat. (With BlameOptions::default() for the source blame this
1296        // wrongly credited d2.) Verified against real git.
1297        let (_d, store) = fresh_store();
1298        let indented = {
1299            let mut v = Vec::new();
1300            for b in [BLOCK_A, BLOCK_B] {
1301                v.extend_from_slice(b"    ");
1302                v.extend_from_slice(b);
1303                v.push(b'\n');
1304            }
1305            v.extend_from_slice(b"zzz\n");
1306            v
1307        };
1308        let d1 = put_multi_file_commit(&store, &[("a.txt", &indented)], vec![], 1, 100);
1309        let dedented = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1310        let d2 = put_multi_file_commit(&store, &[("a.txt", &dedented)], vec![d1], 2, 200);
1311        let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1312        let d3 = put_multi_file_commit(
1313            &store,
1314            &[("a.txt", b"zzz\n"), ("b.txt", &block)],
1315            vec![d2],
1316            3,
1317            300,
1318        );
1319
1320        let opts = BlameOptions {
1321            ignore_whitespace: true,
1322            copies: CopyDetection::On {
1323                level: 1,
1324                threshold: 40,
1325            },
1326            ..Default::default()
1327        };
1328        let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
1329        assert!(
1330            r.lines.iter().all(|l| l.commit_hash == d1),
1331            "the source blame keeps -w → credits the original, not the reformat"
1332        );
1333    }
1334
1335    #[test]
1336    fn blame_c_credits_copy_through_prior_same_file_move() {
1337        // Review P1: the copy *source* must be blamed with the implied `-M`,
1338        // so a copied block traces through a prior same-file move in the
1339        // source. d1 = block then X,Y; d2 moves the block below X,Y; d3
1340        // copies it to b.txt. `git blame -C` credits d1, not the d2 move.
1341        // (With BlameOptions::default() this wrongly credited d2.) Verified
1342        // against real git.
1343        let (_d, store) = fresh_store();
1344        let v1 = [BLOCK_A, BLOCK_B, b"X", b"Y", b""].join(&b'\n');
1345        let d1 = put_multi_file_commit(&store, &[("a.txt", &v1)], vec![], 1, 100);
1346        let v2 = [b"X" as &[u8], b"Y", BLOCK_A, BLOCK_B, b""].join(&b'\n');
1347        let d2 = put_multi_file_commit(&store, &[("a.txt", &v2)], vec![d1], 2, 200);
1348        let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1349        let d3 = put_multi_file_commit(
1350            &store,
1351            &[("a.txt", b"X\nY\n"), ("b.txt", &block)],
1352            vec![d2],
1353            3,
1354            300,
1355        );
1356
1357        let opts = BlameOptions {
1358            copies: CopyDetection::On {
1359                level: 1,
1360                threshold: 40,
1361            },
1362            ..Default::default()
1363        };
1364        let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
1365        assert!(
1366            r.lines.iter().all(|l| l.commit_hash == d1),
1367            "the source blame keeps implied -M → credits the original, not the move"
1368        );
1369    }
1370
1371    #[test]
1372    fn blame_c_alone_implies_within_file_m() {
1373        // Review test gap: `-C` with `moves: Off` must still detect a
1374        // within-file move (git's `-C` implies `-M`), via effective_move()
1375        // returning GIT_DEFAULT. A long block moved within the *blamed*
1376        // file is credited to its origin with only copies set.
1377        let (_d, store) = fresh_store();
1378        let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
1379        let v2 = [b"B" as &[u8], b"C", LONG_LINE, BLOCK_A, b""].join(&b'\n');
1380        let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1381        let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1382
1383        let opts = BlameOptions {
1384            copies: CopyDetection::On {
1385                level: 1,
1386                threshold: 40,
1387            },
1388            ..Default::default() // moves: Off — the implication is under test
1389        };
1390        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1391        assert_eq!(m.lines[2].text, LONG_LINE);
1392        assert_eq!(
1393            m.lines[2].commit_hash, c_a,
1394            "-C implies -M, so the within-file move reverts to its origin"
1395        );
1396    }
1397
1398    #[test]
1399    fn blame_m_many_single_line_moves_no_stack_overflow() {
1400        // Review P1 (#2): N independent single-line moves used to recurse N
1401        // deep. With the work-stack it must just complete. Reverse 400 long,
1402        // distinct lines: each is its own move block, so all revert to c_a.
1403        let (_d, store) = fresh_store();
1404        let lines: Vec<String> = (0..400)
1405            .map(|i| format!("let unique_symbol_number_{i:05} = compute({i});"))
1406            .collect();
1407        let mut v1 = lines.join("\n");
1408        v1.push('\n');
1409        let mut rev: Vec<&str> = lines.iter().map(String::as_str).collect();
1410        rev.reverse();
1411        let mut v2 = rev.join("\n");
1412        v2.push('\n');
1413        let c_a = put_file_commit(&store, "f.txt", v1.as_bytes(), vec![], 1, 100);
1414        let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
1415
1416        let opts = BlameOptions {
1417            moves: MoveDetection::On { threshold: 20 },
1418            ..Default::default()
1419        };
1420        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1421        assert_eq!(m.lines.len(), 400);
1422        assert!(
1423            m.lines.iter().all(|l| l.commit_hash == c_a),
1424            "every reordered long line is a move → all revert to c_a"
1425        );
1426    }
1427
1428    #[test]
1429    fn blame_m_large_new_block_terminates() {
1430        // Review P1 (#1): a large genuinely-new block with no move/copy
1431        // match must not blow up the (previously cubic) search. 3000 new,
1432        // distinct lines that appear in no source: all stay on the editing
1433        // commit, and the call returns promptly via the source key-index.
1434        use std::fmt::Write as _;
1435        let (_d, store) = fresh_store();
1436        let c_a = put_file_commit(&store, "f.txt", b"seed\n", vec![], 1, 100);
1437        let mut v2 = String::from("seed\n");
1438        for i in 0..3000 {
1439            let _ = writeln!(v2, "brand_new_distinct_line_number_{i:06}");
1440        }
1441        let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
1442
1443        let opts = BlameOptions {
1444            moves: MoveDetection::On { threshold: 20 },
1445            ..Default::default()
1446        };
1447        let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1448        assert_eq!(m.lines.len(), 3001);
1449        assert_eq!(m.lines[0].commit_hash, c_a, "the seed line is unchanged");
1450        assert!(
1451            m.lines[1..].iter().all(|l| l.commit_hash == c_b),
1452            "the large new block stays on the editing commit"
1453        );
1454    }
1455
1456    #[test]
1457    fn blame_single_commit_attributes_all_lines_to_it() {
1458        let (_d, store) = fresh_store();
1459        let c = put_file_commit(&store, "f.txt", b"l1\nl2\nl3\n", vec![], 42, 1000);
1460        let r = blame_file(&store, c, "f.txt").unwrap();
1461        assert_eq!(r.lines.len(), 3);
1462        for (i, line) in r.lines.iter().enumerate() {
1463            assert_eq!(line.line_num, i + 1);
1464            assert_eq!(line.commit_hash, c);
1465            assert_eq!(line.timestamp, 1000);
1466            assert_eq!(line.author.kind, crate::object::IdentityKind::Opaque);
1467        }
1468        assert_eq!(r.lines[0].text, b"l1");
1469        assert_eq!(r.lines[1].text, b"l2");
1470        assert_eq!(r.lines[2].text, b"l3");
1471    }
1472
1473    #[test]
1474    fn strip_ws_removes_all_whitespace() {
1475        assert_eq!(strip_ws(b"  foo(a,  b)\t"), b"foo(a,b)".to_vec());
1476        assert_eq!(strip_ws(b"abc"), b"abc".to_vec());
1477        assert_eq!(strip_ws(b" \t "), b"".to_vec());
1478        // Vertical tab (\x0B) and form feed (\x0C) are whitespace to git's
1479        // xdiff `isspace`; strip both for parity.
1480        assert_eq!(strip_ws(b"a\x0Bb\x0Cc"), b"abc".to_vec());
1481    }
1482
1483    #[test]
1484    fn blame_w_ignores_whitespace_only_change() {
1485        let (_d, store) = fresh_store();
1486        let c_a = put_file_commit(&store, "f.txt", b"foo(a, b)\nkeep\n", vec![], 1, 100);
1487        let c_b = put_file_commit(&store, "f.txt", b"foo(a,b)\nkeep\n", vec![c_a], 2, 200);
1488
1489        // Default: the whitespace-only edit reattributes line 1 to c_b.
1490        let plain = blame_file(&store, c_b, "f.txt").unwrap();
1491        assert_eq!(plain.lines[0].commit_hash, c_b);
1492
1493        // -w: line 1 keeps c_a, but output still shows the current bytes.
1494        let opts = BlameOptions {
1495            ignore_whitespace: true,
1496            ..Default::default()
1497        };
1498        let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1499        assert_eq!(
1500            w.lines[0].commit_hash, c_a,
1501            "a whitespace-only change must not steal blame"
1502        );
1503        assert_eq!(
1504            w.lines[0].text, b"foo(a,b)",
1505            "output keeps the current bytes"
1506        );
1507        assert_eq!(w.lines[1].commit_hash, c_a);
1508    }
1509
1510    #[test]
1511    fn blame_w_still_attributes_real_content_change() {
1512        let (_d, store) = fresh_store();
1513        let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1514        let c_b = put_file_commit(&store, "f.txt", b"a\nB CHANGED\n", vec![c_a], 2, 200);
1515        let opts = BlameOptions {
1516            ignore_whitespace: true,
1517            ..Default::default()
1518        };
1519        let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1520        assert_eq!(w.lines[0].commit_hash, c_a);
1521        assert_eq!(
1522            w.lines[1].commit_hash, c_b,
1523            "a non-whitespace change is still attributed normally under -w"
1524        );
1525    }
1526
1527    #[test]
1528    fn blame_w_keeps_position_for_whitespace_equal_duplicate() {
1529        // Regression (PR #464 review P1): old `ab`, new `ab` + `a b`.
1530        // Stripping whitespace collapses both new lines to the key `ab`,
1531        // so a position-blind LCS would pair the *second* new line with
1532        // the old one and report line 1 as new — the reverse of git.
1533        // `git blame -w` keeps line 1 on the original commit and line 2 on
1534        // the new one; assert mkit matches.
1535        let (_d, store) = fresh_store();
1536        let c_a = put_file_commit(&store, "f.txt", b"ab\n", vec![], 1, 100);
1537        let c_b = put_file_commit(&store, "f.txt", b"ab\na b\n", vec![c_a], 2, 200);
1538        let opts = BlameOptions {
1539            ignore_whitespace: true,
1540            ..Default::default()
1541        };
1542        let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1543        assert_eq!(w.lines.len(), 2);
1544        assert_eq!(w.lines[0].commit_hash, c_a, "unchanged line 1 keeps c_a");
1545        assert_eq!(w.lines[1].commit_hash, c_b, "added line 2 is c_b");
1546        assert_eq!(w.lines[1].text, b"a b", "output keeps the current bytes");
1547    }
1548
1549    #[test]
1550    fn blame_w_blank_line_duplicate_is_position_stable() {
1551        // The same duplicate-key hazard with blank lines (review P1 calls
1552        // it out explicitly): inserting a second blank line must credit the
1553        // *added* blank to the new commit and leave the original blank on
1554        // its commit, matching `git blame -w` (verified against real git).
1555        let (_d, store) = fresh_store();
1556        let c_a = put_file_commit(&store, "f.txt", b"x\n\ny\n", vec![], 1, 100);
1557        let c_b = put_file_commit(&store, "f.txt", b"x\n\n\ny\n", vec![c_a], 2, 200);
1558        let opts = BlameOptions {
1559            ignore_whitespace: true,
1560            ..Default::default()
1561        };
1562        let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1563        assert_eq!(w.lines.len(), 4);
1564        assert_eq!(w.lines[0].commit_hash, c_a, "x");
1565        assert_eq!(w.lines[1].commit_hash, c_a, "original blank");
1566        assert_eq!(w.lines[2].commit_hash, c_b, "added blank is new");
1567        assert_eq!(w.lines[3].commit_hash, c_a, "y");
1568    }
1569
1570    #[test]
1571    fn blame_duplicate_line_addition_is_position_stable() {
1572        // Same stability property without `-w`: appending a genuine
1573        // duplicate of an existing line must attribute the *new* (second)
1574        // occurrence to the newer commit, not the first.
1575        let (_d, store) = fresh_store();
1576        let c_a = put_file_commit(&store, "f.txt", b"x\n", vec![], 1, 100);
1577        let c_b = put_file_commit(&store, "f.txt", b"x\nx\n", vec![c_a], 2, 200);
1578        let r = blame_file(&store, c_b, "f.txt").unwrap();
1579        assert_eq!(r.lines[0].commit_hash, c_a, "original line keeps c_a");
1580        assert_eq!(r.lines[1].commit_hash, c_b, "appended duplicate is c_b");
1581    }
1582
1583    #[test]
1584    fn blame_two_commits_with_modified_middle() {
1585        let (_d, store) = fresh_store();
1586        let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 42, 1000);
1587        let c_b = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![c_a], 42, 2000);
1588        let r = blame_file(&store, c_b, "f.txt").unwrap();
1589        assert_eq!(r.lines.len(), 3);
1590        assert_eq!(r.lines[0].commit_hash, c_a);
1591        assert_eq!(r.lines[1].commit_hash, c_b);
1592        assert_eq!(r.lines[2].commit_hash, c_a);
1593        assert_eq!(r.lines[1].text, b"MOD");
1594    }
1595
1596    #[test]
1597    fn blame_three_commits_progressive_changes() {
1598        let (_d, store) = fresh_store();
1599        let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1600        let c_b = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![c_a], 2, 200);
1601        let c_c = put_file_commit(&store, "f.txt", b"a\nX\nc\n", vec![c_b], 3, 300);
1602        let r = blame_file(&store, c_c, "f.txt").unwrap();
1603        assert_eq!(r.lines.len(), 3);
1604        assert_eq!(r.lines[0].commit_hash, c_a, "a from A");
1605        assert_eq!(r.lines[1].commit_hash, c_c, "X from C");
1606        assert_eq!(r.lines[2].commit_hash, c_b, "c from B");
1607    }
1608
1609    #[test]
1610    fn blame_tracks_additions() {
1611        let (_d, store) = fresh_store();
1612        let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1613        let c_b = put_file_commit(&store, "f.txt", b"a\nNEW\nb\n", vec![c_a], 1, 200);
1614        let r = blame_file(&store, c_b, "f.txt").unwrap();
1615        assert_eq!(r.lines.len(), 3);
1616        assert_eq!(r.lines[0].commit_hash, c_a);
1617        assert_eq!(r.lines[1].commit_hash, c_b);
1618        assert_eq!(r.lines[2].commit_hash, c_a);
1619    }
1620
1621    #[test]
1622    fn blame_tracks_deletions() {
1623        let (_d, store) = fresh_store();
1624        let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
1625        let c_b = put_file_commit(&store, "f.txt", b"a\nc\n", vec![c_a], 1, 200);
1626        let r = blame_file(&store, c_b, "f.txt").unwrap();
1627        assert_eq!(r.lines.len(), 2);
1628        assert_eq!(r.lines[0].commit_hash, c_a);
1629        assert_eq!(r.lines[1].commit_hash, c_a);
1630    }
1631
1632    #[test]
1633    fn blame_file_not_found_returns_error() {
1634        let (_d, store) = fresh_store();
1635        let c = put_file_commit(&store, "real.txt", b"x\n", vec![], 1, 100);
1636        let err = blame_file(&store, c, "missing.txt").unwrap_err();
1637        assert!(matches!(err, BlameError::FileNotFound(_)));
1638    }
1639
1640    #[test]
1641    fn lcs_identical_lines() {
1642        let lines: Vec<&[u8]> = vec![b"a", b"b", b"c"];
1643        let m = match_lines(&lines, &lines);
1644        assert_eq!(m, vec![Some(0), Some(1), Some(2)]);
1645    }
1646
1647    #[test]
1648    fn lcs_completely_different() {
1649        let old: Vec<&[u8]> = vec![b"a", b"b", b"c"];
1650        let new: Vec<&[u8]> = vec![b"x", b"y", b"z"];
1651        let m = match_lines(&old, &new);
1652        assert_eq!(m, vec![None, None, None]);
1653    }
1654
1655    #[test]
1656    fn lcs_duplicate_key_matches_earliest_new_line() {
1657        // One old line, two identical new lines: the matcher must pair the
1658        // *first* new line (the unchanged one) and leave the second as an
1659        // insertion, so blame keeps the original on line 1. A greedy
1660        // diagonal-first backtrack would instead pair the last occurrence.
1661        let old: Vec<&[u8]> = vec![b"ab"];
1662        let new: Vec<&[u8]> = vec![b"ab", b"ab"];
1663        assert_eq!(match_lines(&old, &new), vec![Some(0), None]);
1664    }
1665
1666    #[test]
1667    fn split_lines_handles_trailing_newline() {
1668        assert_eq!(
1669            split_lines(b"a\nb\nc\n"),
1670            vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
1671        );
1672    }
1673
1674    #[test]
1675    fn split_lines_handles_no_trailing_newline() {
1676        assert_eq!(
1677            split_lines(b"a\nb\nc"),
1678            vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
1679        );
1680    }
1681
1682    #[test]
1683    fn split_lines_empty() {
1684        assert!(split_lines(b"").is_empty());
1685    }
1686
1687    #[test]
1688    fn find_blob_in_nested_tree() {
1689        let (_d, store) = fresh_store();
1690        let blob = put_blob(&store, b"hello\n");
1691        let inner = Object::Tree(Tree {
1692            entries: vec![TreeEntry {
1693                name: b"main.txt".to_vec(),
1694                mode: EntryMode::Blob,
1695                object_hash: blob,
1696            }],
1697        });
1698        let inner_h = store.write(&serialize::serialize(&inner).unwrap()).unwrap();
1699        let outer = Object::Tree(Tree {
1700            entries: vec![TreeEntry {
1701                name: b"src".to_vec(),
1702                mode: EntryMode::Tree,
1703                object_hash: inner_h,
1704            }],
1705        });
1706        let outer_h = store.write(&serialize::serialize(&outer).unwrap()).unwrap();
1707        let found = find_blob_in_tree(&store, outer_h, "src/main.txt").unwrap();
1708        assert_eq!(found, Some(blob));
1709        let missing = find_blob_in_tree(&store, outer_h, "src/none.txt").unwrap();
1710        assert_eq!(missing, None);
1711    }
1712
1713    /// Convenience: a `BlameOptions` that ignores the given commits with
1714    /// `--ignore-rev-precise` on, `-w` (content matching needs `-w` to
1715    /// agree with the matcher the same way `-M`/`-C` do).
1716    fn ignoring_precise(revs: &[Hash]) -> BlameOptions {
1717        BlameOptions {
1718            ignore_revs: Arc::new(revs.iter().copied().collect()),
1719            ignore_rev_precise: true,
1720            ignore_whitespace: true,
1721            ..Default::default()
1722        }
1723    }
1724
1725    /// Convenience: a `BlameOptions` that ignores the given commits.
1726    fn ignoring(revs: &[Hash]) -> BlameOptions {
1727        BlameOptions {
1728            ignore_revs: Arc::new(revs.iter().copied().collect()),
1729            ..Default::default()
1730        }
1731    }
1732
1733    #[test]
1734    fn blame_ignore_rev_falls_through_reformat() {
1735        // A reformat commit (changes a line's bytes only) is ignored, so
1736        // its line falls through to the commit that owns the parent line —
1737        // but the output still shows the reformatted bytes. Mirrors
1738        // `git blame --ignore-rev <reformat>` (verified against real git).
1739        let (_d, store) = fresh_store();
1740        let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\ngamma\n", vec![], 1, 100);
1741        let c_b = put_file_commit(
1742            &store,
1743            "f.txt",
1744            b"alpha\n  beta  \ngamma\n",
1745            vec![c_a],
1746            2,
1747            200,
1748        );
1749
1750        // Without ignore: the reformat steals line 2.
1751        let plain = blame_file(&store, c_b, "f.txt").unwrap();
1752        assert_eq!(plain.lines[1].commit_hash, c_b);
1753
1754        let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
1755        assert_eq!(
1756            r.lines[1].commit_hash, c_a,
1757            "ignored reformat falls through to the original commit"
1758        );
1759        assert_eq!(r.lines[1].text, b"  beta  ", "output keeps current bytes");
1760        assert!(
1761            r.lines.iter().all(|l| l.commit_hash == c_a),
1762            "no line is credited to the ignored commit"
1763        );
1764    }
1765
1766    #[test]
1767    fn blame_ignore_rev_keeps_genuine_insertion() {
1768        // A line genuinely *added* by the ignored commit has no counterpart
1769        // in the parent, so git leaves it on the ignored commit (no
1770        // `blame.markUnblamableLines` marker by default). Verified against
1771        // real `git blame --ignore-rev`.
1772        let (_d, store) = fresh_store();
1773        let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\n", vec![], 1, 100);
1774        let c_b = put_file_commit(
1775            &store,
1776            "f.txt",
1777            b"alpha\nbeta\nBRANDNEW\ngamma\n",
1778            vec![c_a],
1779            2,
1780            200,
1781        );
1782
1783        let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
1784        assert_eq!(r.lines[0].commit_hash, c_a, "alpha");
1785        assert_eq!(r.lines[1].commit_hash, c_a, "beta");
1786        assert_eq!(
1787            r.lines[2].commit_hash, c_b,
1788            "a genuine insertion stays on the ignored commit"
1789        );
1790        assert_eq!(r.lines[3].commit_hash, c_b, "…and the second insertion");
1791    }
1792
1793    #[test]
1794    fn blame_ignore_rev_pairs_changed_lines_to_distinct_origins() {
1795        // Two adjacent changed lines in the ignored commit pair positionally
1796        // with their two parent lines, which have *different* origins: the
1797        // first parent line came from c2, the second from c1. git credits
1798        // each fallen-through line to its own parent's origin (verified).
1799        let (_d, store) = fresh_store();
1800        let c1 = put_file_commit(&store, "f.txt", b"L1\nL2\nL3\nL4\n", vec![], 1, 100);
1801        let c2 = put_file_commit(&store, "f.txt", b"L1\nL2x\nL3\nL4\n", vec![c1], 2, 200);
1802        let c3 = put_file_commit(&store, "f.txt", b"L1\nL2y\nL3y\nL4\n", vec![c2], 3, 300);
1803
1804        let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c3])).unwrap();
1805        assert_eq!(r.lines[1].commit_hash, c2, "L2y inherits L2x's origin (c2)");
1806        assert_eq!(r.lines[2].commit_hash, c1, "L3y inherits L3's origin (c1)");
1807        assert!(r.lines.iter().all(|l| l.commit_hash != c3));
1808    }
1809
1810    #[test]
1811    fn blame_ignore_rev_unequal_hunk_more_added() {
1812        // 1 line removed, 2 added in the ignored commit: the *first* added
1813        // line pairs with the removed line (falls through); the extra added
1814        // line stays on the ignored commit. Verified against real git.
1815        let (_d, store) = fresh_store();
1816        let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
1817        let c2 = put_file_commit(&store, "f.txt", b"L1\nMIDa\nMIDb\nL3\n", vec![c1], 2, 200);
1818
1819        let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1820        assert_eq!(r.lines[1].commit_hash, c1, "MIDa falls through to c1");
1821        assert_eq!(r.lines[2].commit_hash, c2, "MIDb has no pair → stays on c2");
1822    }
1823
1824    #[test]
1825    fn blame_ignore_rev_unequal_hunk_more_removed() {
1826        // 2 removed, 1 added: the single added line pairs with the *first*
1827        // removed line; the extra removed line simply vanishes. Verified.
1828        let (_d, store) = fresh_store();
1829        let c1 = put_file_commit(&store, "f.txt", b"L1\nM1\nM2\nL3\n", vec![], 1, 100);
1830        let c2 = put_file_commit(&store, "f.txt", b"L1\nMERGED\nL3\n", vec![c1], 2, 200);
1831
1832        let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1833        assert_eq!(r.lines[1].commit_hash, c1, "MERGED falls through to c1");
1834    }
1835
1836    #[test]
1837    fn blame_ignore_root_commit_keeps_its_lines() {
1838        // Ignoring the oldest commit in the walk: its lines have no parent
1839        // version of the file to fall through to, so they stay on it —
1840        // matching `git blame --ignore-rev <root>`.
1841        let (_d, store) = fresh_store();
1842        let root = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1843        let c2 = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![root], 2, 200);
1844
1845        let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[root])).unwrap();
1846        assert_eq!(r.lines[0].commit_hash, root, "a stays on the ignored root");
1847        assert_eq!(r.lines[1].commit_hash, root, "b stays on the ignored root");
1848        assert_eq!(r.lines[2].commit_hash, c2, "c is unaffected");
1849    }
1850
1851    #[test]
1852    fn blame_ignore_multiple_revs_chains_through() {
1853        // Two stacked reformats, both ignored: line falls through both to
1854        // the original author.
1855        let (_d, store) = fresh_store();
1856        let c1 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![], 1, 100);
1857        let c2 = put_file_commit(&store, "f.txt", b"keep\n x \n", vec![c1], 2, 200);
1858        let c3 = put_file_commit(&store, "f.txt", b"keep\n  x  \n", vec![c2], 3, 300);
1859
1860        let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c2, c3])).unwrap();
1861        assert_eq!(
1862            r.lines[1].commit_hash, c1,
1863            "line falls through both ignored reformats to c1"
1864        );
1865    }
1866
1867    #[test]
1868    fn blame_ignore_rev_fallthrough_not_overwritten_by_move_detection() {
1869        // Review: `--ignore-rev` + `-M`. An ignored commit replaces the last
1870        // line (`x`) in place with a duplicate of the first line (`dup`,
1871        // >= 20 alnum). The trailing hunk is 1-for-1, so ignore-rev
1872        // fallthrough pairs the new line with the *replaced* parent line
1873        // (`x`, origin c1). But `dup`'s key also appears at line 0 of the
1874        // parent, so the move detector *would* credit it to `dup`'s origin
1875        // (c0). Fallthrough must win: detection runs only on lines it did
1876        // not resolve.
1877        let (_d, store) = fresh_store();
1878        let dup: &[u8] = b"dupaaaaaaaaaaaaaaaaa"; // 20 alnum
1879        let mid: &[u8] = b"MIDLINE";
1880        let x_v0: &[u8] = b"exoldbbbbbbbbbbbbbbb";
1881        let x_v1: &[u8] = b"exnewbbbbbbbbbbbbbbb";
1882        let c0 = put_file_commit(
1883            &store,
1884            "f.txt",
1885            &[dup, mid, x_v0, b""].join(&b'\n'),
1886            vec![],
1887            1,
1888            100,
1889        );
1890        // c1 changes the last line (origin → c1); `dup`/`mid` keep origin c0.
1891        let c1 = put_file_commit(
1892            &store,
1893            "f.txt",
1894            &[dup, mid, x_v1, b""].join(&b'\n'),
1895            vec![c0],
1896            2,
1897            200,
1898        );
1899        // c2 (ignored) replaces the last line with a duplicate of `dup`.
1900        let c2 = put_file_commit(
1901            &store,
1902            "f.txt",
1903            &[dup, mid, dup, b""].join(&b'\n'),
1904            vec![c1],
1905            3,
1906            300,
1907        );
1908
1909        let opts = BlameOptions {
1910            moves: MoveDetection::On { threshold: 20 },
1911            ignore_revs: Arc::new([c2].into_iter().collect()),
1912            ..Default::default()
1913        };
1914        let r = blame_file_with(&store, c2, "f.txt", &opts).unwrap();
1915        assert_eq!(
1916            r.lines[2].commit_hash, c1,
1917            "fallthrough (replaced parent line → c1) wins; -M does not overwrite it to c0"
1918        );
1919        // Sanity: plain --ignore-rev (no -M) gives the same line-2 origin.
1920        let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1921        assert_eq!(
1922            plain.lines[2].commit_hash, c1,
1923            "-M did not change the result"
1924        );
1925    }
1926
1927    // --- `--ignore-rev-precise` (#496) ---------------------------------
1928
1929    #[test]
1930    fn blame_ignore_rev_precise_reattributes_moved_reindented_lines() {
1931        // A reformat commit reorders three distinct-origin lines and
1932        // reindents them. Under -w the LCS matcher recognizes ZZZ as
1933        // unchanged content wherever it landed and matches it directly
1934        // (so it needs no fall-through at all: LCS itself is already
1935        // content-aware for an exact, if relocated, match). That leaves
1936        // YYY and XXX with NO positional counterpart in their own
1937        // hunk — git's `--ignore-rev` treats them as genuine insertions
1938        // and credits them to the noise commit itself.
1939        // `--ignore-rev-precise` searches the parent's unmatched lines
1940        // across the WHOLE file (not just the enclosing hunk), so it finds
1941        // YYY's and XXX's true origins even though the positional pass
1942        // found no local candidate for either. Not pinned against git — no
1943        // git equivalent exists; documented mkit-only divergence (#496).
1944        let (_d, store) = fresh_store();
1945        let c0 = put_file_commit(&store, "f.txt", b"keep\ntail\n", vec![], 1, 100);
1946        let c1 = put_file_commit(&store, "f.txt", b"keep\nXXX\ntail\n", vec![c0], 2, 200);
1947        let c2 = put_file_commit(&store, "f.txt", b"keep\nXXX\nYYY\ntail\n", vec![c1], 3, 300);
1948        let c3 = put_file_commit(
1949            &store,
1950            "f.txt",
1951            b"keep\nXXX\nYYY\nZZZ\ntail\n",
1952            vec![c2],
1953            4,
1954            400,
1955        );
1956        let c4 = put_file_commit(
1957            &store,
1958            "f.txt",
1959            b"keep\n  ZZZ\n  YYY\n  XXX\ntail\n",
1960            vec![c3],
1961            5,
1962            500,
1963        );
1964
1965        // Positional (git-identical) fall-through: YYY and XXX have no
1966        // in-hunk counterpart (ZZZ already consumed the only anchor
1967        // available between `keep` and `tail`), so git's default leaves
1968        // them on the ignored commit.
1969        let positional_opts = BlameOptions {
1970            ignore_whitespace: true,
1971            ignore_revs: Arc::new([c4].into_iter().collect()),
1972            ..Default::default()
1973        };
1974        let positional = blame_file_with(&store, c4, "f.txt", &positional_opts).unwrap();
1975        assert_eq!(
1976            positional.lines[1].commit_hash, c3,
1977            "ZZZ is recognized unchanged by the LCS matcher itself (needs no fall-through)"
1978        );
1979        assert_eq!(
1980            positional.lines[2].commit_hash, c4,
1981            "positional: YYY has no in-hunk counterpart, stays on the ignored commit"
1982        );
1983        assert_eq!(
1984            positional.lines[3].commit_hash, c4,
1985            "positional: XXX has no in-hunk counterpart, stays on the ignored commit"
1986        );
1987
1988        // Precise: content matching searches the whole parent file (not
1989        // just YYY/XXX's own, counterpart-less hunk) and finds each true
1990        // origin.
1991        let precise = blame_file_with(&store, c4, "f.txt", &ignoring_precise(&[c4])).unwrap();
1992        assert_eq!(
1993            precise.lines[1].commit_hash, c3,
1994            "ZZZ is unaffected by precise mode (already resolved by plain LCS)"
1995        );
1996        assert_eq!(
1997            precise.lines[2].commit_hash, c2,
1998            "precise: YYY correctly attributed to its true origin"
1999        );
2000        assert_eq!(
2001            precise.lines[3].commit_hash, c1,
2002            "precise: XXX correctly attributed to its true origin"
2003        );
2004        assert_eq!(precise.lines[0].commit_hash, c0, "keep is unaffected");
2005        assert_eq!(precise.lines[4].commit_hash, c0, "tail is unaffected");
2006    }
2007
2008    #[test]
2009    fn blame_ignore_rev_precise_unequal_hunk_surplus_stays_put() {
2010        // The ignored commit splits one line into three fabricated lines
2011        // with no content match anywhere in the parent. Both modes pair the
2012        // first split line positionally (the only candidate) and leave the
2013        // two surplus lines on the ignored commit — `--ignore-rev-precise`
2014        // must not invent a match where none exists.
2015        let (_d, store) = fresh_store();
2016        let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
2017        let c2 = put_file_commit(
2018            &store,
2019            "f.txt",
2020            b"L1\nMIDaaa\nMIDbbb\nMIDccc\nL3\n",
2021            vec![c1],
2022            2,
2023            200,
2024        );
2025
2026        let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2027        let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
2028        for r in [&plain, &precise] {
2029            assert_eq!(r.lines[1].commit_hash, c1, "MIDaaa falls through to c1");
2030            assert_eq!(
2031                r.lines[2].commit_hash, c2,
2032                "MIDbbb has no pair -> stays on c2"
2033            );
2034            assert_eq!(
2035                r.lines[3].commit_hash, c2,
2036                "MIDccc has no pair -> stays on c2"
2037            );
2038        }
2039    }
2040
2041    #[test]
2042    fn blame_ignore_rev_precise_no_match_falls_back_to_positional() {
2043        // A single fabricated line replacing a single parent line, with no
2044        // other candidate anywhere in the file: precise mode has nothing to
2045        // find, so it falls back to exactly the positional result.
2046        let (_d, store) = fresh_store();
2047        let c1 = put_file_commit(&store, "f.txt", b"L1\nA\nL3\n", vec![], 1, 100);
2048        let c2 = put_file_commit(&store, "f.txt", b"L1\nFABRICATED\nL3\n", vec![c1], 2, 200);
2049
2050        let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2051        let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
2052        assert_eq!(plain.lines[1].commit_hash, c1);
2053        assert_eq!(
2054            precise.lines[1].commit_hash, plain.lines[1].commit_hash,
2055            "no content candidate exists anywhere in the parent: precise matches positional"
2056        );
2057    }
2058
2059    #[test]
2060    fn precise_overrides_trivial_key_guard() {
2061        // Unit-test the helper directly (mirroring
2062        // `ignore_fallthrough_pairs_per_hunk`'s direct-call style), pinning
2063        // the trivial-key guard: a positional guess for a line whose key is
2064        // under 3 bytes is kept even when a perfect, unclaimed content match
2065        // sits elsewhere in the parent — while a longer key on the same call
2066        // is still correctly reattributed when it has no positional guess
2067        // (a `None` genuine-insertion slot the whole-file search can fill).
2068        //
2069        // new = ["ab" (trivial, 2 bytes), "REALZZZ" (7 bytes)]
2070        // old = ["REALZZZ", "ab", "FILLER"]           (all LCS-unmatched)
2071        // positional (`fall`): new0 -> old2 ("FILLER", arbitrary/wrong),
2072        //                       new1 -> None (no in-hunk counterpart).
2073        let mapping = vec![None, None];
2074        let fall = vec![Some(2), None];
2075        let new_lines = vec![b"ab".to_vec(), b"REALZZZ".to_vec()];
2076        let parent_lines = vec![b"REALZZZ".to_vec(), b"ab".to_vec(), b"FILLER".to_vec()];
2077        let matched = vec![false, false];
2078
2079        let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2080            mapping: &mapping,
2081            fall: &fall,
2082            new_lines: &new_lines,
2083            parent_lines: &parent_lines,
2084            matched: &matched,
2085            ignore_whitespace: false,
2086        });
2087        assert_eq!(
2088            out[0],
2089            Some(2),
2090            "trivial key 'ab' keeps its positional guess even though a perfect \
2091             unclaimed match ('ab' at old index 1) exists"
2092        );
2093        assert_eq!(
2094            out[1],
2095            Some(0),
2096            "non-trivial key 'REALZZZ' fills its None positional slot from its \
2097             true content match (old index 0)"
2098        );
2099    }
2100
2101    #[test]
2102    fn precise_overrides_never_teleports_edited_line_worse_than_positional() {
2103        // Regression for the #523 review counterexample proving the earlier
2104        // "never worse than positional" claim FALSE. The fix makes it true
2105        // by construction: a slot whose positional guess is already a real
2106        // parent line (`Some(j)`) is only re-pointed by a *genuine moved
2107        // block* (a run of >= 2 file-adjacent lines), never by an isolated
2108        // single-line key coincidence.
2109        //
2110        // parent = [A, foo, B1, B2, bar, C]  (old idx 0..=5)
2111        // new    = [A, bar, B1, B2, C]        (new idx 0..=4)
2112        // The ignored commit edits foo->bar (old idx 1) and deletes an
2113        // unrelated `bar` authored by commit X (old idx 4). LCS anchors
2114        // A/B1/B2/C, so mapping[bar]=None and the positional fall pairs the
2115        // edited `bar` with old idx 1 (`foo`) — the line's TRUE positional
2116        // predecessor. The only unmatched old `bar` is at old idx 4.
2117        //
2118        // Old behavior: content override sends new1 to old idx 4, blaming the
2119        // edited line on X — strictly worse than positional. New behavior:
2120        // that single-line coincidence cannot displace the filled positional
2121        // guess, so out[1] stays Some(1).
2122        let mapping = vec![Some(0), None, Some(2), Some(3), Some(5)];
2123        let fall = vec![None, Some(1), None, None, None];
2124        let new_lines = vec![
2125            b"A".to_vec(),
2126            b"bar".to_vec(),
2127            b"B1".to_vec(),
2128            b"B2".to_vec(),
2129            b"C".to_vec(),
2130        ];
2131        let parent_lines = vec![
2132            b"A".to_vec(),
2133            b"foo".to_vec(),
2134            b"B1".to_vec(),
2135            b"B2".to_vec(),
2136            b"bar".to_vec(),
2137            b"C".to_vec(),
2138        ];
2139        let matched = vec![false; new_lines.len()];
2140
2141        let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2142            mapping: &mapping,
2143            fall: &fall,
2144            new_lines: &new_lines,
2145            parent_lines: &parent_lines,
2146            matched: &matched,
2147            ignore_whitespace: false,
2148        });
2149        assert_eq!(
2150            out[1],
2151            Some(1),
2152            "edited `bar` keeps its positional predecessor (foo @ old idx 1)"
2153        );
2154        assert_ne!(
2155            out[1],
2156            Some(4),
2157            "must NOT teleport to the unrelated `bar` @ old idx 4 (commit X)"
2158        );
2159        assert_eq!(
2160            out,
2161            vec![None, Some(1), None, None, None],
2162            "no slot is attributed worse than the positional fall-through"
2163        );
2164    }
2165
2166    #[test]
2167    fn precise_overrides_reindented_brace_does_not_teleport_without_w() {
2168        // Regression for #523 finding #2: without `-w`, `line_key` keeps raw
2169        // bytes, so a reindented `"    }"` is a 5-byte key that clears a
2170        // naive `len < 3` guard and would teleport to any other same-indent
2171        // `"    }"` in the parent. The trivial-key guard now measures the
2172        // WHITESPACE-STRIPPED length regardless of `-w`, so `"    }"` -> `}`
2173        // (1 byte) is trivial and stays put.
2174        //
2175        // The brace is a genuine-insertion slot (fall = None — its positional
2176        // counterpart consumed by an anchor), which the never-worse rule
2177        // would otherwise let a single exact match fill; only the
2178        // stripped-length guard prevents the teleport here.
2179        let mapping = vec![None];
2180        let fall = vec![None];
2181        let new_lines = vec![b"    }".to_vec()];
2182        let parent_lines = vec![b"    }".to_vec()]; // unrelated same-indent brace
2183        let matched = vec![false];
2184
2185        let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2186            mapping: &mapping,
2187            fall: &fall,
2188            new_lines: &new_lines,
2189            parent_lines: &parent_lines,
2190            matched: &matched,
2191            ignore_whitespace: false,
2192        });
2193        assert_eq!(
2194            out[0], None,
2195            "an indented brace is trivial once whitespace-stripped; it must not \
2196             teleport to an unrelated brace even without -w"
2197        );
2198    }
2199
2200    #[test]
2201    fn ignore_fallthrough_pairs_per_hunk() {
2202        // Unit-test the pairing helper directly against the verified rules.
2203        // mapping: new→old, None = unmatched.
2204        // old=[0,1,2,3], new anchors at 0 and 3, two unmatched between →
2205        // pair new1↔old1, new2↔old2.
2206        let mapping = vec![Some(0), None, None, Some(3)];
2207        assert_eq!(
2208            super::ignore_fallthrough(&mapping, 4),
2209            vec![None, Some(1), Some(2), None]
2210        );
2211        // More added than removed: old hunk has one line, new hunk two →
2212        // first pairs, second unpaired.
2213        let mapping = vec![Some(0), None, None, Some(2)];
2214        assert_eq!(
2215            super::ignore_fallthrough(&mapping, 3),
2216            vec![None, Some(1), None, None]
2217        );
2218        // Trailing insertion with no removed lines → all unpaired.
2219        let mapping = vec![Some(0), Some(1), None, None];
2220        assert_eq!(
2221            super::ignore_fallthrough(&mapping, 2),
2222            vec![None, None, None, None]
2223        );
2224    }
2225
2226    #[test]
2227    fn reverse_attributes_each_line_to_last_commit_it_survived() {
2228        // Verified against `git blame --reverse c1..c4`: blames c1's lines
2229        // (keep, doomed, also); survivors go to the end, the removed line
2230        // freezes at the last commit it existed in.
2231        let (_d, store) = fresh_store();
2232        let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nalso\n", vec![], 1, 100);
2233        let c2 = put_file_commit(
2234            &store,
2235            "f.txt",
2236            b"keep\ndoomed\nalso\nextra\n",
2237            vec![c1],
2238            2,
2239            200,
2240        );
2241        let c3 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra\n", vec![c2], 3, 300);
2242        let c4 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra2\n", vec![c3], 4, 400);
2243
2244        let r = blame_file_reverse(&store, c1, c4, "f.txt", &BlameOptions::default()).unwrap();
2245        assert_eq!(r.lines.len(), 3, "blames the start (c1) version's 3 lines");
2246        assert_eq!(r.lines[0].text, b"keep");
2247        assert_eq!(r.lines[0].commit_hash, c4, "keep survives to the end");
2248        assert_eq!(r.lines[1].text, b"doomed");
2249        assert_eq!(r.lines[1].commit_hash, c2, "doomed last existed in c2");
2250        assert_eq!(r.lines[2].text, b"also");
2251        assert_eq!(r.lines[2].commit_hash, c4, "also survives to the end");
2252    }
2253
2254    #[test]
2255    fn reverse_line_removed_immediately_stays_on_start() {
2256        // A start line removed in the very first included commit never
2257        // survives a step, so it is attributed to `start` itself (git marks
2258        // it with `^`). Verified against real git.
2259        let (_d, store) = fresh_store();
2260        let c1 = put_file_commit(&store, "f.txt", b"keep\ngone\n", vec![], 1, 100);
2261        let c2 = put_file_commit(&store, "f.txt", b"keep\n", vec![c1], 2, 200);
2262        let c3 = put_file_commit(&store, "f.txt", b"keep\nnew\n", vec![c2], 3, 300);
2263
2264        let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2265        assert_eq!(r.lines[0].commit_hash, c3, "keep survives to the end");
2266        assert_eq!(
2267            r.lines[1].commit_hash, c1,
2268            "gone never survived a step → stays on start"
2269        );
2270        assert_eq!(r.lines[1].text, b"gone");
2271    }
2272
2273    #[test]
2274    fn reverse_modified_line_freezes_before_the_edit() {
2275        // A line modified every commit: the start version last exists in
2276        // start (it is changed in the next commit). Verified against git.
2277        let (_d, store) = fresh_store();
2278        let c1 = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![], 1, 100);
2279        let c2 = put_file_commit(&store, "f.txt", b"a\nMOD2\nc\n", vec![c1], 2, 200);
2280        let c3 = put_file_commit(&store, "f.txt", b"a\nMOD3\nc\n", vec![c2], 3, 300);
2281
2282        let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2283        assert_eq!(r.lines[0].commit_hash, c3, "a survives");
2284        assert_eq!(r.lines[1].commit_hash, c1, "MOD changed in c2 → last in c1");
2285        assert_eq!(r.lines[2].commit_hash, c3, "c survives");
2286    }
2287
2288    #[test]
2289    fn reverse_unchanged_commit_advances_attribution() {
2290        // A commit that does not touch the file still counts as a commit the
2291        // line existed in, so attribution advances through it.
2292        let (_d, store) = fresh_store();
2293        let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
2294        let c2 = put_file_commit(&store, "f.txt", b"a\n", vec![c1], 2, 200); // identical
2295        let c3 = put_file_commit(&store, "f.txt", b"b\n", vec![c2], 3, 300); // a removed
2296
2297        let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2298        assert_eq!(
2299            r.lines[0].commit_hash, c2,
2300            "a last existed in the unchanged c2, gone by c3"
2301        );
2302    }
2303
2304    #[test]
2305    fn reverse_open_end_walks_to_provided_end() {
2306        // Sanity that the range is honored: stopping the range at c2 freezes
2307        // every still-living line at c2.
2308        let (_d, store) = fresh_store();
2309        let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\n", vec![], 1, 100);
2310        let c2 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nx\n", vec![c1], 2, 200);
2311        let _c3 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![c2], 3, 300);
2312
2313        let r = blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap();
2314        assert!(
2315            r.lines.iter().all(|l| l.commit_hash == c2),
2316            "with the range ending at c2 both start lines last exist in c2"
2317        );
2318    }
2319
2320    #[test]
2321    fn reverse_traces_through_whitespace_edit_under_w() {
2322        // `-w`: a whitespace-only reformat should not count as the line
2323        // disappearing, so the line survives past the reformat.
2324        let (_d, store) = fresh_store();
2325        let c1 = put_file_commit(&store, "f.txt", b"foo(a, b)\n", vec![], 1, 100);
2326        let c2 = put_file_commit(&store, "f.txt", b"foo(a,b)\n", vec![c1], 2, 200); // ws-only
2327        let c3 = put_file_commit(&store, "f.txt", b"changed\n", vec![c2], 3, 300);
2328
2329        // Without -w the reformat in c2 "ends" the original line at c1.
2330        let plain = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2331        assert_eq!(
2332            plain.lines[0].commit_hash, c1,
2333            "ws edit ends the line at c1"
2334        );
2335
2336        let w = BlameOptions {
2337            ignore_whitespace: true,
2338            ..Default::default()
2339        };
2340        let rw = blame_file_reverse(&store, c1, c3, "f.txt", &w).unwrap();
2341        assert_eq!(
2342            rw.lines[0].commit_hash, c2,
2343            "-w traces the line through the reformat → last exists in c2"
2344        );
2345    }
2346
2347    #[test]
2348    fn reverse_start_not_ancestor_errors() {
2349        let (_d, store) = fresh_store();
2350        let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
2351        // A sibling commit not on c1's first-parent chain.
2352        let other = put_file_commit(&store, "f.txt", b"z\n", vec![], 9, 900);
2353        let err =
2354            blame_file_reverse(&store, other, c1, "f.txt", &BlameOptions::default()).unwrap_err();
2355        assert!(
2356            matches!(err, BlameError::ReverseRange { .. }),
2357            "got {err:?}"
2358        );
2359    }
2360
2361    #[test]
2362    fn reverse_missing_path_in_start_errors() {
2363        let (_d, store) = fresh_store();
2364        let c1 = put_file_commit(&store, "other.txt", b"x\n", vec![], 1, 100);
2365        let c2 = put_file_commit(&store, "f.txt", b"y\n", vec![c1], 2, 200);
2366        let err =
2367            blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap_err();
2368        assert!(matches!(err, BlameError::FileNotFound(_)), "got {err:?}");
2369    }
2370
2371    // ---- merge-aware walk (#458) -----------------------------------------
2372    // All scenarios verified against real `git blame` / `git blame
2373    // --first-parent` (2.50.1); mkit hashes differ, so assert by commit.
2374
2375    /// base → {main adds main-line, feature adds feature-line} → merge.
2376    /// Returns (base, main, feat, merge). P1 of the merge is `main`.
2377    fn diamond_distinct(store: &ObjectStore) -> (Hash, Hash, Hash, Hash) {
2378        let base = put_file_commit(store, "f.txt", b"base1\nbase2\n", vec![], 1, 100);
2379        let feat = put_file_commit(
2380            store,
2381            "f.txt",
2382            b"base1\nbase2\nfeature\n",
2383            vec![base],
2384            2,
2385            200,
2386        );
2387        let main = put_file_commit(store, "f.txt", b"main\nbase1\nbase2\n", vec![base], 3, 300);
2388        let merge = put_file_commit(
2389            store,
2390            "f.txt",
2391            b"main\nbase1\nbase2\nfeature\n",
2392            vec![main, feat],
2393            4,
2394            400,
2395        );
2396        (base, main, feat, merge)
2397    }
2398
2399    #[test]
2400    fn blame_merge_aware_credits_side_branch_lines() {
2401        // Default (merge-aware): the side-branch line is credited to the
2402        // commit that wrote it, not the merge.
2403        let (_d, store) = fresh_store();
2404        let (base, main, feat, merge) = diamond_distinct(&store);
2405        let r = blame_file(&store, merge, "f.txt").unwrap();
2406        assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
2407        assert_eq!(r.lines[1].commit_hash, base, "base1 → base");
2408        assert_eq!(r.lines[2].commit_hash, base, "base2 → base");
2409        assert_eq!(r.lines[3].commit_hash, feat, "feature → feature commit");
2410        assert!(
2411            r.lines.iter().all(|l| l.commit_hash != merge),
2412            "none → merge"
2413        );
2414    }
2415
2416    #[test]
2417    fn blame_first_parent_credits_merge_for_side_branch_line() {
2418        // `--first-parent`: the side branch is never followed, so the
2419        // feature line first appears (to that walk) at the merge.
2420        let (_d, store) = fresh_store();
2421        let (base, main, _feat, merge) = diamond_distinct(&store);
2422        let opts = BlameOptions {
2423            first_parent: true,
2424            ..Default::default()
2425        };
2426        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2427        assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
2428        assert_eq!(r.lines[1].commit_hash, base);
2429        assert_eq!(r.lines[3].commit_hash, merge, "feature line → merge");
2430    }
2431
2432    #[test]
2433    fn blame_merge_identical_line_goes_to_first_parent() {
2434        // Both branches add the SAME line; git credits the first parent.
2435        let (_d, store) = fresh_store();
2436        let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2437        let feat = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 2, 200);
2438        let main = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 3, 300);
2439        let merge = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![main, feat], 4, 400);
2440        let r = blame_file(&store, merge, "f.txt").unwrap();
2441        assert_eq!(r.lines[1].commit_hash, main, "shared line → first parent");
2442        assert!(r.lines.iter().all(|l| l.commit_hash != feat));
2443    }
2444
2445    #[test]
2446    fn blame_evil_merge_attributes_new_line_to_merge() {
2447        // The merge blob introduces a line present in neither parent: it is
2448        // introduced by the merge commit.
2449        let (_d, store) = fresh_store();
2450        let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2451        let feat = put_file_commit(&store, "f.txt", b"base\nfeat\n", vec![base], 2, 200);
2452        let main = put_file_commit(&store, "f.txt", b"main\nbase\n", vec![base], 3, 300);
2453        let merge = put_file_commit(
2454            &store,
2455            "f.txt",
2456            b"main\nbase\nfeat\nEVIL\n",
2457            vec![main, feat],
2458            4,
2459            400,
2460        );
2461        let r = blame_file(&store, merge, "f.txt").unwrap();
2462        assert_eq!(r.lines[0].commit_hash, main);
2463        assert_eq!(r.lines[1].commit_hash, base);
2464        assert_eq!(r.lines[2].commit_hash, feat);
2465        assert_eq!(r.lines[3].commit_hash, merge, "the evil line → merge");
2466    }
2467
2468    #[test]
2469    fn blame_octopus_merge_credits_each_branch() {
2470        // A 3-parent merge: each branch's line is credited to its commit.
2471        let (_d, store) = fresh_store();
2472        let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2473        let b1 = put_file_commit(&store, "f.txt", b"base\nb1\n", vec![base], 2, 200);
2474        let b2 = put_file_commit(&store, "f.txt", b"base\nb2\n", vec![base], 3, 300);
2475        let b3 = put_file_commit(&store, "f.txt", b"base\nb3\n", vec![base], 4, 400);
2476        let merge = put_file_commit(
2477            &store,
2478            "f.txt",
2479            b"base\nb1\nb2\nb3\n",
2480            vec![b1, b2, b3],
2481            5,
2482            500,
2483        );
2484        let r = blame_file(&store, merge, "f.txt").unwrap();
2485        assert_eq!(r.lines[0].commit_hash, base);
2486        assert_eq!(r.lines[1].commit_hash, b1);
2487        assert_eq!(r.lines[2].commit_hash, b2);
2488        assert_eq!(r.lines[3].commit_hash, b3);
2489    }
2490
2491    #[test]
2492    fn blame_m_merge_credits_move_from_second_parent() {
2493        // A long line L is written on the SECOND merge parent and the merge
2494        // moves it to the file's end. `git blame -M` credits the moved line
2495        // to the 2nd-parent commit that wrote it, NOT the merge — the detector
2496        // must run against the second parent, not the first only. (Pinned
2497        // against real `git blame -M`, git 2.50.1.)
2498        let (_d, store) = fresh_store();
2499        let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
2500        // First parent: an unrelated edit, no L.
2501        let p1 = put_file_commit(&store, "f.txt", b"X\nY\nZ\n", vec![base], 2, 200);
2502        // Second parent: writes L at the top.
2503        let v2 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
2504        let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2505        // Merge (p1 first, c2 second): L moved to the end.
2506        let vm = [b"X" as &[u8], b"Y", b"Z", LONG_LINE, b""].join(&b'\n');
2507        let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2508
2509        let opts = BlameOptions {
2510            moves: MoveDetection::On { threshold: 20 },
2511            ..Default::default()
2512        };
2513        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2514        // Child order: X, Y, Z, L.
2515        assert_eq!(r.lines[3].text, LONG_LINE);
2516        assert_eq!(
2517            r.lines[3].commit_hash, c2,
2518            "-M credits the move to the 2nd-parent origin, not the merge"
2519        );
2520        assert_eq!(r.lines[2].commit_hash, p1, "Z stays on the first parent");
2521    }
2522
2523    #[test]
2524    fn blame_m_merge_move_prefers_first_parent() {
2525        // Both parents independently wrote the SAME long line L at the top;
2526        // the merge moves L to the end so neither parent's matcher explains it
2527        // in place. `git blame -M` credits the FIRST parent (the merge-walk's
2528        // first-parent-wins tie-break also governs the move detector). Pinned
2529        // against real `git blame -M`.
2530        let (_d, store) = fresh_store();
2531        let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
2532        let v = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
2533        let p1 = put_file_commit(&store, "f.txt", &v, vec![base], 2, 200);
2534        let c2 = put_file_commit(&store, "f.txt", &v, vec![base], 3, 300);
2535        let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
2536        let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2537
2538        let opts = BlameOptions {
2539            moves: MoveDetection::On { threshold: 20 },
2540            ..Default::default()
2541        };
2542        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2543        // Child order: X, Y, L.
2544        assert_eq!(r.lines[2].text, LONG_LINE);
2545        assert_eq!(
2546            r.lines[2].commit_hash, p1,
2547            "-M move at a merge prefers the first parent on a tie"
2548        );
2549        assert!(
2550            r.lines.iter().all(|l| l.commit_hash != c2),
2551            "the second parent never wins the tie"
2552        );
2553    }
2554
2555    #[test]
2556    fn blame_ignore_rev_merge_falls_through_to_second_parent() {
2557        // An ignored merge resolves a modify/delete conflict by keeping a
2558        // NOISE version of the feature line. The first parent DELETED that
2559        // line (no positional counterpart in its conflicted hunk), so the
2560        // fall-through must cross to the SECOND parent that actually wrote the
2561        // content — `git blame --ignore-rev <merge>` credits the feature
2562        // commit, not the merge. (Pinned against real git 2.50.1.)
2563        let (_d, store) = fresh_store();
2564        let base = put_file_commit(&store, "f.txt", b"TOP\nMID\nBOT\n", vec![], 1, 100);
2565        // First parent: deletes MID.
2566        let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
2567        // Second parent: rewrites MID to real content.
2568        let v2 = [b"TOP" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"BOT", b""].join(&b'\n');
2569        let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2570        // Merge keeps a noise version of the feature line.
2571        let vm = [b"TOP" as &[u8], b"  REAL_CONTENT_OF_B_LINE  X", b"BOT", b""].join(&b'\n');
2572        let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2573
2574        let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
2575        assert_eq!(r.lines[1].text, b"  REAL_CONTENT_OF_B_LINE  X");
2576        assert_eq!(
2577            r.lines[1].commit_hash, c2,
2578            "ignored merge falls through across to the 2nd parent's origin"
2579        );
2580    }
2581
2582    #[test]
2583    fn blame_ignore_rev_merge_prefers_first_parent_counterpart() {
2584        // Both parents have a positional counterpart in the conflicted hunk;
2585        // the ignored merge's fall-through prefers the FIRST parent (git's
2586        // positional fall-through is first-parent-wins, the same tie-break the
2587        // merge walk uses elsewhere). Pinned against real git.
2588        let (_d, store) = fresh_store();
2589        let base = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
2590        let p1 = put_file_commit(
2591            &store,
2592            "f.txt",
2593            b"a\nMAIN_B_VERSION\nc\n",
2594            vec![base],
2595            2,
2596            200,
2597        );
2598        let v2 = [b"a" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"c", b""].join(&b'\n');
2599        let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2600        let vm = [b"a" as &[u8], b"  REAL_CONTENT_OF_B_LINE  X", b"c", b""].join(&b'\n');
2601        let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2602
2603        let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
2604        assert_eq!(
2605            r.lines[1].commit_hash, p1,
2606            "fall-through prefers the first parent on a positional tie"
2607        );
2608        assert!(
2609            r.lines.iter().all(|l| l.commit_hash != c2),
2610            "the second parent does not win when the first parent has a counterpart"
2611        );
2612    }
2613
2614    #[test]
2615    fn blame_ignore_rev_precise_merge_second_parent_composition() {
2616        // Composes `--ignore-rev-precise` with the per-parent merge walk
2617        // (`apply_ignore_fallthrough`'s loop over every relevant parent):
2618        // the FIRST parent lacks the swapped content entirely (no positional
2619        // counterpart at all, so it contributes nothing and the walk falls
2620        // through to the next parent — same shape as
2621        // `blame_ignore_rev_merge_falls_through_to_second_parent`), and the
2622        // SECOND parent has the same moved-and-reindented reformat as
2623        // `blame_ignore_rev_precise_reattributes_moved_reindented_lines`
2624        // (ZZZ is recognized unchanged by plain LCS; YYY and XXX have no
2625        // in-hunk counterpart and are genuine insertions under the
2626        // positional default). Precise mode's whole-file search must run
2627        // against the SECOND parent's file (the one that actually has the
2628        // content), not the first parent's, which never pairs anything at
2629        // all. Tokens are >= 3 bytes so the trivial-key guard doesn't apply.
2630        let (_d, store) = fresh_store();
2631        let base = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![], 1, 100);
2632        // First (ignored merge's first) parent: never had XXX/YYY/ZZZ at all.
2633        let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
2634        // Second parent: builds up XXX, YYY, ZZZ with distinct origins.
2635        let c_x = put_file_commit(&store, "f.txt", b"TOP\nXXX\nBOT\n", vec![base], 3, 300);
2636        let c_y = put_file_commit(&store, "f.txt", b"TOP\nXXX\nYYY\nBOT\n", vec![c_x], 4, 400);
2637        let c_z = put_file_commit(
2638            &store,
2639            "f.txt",
2640            b"TOP\nXXX\nYYY\nZZZ\nBOT\n",
2641            vec![c_y],
2642            5,
2643            500,
2644        );
2645        // Ignored merge: reorders + reindents XXX/YYY/ZZZ (same shape as the
2646        // non-merge reindent test).
2647        let merge = put_file_commit(
2648            &store,
2649            "f.txt",
2650            b"TOP\n  ZZZ\n  YYY\n  XXX\nBOT\n",
2651            vec![p1, c_z],
2652            6,
2653            600,
2654        );
2655
2656        let positional_opts = BlameOptions {
2657            ignore_whitespace: true,
2658            ignore_revs: Arc::new([merge].into_iter().collect()),
2659            ..Default::default()
2660        };
2661        let positional = blame_file_with(&store, merge, "f.txt", &positional_opts).unwrap();
2662        assert_eq!(
2663            positional.lines[1].commit_hash, c_z,
2664            "ZZZ is recognized unchanged by the LCS matcher itself, against the 2nd parent"
2665        );
2666        assert_eq!(
2667            positional.lines[2].commit_hash, merge,
2668            "positional: YYY has no in-hunk counterpart on either parent, stays on the merge"
2669        );
2670        assert_eq!(
2671            positional.lines[3].commit_hash, merge,
2672            "positional: XXX has no in-hunk counterpart on either parent, stays on the merge"
2673        );
2674
2675        let precise = blame_file_with(&store, merge, "f.txt", &ignoring_precise(&[merge])).unwrap();
2676        assert_eq!(
2677            precise.lines[1].commit_hash, c_z,
2678            "ZZZ is unaffected by precise mode (already resolved by plain LCS)"
2679        );
2680        assert_eq!(
2681            precise.lines[2].commit_hash, c_y,
2682            "precise: YYY correctly attributed to its true origin via the 2nd parent's whole file"
2683        );
2684        assert_eq!(
2685            precise.lines[3].commit_hash, c_x,
2686            "precise: XXX correctly attributed to its true origin via the 2nd parent's whole file"
2687        );
2688        assert!(
2689            positional.lines.iter().all(|l| l.commit_hash != p1)
2690                && precise.lines.iter().all(|l| l.commit_hash != p1),
2691            "the content-less first parent never wins"
2692        );
2693    }
2694
2695    #[test]
2696    fn blame_c_merge_credits_copy_from_second_parent() {
2697        // `-C` merge parity: the blamed file already EXISTS in the parents and
2698        // a merge appends a block that lives in `src.txt` on the SECOND parent.
2699        // `git blame -C -C <merge> -- b.txt` credits the SECOND parent (`c2`),
2700        // enumerating that parent's tree to find the copy source — it does NOT
2701        // credit the merge. Pinned against real git 2.50.1:
2702        //   base: b.txt="hello"; p1 adds m.txt; c2 adds src.txt with the block;
2703        //   merge(p1,c2) appends the block to b.txt
2704        //   => `git blame -C -C` credits c2/src.txt for the appended lines.
2705        let (_d, store) = fresh_store();
2706        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2707        let p1 = put_multi_file_commit(
2708            &store,
2709            &[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
2710            vec![base],
2711            2,
2712            200,
2713        );
2714        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2715        let c2 = put_multi_file_commit(
2716            &store,
2717            &[("b.txt", b"hello\n"), ("src.txt", &src)],
2718            vec![base],
2719            3,
2720            300,
2721        );
2722        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2723        let merge = put_multi_file_commit(
2724            &store,
2725            &[
2726                ("b.txt", &bmerge),
2727                ("m.txt", b"main only\n"),
2728                ("src.txt", &src),
2729            ],
2730            vec![p1, c2],
2731            4,
2732            400,
2733        );
2734
2735        let opts = BlameOptions {
2736            copies: CopyDetection::On {
2737                level: 2,
2738                threshold: 40,
2739            },
2740            ..Default::default()
2741        };
2742        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2743        // Child order: hello, BLOCK_A, BLOCK_B.
2744        assert_eq!(r.lines[1].text, BLOCK_A);
2745        assert_eq!(
2746            r.lines[1].commit_hash, c2,
2747            "a modified file's appended block is copied across to the second parent's tree (git credits c2)"
2748        );
2749        assert_eq!(
2750            r.lines[2].commit_hash, c2,
2751            "the whole copied block is credited to c2"
2752        );
2753        // The unchanged first line stays on its own origin, not the merge.
2754        assert_ne!(r.lines[1].commit_hash, merge);
2755    }
2756
2757    #[test]
2758    fn blame_c_merge_credits_copy_from_third_octopus_parent() {
2759        // `-C -C` searches EVERY relevant merge parent's tree, not just the
2760        // first two. Octopus merge(p1, p2, p3): the copy source lives only in
2761        // `src.txt` on the THIRD parent; real git credits p3 for the appended
2762        // block (pinned against git 2.50.1). Guards against a first-/second-
2763        // parent-only search.
2764        let (_d, store) = fresh_store();
2765        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2766        let p1 = put_multi_file_commit(
2767            &store,
2768            &[("b.txt", b"hello\n"), ("x.txt", b"x only\n")],
2769            vec![base],
2770            2,
2771            200,
2772        );
2773        let p2 = put_multi_file_commit(
2774            &store,
2775            &[("b.txt", b"hello\n"), ("y.txt", b"y only\n")],
2776            vec![base],
2777            3,
2778            300,
2779        );
2780        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2781        let p3 = put_multi_file_commit(
2782            &store,
2783            &[("b.txt", b"hello\n"), ("src.txt", &src)],
2784            vec![base],
2785            4,
2786            400,
2787        );
2788        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2789        let merge = put_multi_file_commit(
2790            &store,
2791            &[
2792                ("b.txt", &bmerge),
2793                ("x.txt", b"x only\n"),
2794                ("y.txt", b"y only\n"),
2795                ("src.txt", &src),
2796            ],
2797            vec![p1, p2, p3],
2798            5,
2799            500,
2800        );
2801
2802        let opts = BlameOptions {
2803            copies: CopyDetection::On {
2804                level: 2,
2805                threshold: 40,
2806            },
2807            ..Default::default()
2808        };
2809        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2810        assert_eq!(r.lines[1].text, BLOCK_A);
2811        assert_eq!(
2812            r.lines[1].commit_hash, p3,
2813            "the copied block is traced to the third octopus parent's tree"
2814        );
2815    }
2816
2817    #[test]
2818    fn blame_c_merge_source_only_in_merge_tree_credits_merge() {
2819        // Counterpart guard: when the copy source (`src.txt`) is introduced by
2820        // the MERGE itself and no parent's tree holds the block, real git
2821        // credits the merge — the cross-parent search must not manufacture a
2822        // parent credit. Pinned against git 2.50.1.
2823        let (_d, store) = fresh_store();
2824        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2825        let p1 = put_multi_file_commit(
2826            &store,
2827            &[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
2828            vec![base],
2829            2,
2830            200,
2831        );
2832        let c2 = put_multi_file_commit(
2833            &store,
2834            &[("b.txt", b"hello\n"), ("o.txt", b"other\n")],
2835            vec![base],
2836            3,
2837            300,
2838        );
2839        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2840        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2841        // src.txt exists only in the merge's own tree (no parent has it).
2842        let merge = put_multi_file_commit(
2843            &store,
2844            &[
2845                ("b.txt", &bmerge),
2846                ("m.txt", b"main only\n"),
2847                ("o.txt", b"other\n"),
2848                ("src.txt", &src),
2849            ],
2850            vec![p1, c2],
2851            4,
2852            400,
2853        );
2854
2855        let opts = BlameOptions {
2856            copies: CopyDetection::On {
2857                level: 2,
2858                threshold: 40,
2859            },
2860            ..Default::default()
2861        };
2862        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2863        assert_eq!(r.lines[1].text, BLOCK_A);
2864        assert_eq!(
2865            r.lines[1].commit_hash, merge,
2866            "no parent holds the source, so the appended block stays on the merge (git parity)"
2867        );
2868    }
2869
2870    #[test]
2871    fn blame_c_merge_copy_tie_prefers_deduped_second_parent() {
2872        // Real `git blame -C -C` recipe (git 2.50.1):
2873        //   base: b.txt="hello"
2874        //   p1  = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")
2875        //   c2  = base + s2.txt(BLOCK_A,BLOCK_B,"zzz")   (same block, 2nd parent)
2876        //   merge(p1,c2): b.txt="hello"+BLOCK_A+BLOCK_B  (both s1.txt,s2.txt kept)
2877        //   $ git blame -C -C -l b.txt
2878        //   -> lines 2-3 credited to c2/s2.txt, NOT p1 (confirmed independent of
2879        //      file name: "aaa.txt" on c2 still beats "zzz.txt" on p1).
2880        // Mechanism (see move_copy's module note): p1 keeps its porigin, so
2881        // its -C candidates are only files MODIFIED between p1 and the merge
2882        // — s1.txt is unchanged, hence invisible. c2's b.txt blob is
2883        // identical to p1's, so c2's porigin is deduped and c2 gets the
2884        // whole-tree search, which finds s2.txt.
2885        let (_d, store) = fresh_store();
2886        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2887        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2888        let p1 = put_multi_file_commit(
2889            &store,
2890            &[("b.txt", b"hello\n"), ("s1.txt", &src)],
2891            vec![base],
2892            2,
2893            200,
2894        );
2895        let c2 = put_multi_file_commit(
2896            &store,
2897            &[("b.txt", b"hello\n"), ("s2.txt", &src)],
2898            vec![base],
2899            3,
2900            300,
2901        );
2902        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2903        let merge = put_multi_file_commit(
2904            &store,
2905            &[("b.txt", &bmerge), ("s1.txt", &src), ("s2.txt", &src)],
2906            vec![p1, c2],
2907            4,
2908            400,
2909        );
2910
2911        let opts = BlameOptions {
2912            copies: CopyDetection::On {
2913                level: 2,
2914                threshold: 40,
2915            },
2916            ..Default::default()
2917        };
2918        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2919        assert_eq!(r.lines[1].text, BLOCK_A);
2920        assert_eq!(
2921            r.lines[1].commit_hash, c2,
2922            "a copy tie across parents resolves to the non-first parent (git parity)"
2923        );
2924        assert_eq!(r.lines[2].commit_hash, c2);
2925        assert!(
2926            r.lines.iter().all(|l| l.commit_hash != p1),
2927            "the first parent never wins an interior -C tie"
2928        );
2929    }
2930
2931    #[test]
2932    fn blame_c_merge_copy_tie_octopus_prefers_first_non_first_parent() {
2933        // Real git recipe (git 2.50.1) — a 3-way octopus tie where the FIRST
2934        // parent has NO candidate at all and parents 2 and 3 both do:
2935        //   base: b.txt="hello"
2936        //   p1  = base + pm.txt("p1 only")            (no candidate)
2937        //   c2  = base + s2.txt(BLOCK_A,BLOCK_B,"zzz") (candidate)
2938        //   c3  = base + s3.txt(BLOCK_A,BLOCK_B,"zzz") (same candidate)
2939        //   merge(p1,c2,c3): b.txt="hello"+BLOCK_A+BLOCK_B
2940        //   $ git blame -C -C -l b.txt  -> credited to c2 (the SECOND parent,
2941        //   i.e. the FIRST of the two tied non-first parents), NOT c3 (the
2942        //   literal last parent). This disproves plain "last-parent-wins".
2943        //   Mechanism: p1 keeps its porigin (modified-files channel only —
2944        //   pm.txt has no block); c2's and c3's identical b.txt blobs are
2945        //   deduped to porigin-less, so both get whole-tree searches in
2946        //   parent order and c2, searched first, claims the block.
2947        let (_d, store) = fresh_store();
2948        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2949        let p1 = put_multi_file_commit(
2950            &store,
2951            &[("b.txt", b"hello\n"), ("pm.txt", b"p1 only\n")],
2952            vec![base],
2953            2,
2954            200,
2955        );
2956        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2957        let c2 = put_multi_file_commit(
2958            &store,
2959            &[("b.txt", b"hello\n"), ("s2.txt", &src)],
2960            vec![base],
2961            3,
2962            300,
2963        );
2964        let c3 = put_multi_file_commit(
2965            &store,
2966            &[("b.txt", b"hello\n"), ("s3.txt", &src)],
2967            vec![base],
2968            4,
2969            400,
2970        );
2971        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2972        let merge = put_multi_file_commit(
2973            &store,
2974            &[("b.txt", &bmerge), ("s2.txt", &src), ("s3.txt", &src)],
2975            vec![p1, c2, c3],
2976            5,
2977            500,
2978        );
2979
2980        let opts = BlameOptions {
2981            copies: CopyDetection::On {
2982                level: 2,
2983                threshold: 40,
2984            },
2985            ..Default::default()
2986        };
2987        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2988        assert_eq!(r.lines[1].text, BLOCK_A);
2989        assert_eq!(
2990            r.lines[1].commit_hash, c2,
2991            "the first NON-first parent (in order) wins the octopus tie, not the literal last parent"
2992        );
2993        assert_eq!(r.lines[2].commit_hash, c2);
2994    }
2995
2996    #[test]
2997    fn blame_c_merge_copy_source_only_on_first_parent_stays_on_merge() {
2998        // Counterpart guard, no tie involved: the block's ONLY candidate
2999        // source lives on the FIRST parent (p1's s1.txt); the second parent
3000        // has an unrelated file and no candidate at all. Real git recipe
3001        // (git 2.50.1):
3002        //   base: b.txt="hello"
3003        //   p1  = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")
3004        //   c2  = base + m.txt("other unrelated content")
3005        //   merge(p1,c2): b.txt="hello"+BLOCK_A+BLOCK_B
3006        //   $ git blame -C -C -l b.txt -> credited to the MERGE commit
3007        //   itself, NOT p1, even though p1's candidate is uncontested.
3008        //   Mechanism: p1 keeps its porigin, so its -C candidates are only
3009        //   files MODIFIED between p1 and the merge — s1.txt is unchanged,
3010        //   hence invisible. c2 is deduped (same b.txt blob) and gets the
3011        //   whole-tree search, but c2's tree has no block. See
3012        //   blame_c_level1_merge_modified_source_credits_first_parent for
3013        //   the converse: a first-parent source that IS modified gets
3014        //   credited.
3015        let (_d, store) = fresh_store();
3016        let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
3017        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3018        let p1 = put_multi_file_commit(
3019            &store,
3020            &[("b.txt", b"hello\n"), ("s1.txt", &src)],
3021            vec![base],
3022            2,
3023            200,
3024        );
3025        let c2 = put_multi_file_commit(
3026            &store,
3027            &[
3028                ("b.txt", b"hello\n"),
3029                ("m.txt", b"other unrelated content\n"),
3030            ],
3031            vec![base],
3032            3,
3033            300,
3034        );
3035        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3036        let merge = put_multi_file_commit(
3037            &store,
3038            &[
3039                ("b.txt", &bmerge),
3040                ("s1.txt", &src),
3041                ("m.txt", b"other unrelated content\n"),
3042            ],
3043            vec![p1, c2],
3044            4,
3045            400,
3046        );
3047
3048        let opts = BlameOptions {
3049            copies: CopyDetection::On {
3050                level: 2,
3051                threshold: 40,
3052            },
3053            ..Default::default()
3054        };
3055        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3056        assert_eq!(r.lines[1].text, BLOCK_A);
3057        assert_eq!(
3058            r.lines[1].commit_hash, merge,
3059            "an uncontested -C source on the first parent is never traced (git parity)"
3060        );
3061        assert_eq!(r.lines[2].commit_hash, merge);
3062    }
3063
3064    #[test]
3065    fn blame_c_merge_unmodified_first_parent_source_with_fileless_second_stays_on_merge() {
3066        // Modify/delete merge where the SECOND parent deleted the blamed
3067        // file: the filtered DAG sees a single relevant parent, but the
3068        // commit is still a true two-parent merge and p1's unchanged
3069        // s1.txt must stay invisible (modified-files channel). Guards the
3070        // old bug where `is_merge` was keyed on the FILTERED parent list,
3071        // making this shape look linear and tracing the block to p1.
3072        // Real git recipe (git 2.50.1):
3073        //   base: b.txt="hello", sbase.txt
3074        //   p1  = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")   (keeps b.txt)
3075        //   p2  = base - b.txt                            (deleted)
3076        //   merge(p1,p2): b.txt="hello"+BLOCK_A+BLOCK_B; s1.txt unchanged
3077        //   $ git blame -C -C b.txt -> block stays on the MERGE.
3078        let (_d, store) = fresh_store();
3079        let base = put_multi_file_commit(
3080            &store,
3081            &[
3082                ("b.txt", b"hello\n"),
3083                ("sbase.txt", b"source header line\n"),
3084            ],
3085            vec![],
3086            1,
3087            100,
3088        );
3089        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3090        let p1 = put_multi_file_commit(
3091            &store,
3092            &[
3093                ("b.txt", b"hello\n"),
3094                ("sbase.txt", b"source header line\n"),
3095                ("s1.txt", &src),
3096            ],
3097            vec![base],
3098            2,
3099            200,
3100        );
3101        let p2 = put_multi_file_commit(
3102            &store,
3103            &[("sbase.txt", b"source header line\n")],
3104            vec![base],
3105            3,
3106            300,
3107        );
3108        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3109        let merge = put_multi_file_commit(
3110            &store,
3111            &[
3112                ("b.txt", &bmerge),
3113                ("sbase.txt", b"source header line\n"),
3114                ("s1.txt", &src),
3115            ],
3116            vec![p1, p2],
3117            4,
3118            400,
3119        );
3120
3121        let opts = BlameOptions {
3122            copies: CopyDetection::On {
3123                level: 2,
3124                threshold: 40,
3125            },
3126            ..Default::default()
3127        };
3128        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3129        assert_eq!(r.lines[1].text, BLOCK_A);
3130        assert_eq!(
3131            r.lines[1].commit_hash, merge,
3132            "a fileless second parent does not make the merge linear: p1's \
3133             unchanged source stays invisible and the block stays on the merge (git parity)"
3134        );
3135        assert_eq!(r.lines[2].commit_hash, merge);
3136    }
3137
3138    #[test]
3139    fn blame_c_merge_file_deleting_parent_supplies_copy_source() {
3140        // A parent that DELETED the blamed file is still `-C -C` searched —
3141        // porigin-less parents get the whole-tree channel. Guards the old
3142        // bug where detection iterated only the file-bearing (filtered)
3143        // parents and p2's tree was never offered.
3144        // Real git recipe (git 2.50.1):
3145        //   base: f.txt="hello", s.txt="source header line"
3146        //   p1  = base                                    (unchanged)
3147        //   p2  = base - f.txt; s.txt gains BLOCK_A+BLOCK_B
3148        //   merge(p1,p2): f.txt="hello"+BLOCK_A+BLOCK_B; s.txt = p2's
3149        //   $ git blame -C -C f.txt -> block credited to p2 (via s.txt).
3150        let (_d, store) = fresh_store();
3151        let base = put_multi_file_commit(
3152            &store,
3153            &[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
3154            vec![],
3155            1,
3156            100,
3157        );
3158        let p1 = put_multi_file_commit(
3159            &store,
3160            &[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
3161            vec![base],
3162            2,
3163            200,
3164        );
3165        let src = [
3166            b"source header line" as &[u8],
3167            BLOCK_A,
3168            BLOCK_B,
3169            b"zzz",
3170            b"",
3171        ]
3172        .join(&b'\n');
3173        let p2 = put_multi_file_commit(&store, &[("s.txt", &src)], vec![base], 3, 300);
3174        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3175        let merge = put_multi_file_commit(
3176            &store,
3177            &[("f.txt", &bmerge), ("s.txt", &src)],
3178            vec![p1, p2],
3179            4,
3180            400,
3181        );
3182
3183        let opts = BlameOptions {
3184            copies: CopyDetection::On {
3185                level: 2,
3186                threshold: 40,
3187            },
3188            ..Default::default()
3189        };
3190        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
3191        assert_eq!(r.lines[1].text, BLOCK_A);
3192        assert_eq!(
3193            r.lines[1].commit_hash, p2,
3194            "the parent that deleted the blamed file is whole-tree searched \
3195             and its source claims the block (git parity)"
3196        );
3197        assert_eq!(r.lines[2].commit_hash, p2);
3198    }
3199
3200    #[test]
3201    fn blame_c_merge_fileless_first_parent_unmodified_second_source_stays_on_merge() {
3202        // Octopus where the FIRST parent deleted the blamed file and the
3203        // second parent's tree holds the block in a source UNCHANGED at
3204        // the merge. p1 is porigin-less (whole tree — no block there); p2
3205        // is the first file-bearing parent so it KEEPS its porigin and only
3206        // its modified files are candidates — s2.txt is unchanged, hence
3207        // invisible; p3's b.txt blob dedups against p2's (whole tree — no
3208        // block). Guards the old bug where the filtered index made p2 look
3209        // like "the first parent" for the wrong reason (and, on the fixed
3210        // real-parent model, would have wrongly whole-tree-searched p2).
3211        // Real git recipe (git 2.50.1):
3212        //   base: b.txt="hello", s2.txt="source header line"
3213        //   p1  = base - b.txt
3214        //   p2  = base with s2.txt = BLOCK_A+BLOCK_B+... (gains the block)
3215        //   p3  = base + o.txt
3216        //   merge(p1,p2,p3): b.txt="hello"+BLOCK; s2.txt = p2's; o.txt kept
3217        //   $ git blame -C -C b.txt -> block stays on the MERGE.
3218        let (_d, store) = fresh_store();
3219        let base = put_multi_file_commit(
3220            &store,
3221            &[("b.txt", b"hello\n"), ("s2.txt", b"source header line\n")],
3222            vec![],
3223            1,
3224            100,
3225        );
3226        let p1 = put_multi_file_commit(
3227            &store,
3228            &[("s2.txt", b"source header line\n")],
3229            vec![base],
3230            2,
3231            200,
3232        );
3233        let src = [
3234            b"source header line" as &[u8],
3235            BLOCK_A,
3236            BLOCK_B,
3237            b"zzz",
3238            b"",
3239        ]
3240        .join(&b'\n');
3241        let p2 = put_multi_file_commit(
3242            &store,
3243            &[("b.txt", b"hello\n"), ("s2.txt", &src)],
3244            vec![base],
3245            3,
3246            300,
3247        );
3248        let p3 = put_multi_file_commit(
3249            &store,
3250            &[
3251                ("b.txt", b"hello\n"),
3252                ("s2.txt", b"source header line\n"),
3253                ("o.txt", b"other\n"),
3254            ],
3255            vec![base],
3256            4,
3257            400,
3258        );
3259        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3260        let merge = put_multi_file_commit(
3261            &store,
3262            &[("b.txt", &bmerge), ("s2.txt", &src), ("o.txt", b"other\n")],
3263            vec![p1, p2, p3],
3264            5,
3265            500,
3266        );
3267
3268        let opts = BlameOptions {
3269            copies: CopyDetection::On {
3270                level: 2,
3271                threshold: 40,
3272            },
3273            ..Default::default()
3274        };
3275        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3276        assert_eq!(r.lines[1].text, BLOCK_A);
3277        assert_eq!(
3278            r.lines[1].commit_hash, merge,
3279            "the first file-bearing parent keeps its porigin even when the real \
3280             first parent is fileless; its unchanged source stays invisible (git parity)"
3281        );
3282        assert_eq!(r.lines[2].commit_hash, merge);
3283    }
3284
3285    #[test]
3286    fn blame_c_level1_merge_modified_source_credits_first_parent() {
3287        // Plain `-C` (level 1) at a true merge: the source file IS modified
3288        // between the first parent and the merge (the block moved out of
3289        // s1.txt into b.txt), so it is a modified-files-channel candidate
3290        // and the FIRST parent gets the credit — there is no first-parent
3291        // carve-out in git, at any level. Guards the old bug where the
3292        // carve-out unconditionally zeroed the first parent's copy search.
3293        // Real git recipe (git 2.50.1), same result with -C and -C -C:
3294        //   base: b.txt="hello", s1.txt="source header line"
3295        //   p1  = base with s1.txt gaining BLOCK_A+BLOCK_B
3296        //   p2  = base + o.txt
3297        //   merge(p1,p2): b.txt="hello"+BLOCK; s1.txt back to base's (block
3298        //   moved out); o.txt kept
3299        //   $ git blame -C b.txt -> block credited to P1 (via s1.txt).
3300        let (_d, store) = fresh_store();
3301        let base = put_multi_file_commit(
3302            &store,
3303            &[("b.txt", b"hello\n"), ("s1.txt", b"source header line\n")],
3304            vec![],
3305            1,
3306            100,
3307        );
3308        let src = [
3309            b"source header line" as &[u8],
3310            BLOCK_A,
3311            BLOCK_B,
3312            b"zzz",
3313            b"",
3314        ]
3315        .join(&b'\n');
3316        let p1 = put_multi_file_commit(
3317            &store,
3318            &[("b.txt", b"hello\n"), ("s1.txt", &src)],
3319            vec![base],
3320            2,
3321            200,
3322        );
3323        let p2 = put_multi_file_commit(
3324            &store,
3325            &[
3326                ("b.txt", b"hello\n"),
3327                ("s1.txt", b"source header line\n"),
3328                ("o.txt", b"other\n"),
3329            ],
3330            vec![base],
3331            3,
3332            300,
3333        );
3334        let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3335        let merge = put_multi_file_commit(
3336            &store,
3337            &[
3338                ("b.txt", &bmerge),
3339                ("s1.txt", b"source header line\n"),
3340                ("o.txt", b"other\n"),
3341            ],
3342            vec![p1, p2],
3343            4,
3344            400,
3345        );
3346
3347        for level in [1u8, 2] {
3348            let opts = BlameOptions {
3349                copies: CopyDetection::On {
3350                    level,
3351                    threshold: 40,
3352                },
3353                ..Default::default()
3354            };
3355            let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3356            assert_eq!(r.lines[1].text, BLOCK_A);
3357            assert_eq!(
3358                r.lines[1].commit_hash, p1,
3359                "a source modified between the first parent and the merge is a \
3360                 level-{level} candidate and credits the first parent (git parity)"
3361            );
3362        }
3363    }
3364
3365    #[test]
3366    fn blame_c_boundary_first_parent_mode_still_searches_first_parent() {
3367        // `--first-parent -C -C` at a merge boundary: the real parent list
3368        // is truncated to the first parent (git's first_scapegoat does the
3369        // same), which is porigin-less for a newly-added file and therefore
3370        // whole-tree searched — the source on p1 is credited exactly as in
3371        // the merge-aware walk. Real git recipe (git 2.50.1):
3372        //   base: x.txt
3373        //   p1  = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")
3374        //   p2  = base + o.txt
3375        //   merge(p1,p2): + n.txt = BLOCK_A+BLOCK_B   (new file)
3376        //   $ git blame --first-parent -C -C n.txt -> credited to P1.
3377        let (_d, store) = fresh_store();
3378        let base = put_multi_file_commit(&store, &[("x.txt", b"x\n")], vec![], 1, 100);
3379        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3380        let p1 = put_multi_file_commit(
3381            &store,
3382            &[("x.txt", b"x\n"), ("s1.txt", &src)],
3383            vec![base],
3384            2,
3385            200,
3386        );
3387        let p2 = put_multi_file_commit(
3388            &store,
3389            &[("x.txt", b"x\n"), ("o.txt", b"other\n")],
3390            vec![base],
3391            3,
3392            300,
3393        );
3394        let newf = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3395        let merge = put_multi_file_commit(
3396            &store,
3397            &[
3398                ("x.txt", b"x\n"),
3399                ("s1.txt", &src),
3400                ("o.txt", b"other\n"),
3401                ("n.txt", &newf),
3402            ],
3403            vec![p1, p2],
3404            4,
3405            400,
3406        );
3407
3408        let opts = BlameOptions {
3409            copies: CopyDetection::On {
3410                level: 2,
3411                threshold: 40,
3412            },
3413            first_parent: true,
3414            ..Default::default()
3415        };
3416        let r = blame_file_with(&store, merge, "n.txt", &opts).unwrap();
3417        assert_eq!(r.lines[0].text, BLOCK_A);
3418        assert_eq!(
3419            r.lines[0].commit_hash, p1,
3420            "--first-parent truncates the boundary search to the real first \
3421             parent, which is still whole-tree searched (git parity)"
3422        );
3423        assert_eq!(r.lines[1].commit_hash, p1);
3424    }
3425
3426    #[test]
3427    fn blame_c_merge_mixed_within_file_move_beats_copy_on_tie() {
3428        // Real git recipe (git 2.50.1), `-M -C -C`: a length-1 tie between an
3429        // within-file `-M` move source on the FIRST parent and a cross-file
3430        // `-C` copy source on the second parent for the SAME moved line:
3431        //   base: f.txt="X\nY\n"
3432        //   p1  = f.txt=LONG_LINE+"X\nY\n"           (own prior version: -M source)
3433        //   c2  = base f.txt (unchanged) + other.txt=LONG_LINE+"other stuff\n" (-C source)
3434        //   merge(p1,c2): f.txt="X\nY\n"+LONG_LINE   (moved to the end)
3435        //   $ git blame -M -C -C -l f.txt -> LONG_LINE credited to p1 (the
3436        //   `-M` move), not c2's `-C` copy. `-M` is unaffected by `-C`'s
3437        //   first-parent carve-out and keeps its own first-parent-wins tie.
3438        let (_d, store) = fresh_store();
3439        let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
3440        let v1 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
3441        let p1 = put_file_commit(&store, "f.txt", &v1, vec![base], 2, 200);
3442        let c2 = put_multi_file_commit(
3443            &store,
3444            &[
3445                ("f.txt", b"X\nY\n"),
3446                ("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
3447            ],
3448            vec![base],
3449            3,
3450            300,
3451        );
3452        let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
3453        let merge = put_multi_file_commit(
3454            &store,
3455            &[
3456                ("f.txt", &vm),
3457                ("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
3458            ],
3459            vec![p1, c2],
3460            4,
3461            400,
3462        );
3463
3464        let opts = BlameOptions {
3465            moves: MoveDetection::On { threshold: 20 },
3466            copies: CopyDetection::On {
3467                level: 2,
3468                threshold: 40,
3469            },
3470            ..Default::default()
3471        };
3472        let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
3473        assert_eq!(r.lines[2].text, LONG_LINE);
3474        assert_eq!(
3475            r.lines[2].commit_hash, p1,
3476            "-M's within-file move on the first parent still wins the tie over -C's copy on the second"
3477        );
3478    }
3479
3480    #[test]
3481    fn blame_c_merge_boundary_copy_from_second_parent() {
3482        // -C merge residual #2, closed. Real git recipe (git 2.50.1): the
3483        // blamed file (`b.txt`) is ADDED by the merge — no parent contains
3484        // it at all — and its sole copy source lives on the SECOND parent:
3485        //   base: base.txt="base"
3486        //   p1  = base + m.txt("main only")            (no candidate)
3487        //   c2  = base + src.txt(BLOCK_A,BLOCK_B,"zzz") (candidate)
3488        //   merge(p1,c2): adds b.txt=BLOCK_A+BLOCK_B (new path, no parent has it)
3489        //   $ git blame -C -C -l b.txt -> credited to c2/src.txt.
3490        let (_d, store) = fresh_store();
3491        let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3492        let p1 = put_multi_file_commit(
3493            &store,
3494            &[("base.txt", b"base\n"), ("m.txt", b"main only\n")],
3495            vec![base],
3496            2,
3497            200,
3498        );
3499        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3500        let c2 = put_multi_file_commit(
3501            &store,
3502            &[("base.txt", b"base\n"), ("src.txt", &src)],
3503            vec![base],
3504            3,
3505            300,
3506        );
3507        let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3508        let merge = put_multi_file_commit(
3509            &store,
3510            &[
3511                ("base.txt", b"base\n"),
3512                ("m.txt", b"main only\n"),
3513                ("src.txt", &src),
3514                ("b.txt", &bnew),
3515            ],
3516            vec![p1, c2],
3517            4,
3518            400,
3519        );
3520
3521        let opts = BlameOptions {
3522            copies: CopyDetection::On {
3523                level: 2,
3524                threshold: 40,
3525            },
3526            ..Default::default()
3527        };
3528        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3529        assert_eq!(r.lines[0].text, BLOCK_A);
3530        assert_eq!(
3531            r.lines[0].commit_hash, c2,
3532            "a boundary -C source on a non-first parent is traced (git parity)"
3533        );
3534        assert_eq!(r.lines[1].commit_hash, c2);
3535    }
3536
3537    #[test]
3538    fn blame_c_merge_boundary_copy_octopus_third_parent() {
3539        // Real git recipe (git 2.50.1): boundary case (file wholly new),
3540        // 3-way octopus merge, source only on the THIRD parent:
3541        //   base: base.txt="base"
3542        //   p1 = base + pm.txt("p1 only")   (no candidate)
3543        //   c2 = base + cm.txt("c2 only")   (no candidate)
3544        //   c3 = base + s3.txt(BLOCK_A,BLOCK_B,"zzz")  (candidate)
3545        //   merge(p1,c2,c3): adds b.txt=BLOCK_A+BLOCK_B (new path)
3546        //   $ git blame -C -C -l b.txt -> credited to c3. Guards the
3547        //   boundary search against stopping after the first two parents.
3548        let (_d, store) = fresh_store();
3549        let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3550        let p1 = put_multi_file_commit(
3551            &store,
3552            &[("base.txt", b"base\n"), ("pm.txt", b"p1 only\n")],
3553            vec![base],
3554            2,
3555            200,
3556        );
3557        let c2 = put_multi_file_commit(
3558            &store,
3559            &[("base.txt", b"base\n"), ("cm.txt", b"c2 only\n")],
3560            vec![base],
3561            3,
3562            300,
3563        );
3564        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3565        let c3 = put_multi_file_commit(
3566            &store,
3567            &[("base.txt", b"base\n"), ("s3.txt", &src)],
3568            vec![base],
3569            4,
3570            400,
3571        );
3572        let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3573        let merge = put_multi_file_commit(
3574            &store,
3575            &[
3576                ("base.txt", b"base\n"),
3577                ("pm.txt", b"p1 only\n"),
3578                ("cm.txt", b"c2 only\n"),
3579                ("s3.txt", &src),
3580                ("b.txt", &bnew),
3581            ],
3582            vec![p1, c2, c3],
3583            5,
3584            500,
3585        );
3586
3587        let opts = BlameOptions {
3588            copies: CopyDetection::On {
3589                level: 2,
3590                threshold: 40,
3591            },
3592            ..Default::default()
3593        };
3594        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3595        assert_eq!(r.lines[0].text, BLOCK_A);
3596        assert_eq!(
3597            r.lines[0].commit_hash, c3,
3598            "the boundary search walks every real parent, not just the first two"
3599        );
3600        assert_eq!(r.lines[1].commit_hash, c3);
3601    }
3602
3603    #[test]
3604    fn blame_c_merge_boundary_copy_tie_prefers_first_parent() {
3605        // The boundary case's tie-break is the OPPOSITE of the interior
3606        // case's: real git recipe (git 2.50.1), both parents have the SAME
3607        // candidate for a wholly-new file:
3608        //   base: base.txt="base"
3609        //   p1 = base + s1.txt(BLOCK_A,BLOCK_B,"zzz")
3610        //   c2 = base + s2.txt(BLOCK_A,BLOCK_B,"zzz")  (same block)
3611        //   merge(p1,c2): adds b.txt=BLOCK_A+BLOCK_B (new path)
3612        //   $ git blame -C -C -l b.txt -> credited to p1 (the FIRST parent),
3613        //   unlike the interior tie (which excludes the first parent
3614        //   entirely). The boundary search includes every real parent,
3615        //   first-found-wins in natural order, so the first parent CAN win.
3616        let (_d, store) = fresh_store();
3617        let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3618        let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3619        let p1 = put_multi_file_commit(
3620            &store,
3621            &[("base.txt", b"base\n"), ("s1.txt", &src)],
3622            vec![base],
3623            2,
3624            200,
3625        );
3626        let c2 = put_multi_file_commit(
3627            &store,
3628            &[("base.txt", b"base\n"), ("s2.txt", &src)],
3629            vec![base],
3630            3,
3631            300,
3632        );
3633        let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3634        let merge = put_multi_file_commit(
3635            &store,
3636            &[
3637                ("base.txt", b"base\n"),
3638                ("s1.txt", &src),
3639                ("s2.txt", &src),
3640                ("b.txt", &bnew),
3641            ],
3642            vec![p1, c2],
3643            4,
3644            400,
3645        );
3646
3647        let opts = BlameOptions {
3648            copies: CopyDetection::On {
3649                level: 2,
3650                threshold: 40,
3651            },
3652            ..Default::default()
3653        };
3654        let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3655        assert_eq!(r.lines[0].text, BLOCK_A);
3656        assert_eq!(
3657            r.lines[0].commit_hash, p1,
3658            "the boundary copy tie prefers the first parent (git parity) — unlike the interior tie"
3659        );
3660    }
3661
3662    #[test]
3663    fn match_lines_rejects_oversize_inputs() {
3664        // G13 regression: the LCS DP table allocation is O(m*n). For
3665        // attacker-controlled blobs with millions of lines this means
3666        // gigabytes of heap. Cap both dimensions with BLAME_MAX_LINES
3667        // and return a FileTooLarge error rather than over-allocating.
3668        let n = super::BLAME_MAX_LINES + 1;
3669        let opts = BlameOptions::default();
3670        let old: Vec<Vec<u8>> = vec![b"x".to_vec(); n];
3671        let new: Vec<Vec<u8>> = vec![b"y".to_vec(); 1];
3672        let err = super::match_lines_with_options(&old, &new, &opts).unwrap_err();
3673        assert!(
3674            matches!(err, BlameError::FileTooLarge { lines } if lines == n),
3675            "got {err:?}"
3676        );
3677
3678        let old2: Vec<Vec<u8>> = vec![b"a".to_vec(); 1];
3679        let new2: Vec<Vec<u8>> = vec![b"b".to_vec(); n];
3680        let err2 = super::match_lines_with_options(&old2, &new2, &opts).unwrap_err();
3681        assert!(
3682            matches!(err2, BlameError::FileTooLarge { lines } if lines == n),
3683            "got {err2:?}"
3684        );
3685    }
3686}