Skip to main content

mkit_cli/commands/
blame.rs

1//! `mkit blame [-w] [-M] [-C] [--ignore-rev <rev>] [--ignore-revs-file <file>]
2//! [--ignore-rev-precise] [--first-parent] [--reverse] [<rev>] [-L <range>]
3//! <file>` — line-level attribution.
4//!
5//! Blames `<file>` as of `<rev>` (default `HEAD`), optionally restricted
6//! to a line range with `-L`. `-w` ignores whitespace when matching
7//! lines across revisions (git `-w`); `-M`/`-C` detect lines moved within
8//! the file / copied from other files (git `-M`/`-C`); `--ignore-rev` /
9//! `--ignore-revs-file` skip "noise" commits during attribution (git
10//! `--ignore-rev`), falling through to the commit that previously changed
11//! each line. `--ignore-rev-precise` (mkit-only; requires `--ignore-rev`/
12//! `--ignore-revs-file`) refines that fall-through with content matching
13//! instead of git's positional per-hunk guess — a documented divergence,
14//! opt-in only. Blame is merge-aware by default (a line merged from a side
15//! branch is credited to the commit that wrote it); `--first-parent`
16//! restricts the walk to first parents (git `--first-parent`).
17//! `--reverse <start>..<end>` instead walks history forward, attributing
18//! each line of the `<start>` version to the last commit in the range in
19//! which it still existed (git `--reverse`).
20//!
21//! Output modes:
22//!
23//! - default — `<short12>\t<line_num>\t<text>\n` per line, pinned by
24//!   the integration test in `tests/cli_wire.rs:233-243`.
25//! - `--format=json` — JSONL, one self-contained record per line with
26//!   keys `hash`, `line_num`, `author`, `timestamp`, `text`. Schema
27//!   diverges from the tab format because mkit's author is an
28//!   Identity, not a `Name <email>` string — see commands/log.rs for
29//!   the same divergence.
30//!
31//! Line numbers in the output are always the file's own 1-based numbers,
32//! so a `-L 40,60` slice still prints `40..=60`, matching `git blame -L`.
33
34use std::collections::{HashMap, HashSet};
35use std::io::Write;
36use std::sync::Arc;
37
38use clap::{Parser, ValueEnum};
39use mkit_core::hash::{self, Hash};
40use mkit_core::layout::RepoLayout;
41use mkit_core::ops::blame::{
42    BlameOptions, BlameResult, CopyDetection, MoveDetection, blame_file_reverse, blame_file_with,
43    format_blame_text,
44};
45use mkit_core::refs;
46use mkit_core::store::ObjectStore;
47
48use super::revspec;
49use crate::clap_shim;
50use crate::exit;
51use crate::format;
52
53#[derive(Debug, Clone, Copy, ValueEnum)]
54enum BlameFormat {
55    Default,
56    Json,
57}
58
59#[derive(Debug, Parser)]
60#[command(
61    name = "mkit blame",
62    about = "Show line-level commit attribution.",
63    override_usage = "mkit blame [OPTIONS] [<rev>] [--] <file>"
64)]
65// A CLI flag struct: each bool is an independent `git blame` toggle, so the
66// "too many bools" heuristic (which targets state that should be an enum)
67// does not apply.
68#[allow(clippy::struct_excessive_bools)]
69struct BlameOpts {
70    /// Output format. Default emits `<short12>\t<line_num>\t<text>`
71    /// per line; `json` emits JSONL with `hash`, `line_num`,
72    /// `author`, `timestamp`, `text` keys.
73    #[arg(long, value_enum, default_value = "default")]
74    format: BlameFormat,
75    /// Emit git's grouped porcelain: a per-line header block (commit id,
76    /// original + final line numbers, author/committer, summary, `boundary`,
77    /// `filename`) with each content line tab-prefixed; the metadata block
78    /// is emitted once per commit. See [`render_porcelain`] for mkit's
79    /// documented field mapping (identity, UTC tz, `filename` on `-C`).
80    #[arg(long = "porcelain", conflicts_with = "format")]
81    porcelain: bool,
82    /// Like `--porcelain`, but repeat the full header block for every line.
83    #[arg(long = "line-porcelain", conflicts_with = "format")]
84    line_porcelain: bool,
85    /// Ignore whitespace when matching lines across revisions, like
86    /// `git blame -w`, so a whitespace-only edit (reindent, tab↔space,
87    /// spacing tweak) doesn't reattribute the line. Output still shows
88    /// the file's current bytes.
89    #[arg(short = 'w', long = "ignore-whitespace")]
90    ignore_whitespace: bool,
91    /// Restrict output to a line range, like `git blame -L`. Accepts
92    /// `<start>,<end>`, `<start>,+<n>` (n lines forward), `<start>,-<n>`
93    /// (n lines back, ending at start), `<start>,` (start to EOF),
94    /// `,<end>` (start of file to end), or a bare `<start>` (start to
95    /// EOF). Lines are 1-based and inclusive; an inverted range is
96    /// swapped and an over-long end is clamped to EOF, matching git.
97    // `allow_hyphen_values` so a pathological negative start (`-3,5`)
98    // reaches the parser for a git-faithful diagnostic instead of clap
99    // mistaking `-3` for a flag. Valid values never start with `-` (the
100    // `-<n>` offset is always the second field).
101    #[arg(
102        short = 'L',
103        long = "lines",
104        value_name = "START,END",
105        allow_hyphen_values = true
106    )]
107    lines: Option<String>,
108    /// Detect lines moved *within* the file, like `git blame -M`: a moved
109    /// block of at least 20 alphanumeric characters is credited to its
110    /// origin commit rather than the editing one. The inline
111    /// `-M<num>`/`-M<num>%` form overrides the threshold and is pulled out
112    /// of argv by [`extract_inline_thresholds`] before clap runs; this
113    /// bool captures the bare `-M`/`--find-moves` flag.
114    #[arg(short = 'M', long = "find-moves")]
115    find_moves: bool,
116    /// Detect lines copied *from other files*, like `git blame -C`
117    /// (implies `-M`). Repeat to widen the search: `-C` covers files
118    /// changed in the same commit, `-C -C` every file in the parent
119    /// commit. A copied block needs at least 40 alphanumeric characters.
120    /// The inline `-C<num>`/`-C<num>%` form overrides the threshold and
121    /// still counts toward the level; [`extract_inline_thresholds`] pulls
122    /// those out of argv before clap runs, so this count captures only the
123    /// bare `-C`/`--find-copies` occurrences.
124    #[arg(short = 'C', long = "find-copies", action = clap::ArgAction::Count)]
125    find_copies: u8,
126    /// Ignore a "noise" commit (mass reformat, license header, rename)
127    /// when attributing lines, like `git blame --ignore-rev`. A line that
128    /// would be credited to an ignored commit falls through to the commit
129    /// that previously changed it; a genuine insertion stays put. Accepts
130    /// any revision (short hash, ref, `HEAD~2`) and may be repeated.
131    #[arg(long = "ignore-rev", value_name = "REV")]
132    ignore_rev: Vec<String>,
133    /// Ignore every commit listed in `<file>`, like
134    /// `git blame --ignore-revs-file`. One full hex object name per line;
135    /// blank lines and `#` comments (including inline) are skipped. May be
136    /// repeated.
137    #[arg(long = "ignore-revs-file", value_name = "FILE")]
138    ignore_revs_file: Vec<String>,
139    /// Refine `--ignore-rev`/`--ignore-revs-file` fall-through with content
140    /// matching instead of git's positional per-hunk guess (mkit-only
141    /// divergence; the default fall-through stays git-identical). git pairs
142    /// a fallen-through line with whatever line sits at the same offset in
143    /// the hunk; mkit hashes content, so it can often identify the line's
144    /// true surviving origin even when a reformat/reorder moved it to a
145    /// different offset. Requires `--ignore-rev` or `--ignore-revs-file`.
146    #[arg(long = "ignore-rev-precise")]
147    ignore_rev_precise: bool,
148    /// Walk history *forward* instead of backward, like
149    /// `git blame --reverse`. Blames the `<start>` version of the file and
150    /// attributes each line to the last commit in the range in which it
151    /// still existed. Requires the `<rev>` argument to be a
152    /// `<start>..<end>` range (`<start>..` defaults `<end>` to HEAD); a
153    /// bare revision or a missing `<start>` is rejected. Cannot be combined
154    /// with `-M`/`-C` or `--ignore-rev`/`--ignore-revs-file`.
155    #[arg(long = "reverse")]
156    reverse: bool,
157    /// Follow only each commit's first parent, like `git blame
158    /// --first-parent`. By default blame is merge-aware: a line merged in
159    /// from a side branch is credited to the commit that wrote it. With
160    /// `--first-parent` such a line is credited to the merge commit instead.
161    /// Composes with `-w`/`-M`/`-C`/`--ignore-rev`; redundant with
162    /// `--reverse` (reverse blame is already first-parent only).
163    #[arg(long = "first-parent")]
164    first_parent: bool,
165    /// `[<rev>] <file>`: the file to blame, optionally preceded by the
166    /// revision to blame it at (a ref, hash, or `HEAD~2`-style spec).
167    /// Without a revision the file is blamed against HEAD. A `--`
168    /// separator before the file is accepted and ignored.
169    #[arg(value_name = "REV/FILE", num_args = 1..=2, required = true)]
170    rev_and_file: Vec<String>,
171}
172
173#[must_use]
174#[allow(clippy::too_many_lines)] // linear flow: parse + resolve + blame + slice + render
175pub fn run(args: &[String]) -> u8 {
176    // git's inline `-M<num>`/`-C<num>` forms can't be expressed with clap
177    // derive (a short flag that both repeats *and* takes an optional glued
178    // value), so pull them out of argv first; bare `-M`/`-C` and stacked
179    // short clusters (`-CC`, `-Mw`) fall through to clap below.
180    let (clap_args, inline) = extract_inline_thresholds(args);
181    let opts = match clap_shim::parse::<BlameOpts>("mkit blame", &clap_args) {
182        Ok(o) => o,
183        Err(code) => return code,
184    };
185    let json = matches!(opts.format, BlameFormat::Json);
186
187    // Merge the inline-form results with clap's bare-flag results: a
188    // `-M<num>` counts as `-M`, and each `-C<num>` still adds to the copy
189    // level like a bare `-C`.
190    let find_moves = opts.find_moves || inline.moves;
191    let find_copies = opts.find_copies.saturating_add(inline.copies);
192
193    // `rev_and_file` is clamped to 1..=2 by clap: one value is the file
194    // (blame against HEAD); two values are `<rev> <file>`.
195    let (rev_spec, file) = match opts.rev_and_file.as_slice() {
196        [file] => (None, file),
197        [rev, file] => (Some(rev), file),
198        // Unreachable: clap enforces num_args = 1..=2.
199        _ => return emit_err("expected [<rev>] <file>", exit::USAGE),
200    };
201
202    let cwd = match std::env::current_dir() {
203        Ok(p) => p,
204        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
205    };
206    let layout = match super::resolve_layout(&cwd) {
207        Ok(layout) => layout,
208        Err(code) => return code,
209    };
210    let store = match ObjectStore::open(&layout) {
211        Ok(s) => s,
212        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
213    };
214
215    // Uses the *effective* move/copy state (`find_moves`/`find_copies`),
216    // which folds in the inline `-M<num>`/`-C<num>` forms, so a combination
217    // like `-M20 --reverse` is still rejected.
218    if let Err((msg, code)) = check_flag_conflicts(&opts, find_moves, find_copies) {
219        return emit_err(&msg, code);
220    }
221
222    // `-M` enables move detection; `-C` (a repeat count) sets the copy
223    // search level. An inline `-M<num>`/`-C<num>` overrides the default
224    // threshold, otherwise git's defaults (20 for `-M`, 40 for `-C`) apply.
225    // `-C` implies `-M` in the core, so a bare `-C` still credits
226    // within-file moves too.
227    let moves = if find_moves {
228        match inline.move_threshold {
229            Some(threshold) => MoveDetection::On { threshold },
230            None => MoveDetection::GIT_DEFAULT,
231        }
232    } else {
233        MoveDetection::Off
234    };
235    let copies = if find_copies > 0 {
236        match inline.copy_threshold {
237            Some(threshold) => CopyDetection::On {
238                level: find_copies,
239                threshold,
240            },
241            None => CopyDetection::git_default(find_copies),
242        }
243    } else {
244        CopyDetection::Off
245    };
246    // Build the `--ignore-rev` / `--ignore-revs-file` skip set. Each
247    // failure is already git-faithful text paired with an exit code.
248    let ignore_revs = match collect_ignore_revs(&store, &layout, &opts) {
249        Ok(set) => Arc::new(set),
250        Err((msg, code)) => return emit_err(&msg, code),
251    };
252
253    let blame_opts = BlameOptions {
254        ignore_whitespace: opts.ignore_whitespace,
255        moves,
256        copies,
257        ignore_revs,
258        ignore_rev_precise: opts.ignore_rev_precise,
259        first_parent: opts.first_parent,
260    };
261
262    // `--reverse` walks forward over a `<start>..<end>` range; plain blame
263    // walks backward from a single `<rev>` (or HEAD).
264    let result = if opts.reverse {
265        let (start, end) = match resolve_reverse_range(&store, &layout, rev_spec, file) {
266            Ok(pair) => pair,
267            Err((msg, code)) => return emit_err(&msg, code),
268        };
269        match blame_file_reverse(&store, start, end, file, &blame_opts) {
270            Ok(r) => r,
271            Err(e) => return emit_err(&format!("blame: {e}"), exit::NOINPUT),
272        }
273    } else {
274        // Resolve the commit to blame against: an explicit <rev> via the
275        // shared revspec grammar, otherwise HEAD.
276        let head = if let Some(spec) = rev_spec {
277            match revspec::resolve_revision(&store, &layout, spec) {
278                Ok(h) => h,
279                Err(e) => return emit_err(&format!("{e}"), exit::NOINPUT),
280            }
281        } else {
282            match refs::resolve_head(&layout) {
283                Ok(Some(h)) => h,
284                Ok(None) => return emit_err("no commits yet", exit::GENERAL_ERROR),
285                Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
286            }
287        };
288        match blame_file_with(&store, head, file, &blame_opts) {
289            Ok(r) => r,
290            Err(e) => return emit_err(&format!("blame: {e}"), exit::NOINPUT),
291        }
292    };
293
294    // `-L` slices the per-line attributions to the requested range,
295    // preserving the file's own 1-based line numbers in the output.
296    let result = match &opts.lines {
297        Some(spec) => match parse_line_range(spec, result.lines.len(), file) {
298            Ok((start, end)) => BlameResult {
299                lines: result.lines[start - 1..end].to_vec(),
300            },
301            // The message is already git-faithful and self-contained
302            // (it carries `file` where git does), so it prints verbatim.
303            Err(msg) => return emit_err(&msg, exit::USAGE),
304        },
305        None => result,
306    };
307
308    if opts.porcelain || opts.line_porcelain {
309        render_porcelain(&store, &result, file, opts.line_porcelain)
310    } else if json {
311        render_json(&result)
312    } else {
313        let text = format_blame_text(&result);
314        let mut stdout = std::io::stdout().lock();
315        let _ = stdout.write_all(text.as_bytes());
316        exit::OK
317    }
318}
319
320/// Reject flag combinations `run` cannot satisfy, before any store/revision
321/// resolution work. On error returns `(message, exit_code)`.
322///
323/// `find_moves`/`find_copies` are the *effective* move/copy state (clap's
324/// bare `-M`/`-C` folded together with the inline `-M<num>`/`-C<num>`
325/// forms), so the check fires even when detection was requested only via an
326/// inline threshold.
327fn check_flag_conflicts(
328    opts: &BlameOpts,
329    find_moves: bool,
330    find_copies: u8,
331) -> Result<(), (String, u8)> {
332    // `--reverse` is a distinct walk that resolves line survival via the
333    // LCS matcher only — it runs neither move/copy nor ignore-rev
334    // detection, so reject the combination rather than silently ignoring
335    // those flags.
336    if opts.reverse
337        && (find_moves
338            || find_copies > 0
339            || !opts.ignore_rev.is_empty()
340            || !opts.ignore_revs_file.is_empty())
341    {
342        return Err((
343            "--reverse cannot be combined with -M/-C or --ignore-rev/--ignore-revs-file"
344                .to_string(),
345            exit::USAGE,
346        ));
347    }
348    // `--ignore-rev-precise` only refines an active ignore-rev set; without
349    // one it has nothing to refine, so reject it outright rather than
350    // silently no-op'ing.
351    if opts.ignore_rev_precise && opts.ignore_rev.is_empty() && opts.ignore_revs_file.is_empty() {
352        return Err((
353            "--ignore-rev-precise requires --ignore-rev or --ignore-revs-file".to_string(),
354            exit::USAGE,
355        ));
356    }
357    Ok(())
358}
359
360/// Inline `-M<num>`/`-C<num>` threshold state pulled from argv before clap
361/// parsing. Bare `-M`/`-C` are left for clap (which owns the help text and
362/// the `-C` repeat count); only the glued-value forms — which clap-derive
363/// can't model — are handled here.
364#[derive(Default)]
365struct InlineThresholds {
366    /// A `-M<num>` was seen (implies move detection, like a bare `-M`).
367    moves: bool,
368    /// Threshold from the last `-M<num>` seen, if any.
369    move_threshold: Option<usize>,
370    /// Count of `-C<num>` occurrences; each still adds to the copy level.
371    copies: u8,
372    /// Threshold from the last `-C<num>` seen, if any.
373    copy_threshold: Option<usize>,
374}
375
376/// Pull git's inline `-M<num>`/`-C<num>`/`-M<num>%` forms out of `args`,
377/// returning the remaining args (for clap) and the parsed thresholds.
378///
379/// clap-derive can't model a short flag that both repeats (`-C` sets the
380/// copy level) and takes an optional glued value (`-C40` sets the
381/// threshold), so the valued forms are handled here and the bare `-M`/`-C`
382/// flags fall through to clap unchanged. Only glued values are consumed:
383/// bare `-M`/`-C`, the `-L` range value (even a `-3,5`), and every
384/// positional pass through untouched, and nothing after a `--` end-of-
385/// options marker is inspected (so a file literally named `-C9` survives).
386///
387/// A glued value is consumed as a threshold **only when it is numeric**
388/// (`-M20`, `-C40%`). Everything else — bare `-M`/`-C`, stacked short
389/// clusters (`-CC` = copy level 2, `-Mw` = `-M -w`), the `-L` range value
390/// (even a `-3,5`), and positionals — passes through untouched to clap, and
391/// nothing after a `--` end-of-options marker is inspected (so a file
392/// literally named `-C9` survives). Passing non-numeric `-M`/`-C` tokens on
393/// to clap keeps git's/clap's short-flag stacking working and lets clap own
394/// the diagnostic for a genuinely bad flag.
395///
396/// The number is a minimum alphanumeric-character count — git's non-`%`
397/// `-M<n>` unit, which maps 1:1 onto mkit's core threshold. A trailing `%`
398/// is accepted for git-surface compatibility but the number is still used
399/// as a char count: mkit's block detector has no similarity-ratio model, a
400/// deliberate, `log`-consistent divergence (documented in `docs/CLI.md`).
401fn extract_inline_thresholds(args: &[String]) -> (Vec<String>, InlineThresholds) {
402    let mut rest = Vec::with_capacity(args.len());
403    let mut out = InlineThresholds::default();
404    let mut opts_ended = false;
405    for arg in args {
406        if opts_ended {
407            rest.push(arg.clone());
408            continue;
409        }
410        if arg == "--" {
411            opts_ended = true;
412            rest.push(arg.clone());
413            continue;
414        }
415        if let Some(t) = arg.strip_prefix("-M").and_then(parse_threshold) {
416            out.move_threshold = Some(t);
417            out.moves = true;
418        } else if let Some(t) = arg.strip_prefix("-C").and_then(parse_threshold) {
419            out.copy_threshold = Some(t);
420            out.copies = out.copies.saturating_add(1);
421        } else {
422            // Bare `-M`/`-C`, a stacked cluster, or a non-`-M`/`-C` token:
423            // clap handles it.
424            rest.push(arg.clone());
425        }
426    }
427    (rest, out)
428}
429
430/// Parse the value glued to `-M`/`-C` into a threshold, or `None` when it
431/// is not a bare number — an empty suffix (bare `-M`), a stacked cluster
432/// (`-CC` → `"C"`), or otherwise non-numeric. A single trailing `%` is
433/// stripped first (git-surface compatibility; the number is still a char
434/// count). An all-digit value that overflows `usize` clamps to `MAX` — an
435/// unreachable threshold — rather than being mis-read as a stacked cluster.
436fn parse_threshold(val: &str) -> Option<usize> {
437    let num = val.strip_suffix('%').unwrap_or(val);
438    if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) {
439        return None;
440    }
441    Some(num.parse::<usize>().unwrap_or(usize::MAX))
442}
443
444/// Resolve `--ignore-rev` / `--ignore-revs-file` into the set of commits
445/// to skip during attribution.
446///
447/// `--ignore-rev` takes any revision (short hash, ref, `HEAD~2`) via the
448/// shared revspec grammar — git resolves these the same way — so an
449/// unknown one errors `cannot find revision <rev> to ignore`.
450/// `--ignore-revs-file` entries must be **full** hex object names (git
451/// rejects short hashes in the file): each line is truncated at the first
452/// `#` (inline comments), trimmed, and skipped if empty; a malformed
453/// entry errors `invalid object name: <token>`, and an unreadable file
454/// `could not open object name list: <path>`. All three messages and the
455/// full-hash-only rule were verified against real git.
456///
457/// On error returns `(message, exit_code)`; mkit uses its sysexits-style
458/// codes rather than git's blanket `128`.
459fn collect_ignore_revs(
460    store: &ObjectStore,
461    layout: &RepoLayout,
462    opts: &BlameOpts,
463) -> Result<HashSet<Hash>, (String, u8)> {
464    let mut set = HashSet::new();
465
466    for spec in &opts.ignore_rev {
467        match revspec::resolve_revision(store, layout, spec) {
468            Ok(h) => {
469                set.insert(h);
470            }
471            Err(_) => {
472                return Err((
473                    format!("cannot find revision {spec} to ignore"),
474                    exit::DATAERR,
475                ));
476            }
477        }
478    }
479
480    for path in &opts.ignore_revs_file {
481        let contents = std::fs::read_to_string(path).map_err(|_| {
482            (
483                format!("could not open object name list: {path}"),
484                exit::NOINPUT,
485            )
486        })?;
487        for raw in contents.lines() {
488            // Strip an inline `#` comment, then surrounding whitespace
489            // (covers trailing `\r` on CRLF files), matching git.
490            let line = raw.split('#').next().unwrap_or("").trim();
491            if line.is_empty() {
492                continue;
493            }
494            let h = hash::from_hex(line)
495                .map_err(|_| (format!("invalid object name: {line}"), exit::DATAERR))?;
496            set.insert(h);
497        }
498    }
499
500    Ok(set)
501}
502
503/// Resolve the `--reverse` `<start>..<end>` range argument into a pair of
504/// commit hashes. `<start>..` defaults `<end>` to HEAD. A missing range, a
505/// bare revision (no `..`), an empty `<start>`, a triple-dot/extra-dot
506/// range, or an empty range (`start == end`) is a usage error.
507///
508/// `file` is only used to sharpen the no-range diagnostic: if the single
509/// positional looks like the range itself (`a..b`), the file was likely
510/// forgotten.
511///
512/// git's diagnostics here (`No commit to dig up from?`, `More than one
513/// commit to dig up from, X and Y?`) are cryptic; mkit names the concrete
514/// problem instead — a documented bucket-1 divergence, like the clearer
515/// `-L` messages. On error returns `(message, exit_code)`.
516fn resolve_reverse_range(
517    store: &ObjectStore,
518    layout: &RepoLayout,
519    rev_spec: Option<&String>,
520    file: &str,
521) -> Result<(Hash, Hash), (String, u8)> {
522    let Some(spec) = rev_spec else {
523        // A lone `a..b` positional is parsed as the *file*; the user most
524        // likely supplied the range but omitted the filename.
525        if file.contains("..") {
526            return Err((
527                format!(
528                    "--reverse: missing <file> (got only '{file}', which looks like the range)"
529                ),
530                exit::USAGE,
531            ));
532        }
533        return Err((
534            "--reverse requires a <start>..<end> revision range".to_string(),
535            exit::USAGE,
536        ));
537    };
538    let Some((start_str, end_str)) = spec.split_once("..") else {
539        return Err((
540            format!("--reverse requires a <start>..<end> range, got '{spec}'"),
541            exit::USAGE,
542        ));
543    };
544    // Reject git's triple-dot symmetric range and any extra `..`: blame
545    // takes a single two-dot range. (`a...b` splits to end `.b`; `a..b..c`
546    // to end `b..c`.)
547    if end_str.starts_with('.') || end_str.contains("..") {
548        return Err((
549            format!("--reverse requires a single <start>..<end> range, got '{spec}'"),
550            exit::USAGE,
551        ));
552    }
553    if start_str.is_empty() {
554        return Err((
555            "--reverse requires an explicit <start> revision".to_string(),
556            exit::USAGE,
557        ));
558    }
559    let start = revspec::resolve_revision(store, layout, start_str)
560        .map_err(|e| (format!("{e}"), exit::NOINPUT))?;
561    // `<start>..` (empty end) defaults to HEAD, matching git.
562    let end = if end_str.is_empty() {
563        match refs::resolve_head(layout) {
564            Ok(Some(h)) => h,
565            Ok(None) => return Err(("no commits yet".to_string(), exit::GENERAL_ERROR)),
566            Err(e) => return Err((format!("resolve HEAD: {e}"), exit::GENERAL_ERROR)),
567        }
568    } else {
569        revspec::resolve_revision(store, layout, end_str)
570            .map_err(|e| (format!("{e}"), exit::NOINPUT))?
571    };
572    // An empty range (`start == end`) has nothing to walk; git rejects it.
573    if start == end {
574        return Err((
575            format!("--reverse: empty revision range '{spec}'"),
576            exit::USAGE,
577        ));
578    }
579    Ok((start, end))
580}
581
582/// Parse a `git blame -L` style range spec into an inclusive, 1-based
583/// `(start, end)` pair validated against `total` (the file's line count).
584/// `file` only feeds git-faithful "has only N lines" diagnostics.
585///
586/// Accepted forms — `<start>,<end>`, `<start>,+<n>` (n lines forward),
587/// `<start>,-<n>` (n lines back, ending at `<start>`), `<start>,`,
588/// `,<end>`, and a bare `<start>` (treated as `<start>,` → to EOF).
589/// An omitted start defaults to line 1; an omitted end to `total`.
590/// To match git: an inverted absolute range (`5,2`) is swapped, a low
591/// bound past EOF errors `file <f> has only N lines`, and an over-long
592/// high bound is clamped to `total`. A zero/negative line number errors
593/// `-L invalid line number: <tok>` and a zero offset `-L invalid empty
594/// range`.
595///
596/// Returns `Err(message)` with a git-faithful diagnostic on bad input.
597fn parse_line_range(spec: &str, total: usize, file: &str) -> Result<(usize, usize), String> {
598    let (start_tok, end_tok) = match spec.split_once(',') {
599        Some((s, e)) => (s.trim(), Some(e.trim())),
600        None => (spec.trim(), None),
601    };
602
603    // Start anchor: defaults to 1 when omitted (the `,<end>` form).
604    let start = if start_tok.is_empty() {
605        1
606    } else {
607        parse_one_based(start_tok)?
608    };
609
610    // Resolve the inclusive `(lo, hi)` bounds from the end token. git's
611    // end forms:
612    //   omitted / empty  → to EOF
613    //   absolute `<m>`   → swap with start if inverted (`5,2` → 2..5)
614    //   `+<n>`           → n lines forward from start (`5,+2` → 5..6)
615    //   `-<n>`           → n lines back, *ending* at start (`5,-2` → 4..5),
616    //                      the low bound clamped up to line 1
617    // A `+0` / `-0` offset is an empty range.
618    let (lo, hi) = match end_tok {
619        None | Some("") => (start, total),
620        Some(tok) if tok.starts_with('+') => (start, start.saturating_add(parse_offset(tok)? - 1)),
621        Some(tok) if tok.starts_with('-') => {
622            let n = parse_offset(tok)?;
623            (start.saturating_sub(n - 1).max(1), start)
624        }
625        Some(tok) => {
626            let m = parse_one_based(tok)?;
627            if start > m { (m, start) } else { (start, m) }
628        }
629    };
630
631    // Empty file: no blamable lines. Checked *after* token validation so
632    // an explicit zero / empty-range token reports its own error first,
633    // matching git for every form.
634    if total == 0 {
635        return Err(format!("file {file} has only 0 lines"));
636    }
637
638    // git validates the low bound (the actual range start) against EOF for
639    // every form — including `-<n>`, whose anchor may itself sit past EOF —
640    // and clamps an over-long high bound down to the last line. `lo >= 1`
641    // here by construction, so the `lines[lo - 1..hi]` slice is safe.
642    if lo > total {
643        return Err(format!("file {file} has only {total} lines"));
644    }
645    Ok((lo, hi.min(total)))
646}
647
648/// Parse a single decimal line-number token. Junk (non-integer) gets a
649/// clear mkit diagnostic; git instead dumps usage here.
650fn parse_line_num(tok: &str) -> Result<usize, String> {
651    tok.parse::<usize>()
652        .map_err(|_| format!("invalid line number '{tok}' in -L range"))
653}
654
655/// Parse a 1-based absolute line number, rejecting `0` and negatives the
656/// way git does: a parseable-but-invalid integer (e.g. `0`, `-3`) yields
657/// `-L invalid line number: <tok>`, while non-integer junk keeps the
658/// clearer [`parse_line_num`] message.
659fn parse_one_based(tok: &str) -> Result<usize, String> {
660    match tok.parse::<usize>() {
661        Ok(n) if n >= 1 => Ok(n),
662        // `0`, or (via the usize parse failing) a negative integer.
663        _ if tok.parse::<i64>().is_ok() => Err(format!("-L invalid line number: {tok}")),
664        _ => Err(format!("invalid line number '{tok}' in -L range")),
665    }
666}
667
668/// Parse the `<n>` in a `+<n>` / `-<n>` end offset (the leading sign is
669/// included in `tok`). A zero offset is git's `-L invalid empty range`.
670fn parse_offset(tok: &str) -> Result<usize, String> {
671    let n = parse_line_num(&tok[1..])?;
672    if n == 0 {
673        return Err("-L invalid empty range".to_string());
674    }
675    Ok(n)
676}
677
678/// JSONL output for `--format=json`. One record per source line:
679///
680/// ```json
681/// {"hash":"<64-hex>","line_num":<int>,"author":"<identity>","timestamp":<int>,"text":"<line>"}
682/// ```
683fn render_json(result: &BlameResult) -> u8 {
684    let mut stdout = std::io::stdout().lock();
685    for line in &result.lines {
686        let _ = stdout.write_all(b"{");
687        let _ = write!(
688            stdout,
689            "\"hash\":\"{}\"",
690            format::hex_hash(&line.commit_hash)
691        );
692        let _ = write!(stdout, ",\"line_num\":{}", line.line_num);
693        let _ = write!(
694            stdout,
695            ",\"author\":\"{}\"",
696            format::json_escape(&format::full_identity(&line.author))
697        );
698        let _ = write!(stdout, ",\"timestamp\":{}", line.timestamp);
699        // Line text may contain arbitrary bytes from the source file.
700        // Render via lossy UTF-8 — the original bytes are recoverable
701        // via the default tab format for callers that care.
702        let text = String::from_utf8_lossy(&line.text);
703        let _ = write!(stdout, ",\"text\":\"{}\"", format::json_escape(&text));
704        let _ = stdout.write_all(b"}\n");
705    }
706    exit::OK
707}
708
709/// Grouped / line porcelain output (`--porcelain` / `--line-porcelain`),
710/// matching git 2.50.1's field ordering and grouping for the in-scope
711/// fields.
712///
713/// Each line emits a header `<64-hex-sha> <orig> <final>` — plus the group
714/// length on the first line of a run of one commit — then a metadata block
715/// (once per commit for `--porcelain`, for **every** line under
716/// `--line-porcelain`), then the tab-prefixed content bytes.
717///
718/// mkit field mapping — deliberate, `log`-consistent divergences from git,
719/// same spirit as `blame --format=json`:
720/// - `author`/`committer` carry mkit's Identity string (e.g. `ed25519:…`),
721///   not a `Name`; `author-mail`/`committer-mail` are empty (`<>`) — mkit
722///   has no email.
723/// - mkit commits hold a single author + timestamp, so `committer*` mirror
724///   `author*` and both `*-tz` are `+0000` (mkit timestamps are UTC).
725/// - `filename` is the blamed path, or the `-C` copy source for a
726///   cross-file copy. git's `previous` line is outside the in-scope field
727///   set (#524) and is not emitted.
728fn render_porcelain(
729    store: &ObjectStore,
730    result: &BlameResult,
731    file: &str,
732    line_porcelain: bool,
733) -> u8 {
734    let mut summaries: HashMap<Hash, String> = HashMap::new();
735    let mut seen: HashSet<Hash> = HashSet::new();
736    let lines = &result.lines;
737    let mut stdout = std::io::stdout().lock();
738
739    let mut i = 0;
740    while i < lines.len() {
741        // A group is a maximal run of consecutive lines from one commit; its
742        // length is printed on the group's first header (git's 4th field).
743        let commit = lines[i].commit_hash;
744        let mut group_len = 1;
745        while i + group_len < lines.len() && lines[i + group_len].commit_hash == commit {
746            group_len += 1;
747        }
748        for g in 0..group_len {
749            let line = &lines[i + g];
750            let hex = format::hex_hash(&line.commit_hash);
751            if g == 0 {
752                let _ = writeln!(
753                    stdout,
754                    "{hex} {} {} {group_len}",
755                    line.orig_line_num, line.line_num
756                );
757            } else {
758                let _ = writeln!(stdout, "{hex} {} {}", line.orig_line_num, line.line_num);
759            }
760            // Grouped porcelain emits the metadata once per commit;
761            // line-porcelain repeats it for every line.
762            let emit_meta = line_porcelain || seen.insert(line.commit_hash);
763            if emit_meta {
764                let ident = format::full_identity(&line.author);
765                let summary = summaries
766                    .entry(line.commit_hash)
767                    .or_insert_with(|| super::commit_subject(store, &line.commit_hash));
768                let _ = writeln!(stdout, "author {ident}");
769                let _ = writeln!(stdout, "author-mail <>");
770                let _ = writeln!(stdout, "author-time {}", line.timestamp);
771                let _ = writeln!(stdout, "author-tz +0000");
772                let _ = writeln!(stdout, "committer {ident}");
773                let _ = writeln!(stdout, "committer-mail <>");
774                let _ = writeln!(stdout, "committer-time {}", line.timestamp);
775                let _ = writeln!(stdout, "committer-tz +0000");
776                let _ = writeln!(stdout, "summary {summary}");
777                if line.boundary {
778                    let _ = writeln!(stdout, "boundary");
779                }
780                let filename = line.source_path.as_deref().unwrap_or(file);
781                let _ = writeln!(stdout, "filename {filename}");
782            }
783            // Content line: tab prefix + raw bytes + newline (git puts each
784            // line's exact bytes after the tab).
785            let _ = stdout.write_all(b"\t");
786            let _ = stdout.write_all(&line.text);
787            let _ = stdout.write_all(b"\n");
788        }
789        i += group_len;
790    }
791    exit::OK
792}
793
794use super::error as emit_err;
795
796#[cfg(test)]
797mod tests {
798    // Semantics here are pinned against real `git blame -L` behavior
799    // (verified empirically): inclusive bounds, `+n` = n lines, bare
800    // start runs to EOF, inverted ranges swap, over-long ends clamp.
801
802    /// Thin wrapper supplying a fixed filename so the range cases read
803    /// cleanly; the filename only colors the "has only N lines" message.
804    fn range(spec: &str, total: usize) -> Result<(usize, usize), String> {
805        super::parse_line_range(spec, total, "f.txt")
806    }
807
808    #[test]
809    fn explicit_range_is_inclusive() {
810        assert_eq!(range("3,5", 8), Ok((3, 5)));
811    }
812
813    #[test]
814    fn plus_n_is_n_lines_from_start() {
815        // `git blame -L 3,+2` → lines 3,4.
816        assert_eq!(range("3,+2", 8), Ok((3, 4)));
817        assert_eq!(range("1,+1", 8), Ok((1, 1)));
818    }
819
820    #[test]
821    fn minus_n_is_n_lines_ending_at_start() {
822        // `git blame -L <start>,-<n>` → n lines ending at start, low bound
823        // clamped up to line 1. Verified against real git.
824        assert_eq!(range("5,-2", 8), Ok((4, 5)));
825        assert_eq!(range("8,-3", 8), Ok((6, 8)));
826        assert_eq!(range("3,-1", 8), Ok((3, 3)));
827        assert_eq!(range("2,-5", 8), Ok((1, 2))); // clamps to line 1
828    }
829
830    #[test]
831    fn minus_n_anchor_past_eof_still_validates_low_bound() {
832        // `12,-3` on an 8-line file → [10,12] → low bound 10 > 8: error,
833        // matching git (it validates the range start, not the anchor).
834        assert!(range("12,-3", 8).unwrap_err().contains("only 8 lines"));
835        // `8,-3` → [6,8] → fine; high bound already within EOF.
836        assert_eq!(range("8,-3", 8), Ok((6, 8)));
837    }
838
839    #[test]
840    fn open_ended_start_runs_to_eof() {
841        assert_eq!(range("4,", 8), Ok((4, 8)));
842    }
843
844    #[test]
845    fn bare_start_runs_to_eof() {
846        // `git blame -L 3` → 3..EOF.
847        assert_eq!(range("3", 8), Ok((3, 8)));
848    }
849
850    #[test]
851    fn open_ended_end_starts_at_one() {
852        assert_eq!(range(",3", 8), Ok((1, 3)));
853    }
854
855    #[test]
856    fn inverted_range_is_swapped() {
857        // `git blame -L 5,2` → 2..5.
858        assert_eq!(range("5,2", 8), Ok((2, 5)));
859    }
860
861    #[test]
862    fn end_past_eof_is_clamped() {
863        assert_eq!(range("3,99", 8), Ok((3, 8)));
864    }
865
866    #[test]
867    fn start_past_eof_errors() {
868        let err = range("99,100", 8).unwrap_err();
869        assert!(err.contains("only 8 lines"), "got {err:?}");
870    }
871
872    #[test]
873    fn empty_file_message_is_git_faithful_for_every_form() {
874        // git validates explicit line-number tokens *before* the
875        // line-count check, so on an empty file the "has only 0 lines"
876        // message applies only to forms without an explicit zero; an
877        // explicit `0` (`,0` / `3,0`) still reports the invalid-zero error
878        // first. Both halves are pinned against real git.
879        for spec in ["1,", "3", "1,3", "2,5"] {
880            let err = super::parse_line_range(spec, 0, "empty.txt").unwrap_err();
881            assert_eq!(err, "file empty.txt has only 0 lines", "spec {spec:?}");
882        }
883        for spec in [",0", "3,0"] {
884            let err = super::parse_line_range(spec, 0, "empty.txt").unwrap_err();
885            assert_eq!(err, "-L invalid line number: 0", "spec {spec:?}");
886        }
887    }
888
889    #[test]
890    fn zero_start_errors() {
891        assert_eq!(range("0,5", 8).unwrap_err(), "-L invalid line number: 0");
892    }
893
894    #[test]
895    fn zero_line_number_uses_git_message() {
896        // Regression: `,0` defaults start to 1, parses end 0, then the old
897        // inverted-range swap yielded start == 0 and panicked. git reports
898        // `-L invalid line number: 0` (exact word order) for every form
899        // carrying an explicit zero.
900        for spec in [",0", "3,0", "0,0", "0", "0,"] {
901            assert_eq!(
902                range(spec, 8).unwrap_err(),
903                "-L invalid line number: 0",
904                "spec {spec:?}"
905            );
906        }
907    }
908
909    #[test]
910    fn negative_line_number_uses_git_message() {
911        // A parseable-but-invalid integer reports its token, like git's
912        // `-L invalid line number: -3` (negatives only valid as `-<n>`
913        // *offsets*, handled separately).
914        assert_eq!(range("-3,5", 8).unwrap_err(), "-L invalid line number: -3");
915    }
916
917    #[test]
918    fn zero_offset_is_invalid_empty_range() {
919        // git: `+0` / `-0` → `-L invalid empty range`.
920        assert_eq!(range("3,+0", 8).unwrap_err(), "-L invalid empty range");
921        assert_eq!(range("3,-0", 8).unwrap_err(), "-L invalid empty range");
922    }
923
924    #[test]
925    fn non_numeric_errors() {
926        // True junk keeps mkit's clearer message (git dumps usage here).
927        assert!(range("a,b", 8).is_err());
928        assert!(range("3,+x", 8).is_err());
929    }
930}