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}