use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::sync::Arc;
use clap::{Parser, ValueEnum};
use mkit_core::hash::{self, Hash};
use mkit_core::layout::RepoLayout;
use mkit_core::ops::blame::{
BlameOptions, BlameResult, CopyDetection, MoveDetection, blame_file_reverse, blame_file_with,
format_blame_text,
};
use mkit_core::refs;
use mkit_core::store::ObjectStore;
use super::revspec;
use crate::clap_shim;
use crate::exit;
use crate::format;
#[derive(Debug, Clone, Copy, ValueEnum)]
enum BlameFormat {
Default,
Json,
}
#[derive(Debug, Parser)]
#[command(
name = "mkit blame",
about = "Show line-level commit attribution.",
override_usage = "mkit blame [OPTIONS] [<rev>] [--] <file>"
)]
#[allow(clippy::struct_excessive_bools)]
struct BlameOpts {
#[arg(long, value_enum, default_value = "default")]
format: BlameFormat,
#[arg(long = "porcelain", conflicts_with = "format")]
porcelain: bool,
#[arg(long = "line-porcelain", conflicts_with = "format")]
line_porcelain: bool,
#[arg(short = 'w', long = "ignore-whitespace")]
ignore_whitespace: bool,
#[arg(
short = 'L',
long = "lines",
value_name = "START,END",
allow_hyphen_values = true
)]
lines: Option<String>,
#[arg(short = 'M', long = "find-moves")]
find_moves: bool,
#[arg(short = 'C', long = "find-copies", action = clap::ArgAction::Count)]
find_copies: u8,
#[arg(long = "ignore-rev", value_name = "REV")]
ignore_rev: Vec<String>,
#[arg(long = "ignore-revs-file", value_name = "FILE")]
ignore_revs_file: Vec<String>,
#[arg(long = "ignore-rev-precise")]
ignore_rev_precise: bool,
#[arg(long = "reverse")]
reverse: bool,
#[arg(long = "first-parent")]
first_parent: bool,
#[arg(value_name = "REV/FILE", num_args = 1..=2, required = true)]
rev_and_file: Vec<String>,
}
#[must_use]
#[allow(clippy::too_many_lines)] pub fn run(args: &[String]) -> u8 {
let (clap_args, inline) = extract_inline_thresholds(args);
let opts = match clap_shim::parse::<BlameOpts>("mkit blame", &clap_args) {
Ok(o) => o,
Err(code) => return code,
};
let json = matches!(opts.format, BlameFormat::Json);
let find_moves = opts.find_moves || inline.moves;
let find_copies = opts.find_copies.saturating_add(inline.copies);
let (rev_spec, file) = match opts.rev_and_file.as_slice() {
[file] => (None, file),
[rev, file] => (Some(rev), file),
_ => return emit_err("expected [<rev>] <file>", exit::USAGE),
};
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
};
let layout = match super::resolve_layout(&cwd) {
Ok(layout) => layout,
Err(code) => return code,
};
let store = match ObjectStore::open(&layout) {
Ok(s) => s,
Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
};
if let Err((msg, code)) = check_flag_conflicts(&opts, find_moves, find_copies) {
return emit_err(&msg, code);
}
let moves = if find_moves {
match inline.move_threshold {
Some(threshold) => MoveDetection::On { threshold },
None => MoveDetection::GIT_DEFAULT,
}
} else {
MoveDetection::Off
};
let copies = if find_copies > 0 {
match inline.copy_threshold {
Some(threshold) => CopyDetection::On {
level: find_copies,
threshold,
},
None => CopyDetection::git_default(find_copies),
}
} else {
CopyDetection::Off
};
let ignore_revs = match collect_ignore_revs(&store, &layout, &opts) {
Ok(set) => Arc::new(set),
Err((msg, code)) => return emit_err(&msg, code),
};
let blame_opts = BlameOptions {
ignore_whitespace: opts.ignore_whitespace,
moves,
copies,
ignore_revs,
ignore_rev_precise: opts.ignore_rev_precise,
first_parent: opts.first_parent,
};
let result = if opts.reverse {
let (start, end) = match resolve_reverse_range(&store, &layout, rev_spec, file) {
Ok(pair) => pair,
Err((msg, code)) => return emit_err(&msg, code),
};
match blame_file_reverse(&store, start, end, file, &blame_opts) {
Ok(r) => r,
Err(e) => return emit_err(&format!("blame: {e}"), exit::NOINPUT),
}
} else {
let head = if let Some(spec) = rev_spec {
match revspec::resolve_revision(&store, &layout, spec) {
Ok(h) => h,
Err(e) => return emit_err(&format!("{e}"), exit::NOINPUT),
}
} else {
match refs::resolve_head(&layout) {
Ok(Some(h)) => h,
Ok(None) => return emit_err("no commits yet", exit::GENERAL_ERROR),
Err(e) => return emit_err(&format!("resolve HEAD: {e}"), exit::GENERAL_ERROR),
}
};
match blame_file_with(&store, head, file, &blame_opts) {
Ok(r) => r,
Err(e) => return emit_err(&format!("blame: {e}"), exit::NOINPUT),
}
};
let result = match &opts.lines {
Some(spec) => match parse_line_range(spec, result.lines.len(), file) {
Ok((start, end)) => BlameResult {
lines: result.lines[start - 1..end].to_vec(),
},
Err(msg) => return emit_err(&msg, exit::USAGE),
},
None => result,
};
if opts.porcelain || opts.line_porcelain {
render_porcelain(&store, &result, file, opts.line_porcelain)
} else if json {
render_json(&result)
} else {
let text = format_blame_text(&result);
let mut stdout = std::io::stdout().lock();
let _ = stdout.write_all(text.as_bytes());
exit::OK
}
}
fn check_flag_conflicts(
opts: &BlameOpts,
find_moves: bool,
find_copies: u8,
) -> Result<(), (String, u8)> {
if opts.reverse
&& (find_moves
|| find_copies > 0
|| !opts.ignore_rev.is_empty()
|| !opts.ignore_revs_file.is_empty())
{
return Err((
"--reverse cannot be combined with -M/-C or --ignore-rev/--ignore-revs-file"
.to_string(),
exit::USAGE,
));
}
if opts.ignore_rev_precise && opts.ignore_rev.is_empty() && opts.ignore_revs_file.is_empty() {
return Err((
"--ignore-rev-precise requires --ignore-rev or --ignore-revs-file".to_string(),
exit::USAGE,
));
}
Ok(())
}
#[derive(Default)]
struct InlineThresholds {
moves: bool,
move_threshold: Option<usize>,
copies: u8,
copy_threshold: Option<usize>,
}
fn extract_inline_thresholds(args: &[String]) -> (Vec<String>, InlineThresholds) {
let mut rest = Vec::with_capacity(args.len());
let mut out = InlineThresholds::default();
let mut opts_ended = false;
for arg in args {
if opts_ended {
rest.push(arg.clone());
continue;
}
if arg == "--" {
opts_ended = true;
rest.push(arg.clone());
continue;
}
if let Some(t) = arg.strip_prefix("-M").and_then(parse_threshold) {
out.move_threshold = Some(t);
out.moves = true;
} else if let Some(t) = arg.strip_prefix("-C").and_then(parse_threshold) {
out.copy_threshold = Some(t);
out.copies = out.copies.saturating_add(1);
} else {
rest.push(arg.clone());
}
}
(rest, out)
}
fn parse_threshold(val: &str) -> Option<usize> {
let num = val.strip_suffix('%').unwrap_or(val);
if num.is_empty() || !num.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(num.parse::<usize>().unwrap_or(usize::MAX))
}
fn collect_ignore_revs(
store: &ObjectStore,
layout: &RepoLayout,
opts: &BlameOpts,
) -> Result<HashSet<Hash>, (String, u8)> {
let mut set = HashSet::new();
for spec in &opts.ignore_rev {
match revspec::resolve_revision(store, layout, spec) {
Ok(h) => {
set.insert(h);
}
Err(_) => {
return Err((
format!("cannot find revision {spec} to ignore"),
exit::DATAERR,
));
}
}
}
for path in &opts.ignore_revs_file {
let contents = std::fs::read_to_string(path).map_err(|_| {
(
format!("could not open object name list: {path}"),
exit::NOINPUT,
)
})?;
for raw in contents.lines() {
let line = raw.split('#').next().unwrap_or("").trim();
if line.is_empty() {
continue;
}
let h = hash::from_hex(line)
.map_err(|_| (format!("invalid object name: {line}"), exit::DATAERR))?;
set.insert(h);
}
}
Ok(set)
}
fn resolve_reverse_range(
store: &ObjectStore,
layout: &RepoLayout,
rev_spec: Option<&String>,
file: &str,
) -> Result<(Hash, Hash), (String, u8)> {
let Some(spec) = rev_spec else {
if file.contains("..") {
return Err((
format!(
"--reverse: missing <file> (got only '{file}', which looks like the range)"
),
exit::USAGE,
));
}
return Err((
"--reverse requires a <start>..<end> revision range".to_string(),
exit::USAGE,
));
};
let Some((start_str, end_str)) = spec.split_once("..") else {
return Err((
format!("--reverse requires a <start>..<end> range, got '{spec}'"),
exit::USAGE,
));
};
if end_str.starts_with('.') || end_str.contains("..") {
return Err((
format!("--reverse requires a single <start>..<end> range, got '{spec}'"),
exit::USAGE,
));
}
if start_str.is_empty() {
return Err((
"--reverse requires an explicit <start> revision".to_string(),
exit::USAGE,
));
}
let start = revspec::resolve_revision(store, layout, start_str)
.map_err(|e| (format!("{e}"), exit::NOINPUT))?;
let end = if end_str.is_empty() {
match refs::resolve_head(layout) {
Ok(Some(h)) => h,
Ok(None) => return Err(("no commits yet".to_string(), exit::GENERAL_ERROR)),
Err(e) => return Err((format!("resolve HEAD: {e}"), exit::GENERAL_ERROR)),
}
} else {
revspec::resolve_revision(store, layout, end_str)
.map_err(|e| (format!("{e}"), exit::NOINPUT))?
};
if start == end {
return Err((
format!("--reverse: empty revision range '{spec}'"),
exit::USAGE,
));
}
Ok((start, end))
}
fn parse_line_range(spec: &str, total: usize, file: &str) -> Result<(usize, usize), String> {
let (start_tok, end_tok) = match spec.split_once(',') {
Some((s, e)) => (s.trim(), Some(e.trim())),
None => (spec.trim(), None),
};
let start = if start_tok.is_empty() {
1
} else {
parse_one_based(start_tok)?
};
let (lo, hi) = match end_tok {
None | Some("") => (start, total),
Some(tok) if tok.starts_with('+') => (start, start.saturating_add(parse_offset(tok)? - 1)),
Some(tok) if tok.starts_with('-') => {
let n = parse_offset(tok)?;
(start.saturating_sub(n - 1).max(1), start)
}
Some(tok) => {
let m = parse_one_based(tok)?;
if start > m { (m, start) } else { (start, m) }
}
};
if total == 0 {
return Err(format!("file {file} has only 0 lines"));
}
if lo > total {
return Err(format!("file {file} has only {total} lines"));
}
Ok((lo, hi.min(total)))
}
fn parse_line_num(tok: &str) -> Result<usize, String> {
tok.parse::<usize>()
.map_err(|_| format!("invalid line number '{tok}' in -L range"))
}
fn parse_one_based(tok: &str) -> Result<usize, String> {
match tok.parse::<usize>() {
Ok(n) if n >= 1 => Ok(n),
_ if tok.parse::<i64>().is_ok() => Err(format!("-L invalid line number: {tok}")),
_ => Err(format!("invalid line number '{tok}' in -L range")),
}
}
fn parse_offset(tok: &str) -> Result<usize, String> {
let n = parse_line_num(&tok[1..])?;
if n == 0 {
return Err("-L invalid empty range".to_string());
}
Ok(n)
}
fn render_json(result: &BlameResult) -> u8 {
let mut stdout = std::io::stdout().lock();
for line in &result.lines {
let _ = stdout.write_all(b"{");
let _ = write!(
stdout,
"\"hash\":\"{}\"",
format::hex_hash(&line.commit_hash)
);
let _ = write!(stdout, ",\"line_num\":{}", line.line_num);
let _ = write!(
stdout,
",\"author\":\"{}\"",
format::json_escape(&format::full_identity(&line.author))
);
let _ = write!(stdout, ",\"timestamp\":{}", line.timestamp);
let text = String::from_utf8_lossy(&line.text);
let _ = write!(stdout, ",\"text\":\"{}\"", format::json_escape(&text));
let _ = stdout.write_all(b"}\n");
}
exit::OK
}
fn render_porcelain(
store: &ObjectStore,
result: &BlameResult,
file: &str,
line_porcelain: bool,
) -> u8 {
let mut summaries: HashMap<Hash, String> = HashMap::new();
let mut seen: HashSet<Hash> = HashSet::new();
let lines = &result.lines;
let mut stdout = std::io::stdout().lock();
let mut i = 0;
while i < lines.len() {
let commit = lines[i].commit_hash;
let mut group_len = 1;
while i + group_len < lines.len() && lines[i + group_len].commit_hash == commit {
group_len += 1;
}
for g in 0..group_len {
let line = &lines[i + g];
let hex = format::hex_hash(&line.commit_hash);
if g == 0 {
let _ = writeln!(
stdout,
"{hex} {} {} {group_len}",
line.orig_line_num, line.line_num
);
} else {
let _ = writeln!(stdout, "{hex} {} {}", line.orig_line_num, line.line_num);
}
let emit_meta = line_porcelain || seen.insert(line.commit_hash);
if emit_meta {
let ident = format::full_identity(&line.author);
let summary = summaries
.entry(line.commit_hash)
.or_insert_with(|| super::commit_subject(store, &line.commit_hash));
let _ = writeln!(stdout, "author {ident}");
let _ = writeln!(stdout, "author-mail <>");
let _ = writeln!(stdout, "author-time {}", line.timestamp);
let _ = writeln!(stdout, "author-tz +0000");
let _ = writeln!(stdout, "committer {ident}");
let _ = writeln!(stdout, "committer-mail <>");
let _ = writeln!(stdout, "committer-time {}", line.timestamp);
let _ = writeln!(stdout, "committer-tz +0000");
let _ = writeln!(stdout, "summary {summary}");
if line.boundary {
let _ = writeln!(stdout, "boundary");
}
let filename = line.source_path.as_deref().unwrap_or(file);
let _ = writeln!(stdout, "filename {filename}");
}
let _ = stdout.write_all(b"\t");
let _ = stdout.write_all(&line.text);
let _ = stdout.write_all(b"\n");
}
i += group_len;
}
exit::OK
}
use super::error as emit_err;
#[cfg(test)]
mod tests {
fn range(spec: &str, total: usize) -> Result<(usize, usize), String> {
super::parse_line_range(spec, total, "f.txt")
}
#[test]
fn explicit_range_is_inclusive() {
assert_eq!(range("3,5", 8), Ok((3, 5)));
}
#[test]
fn plus_n_is_n_lines_from_start() {
assert_eq!(range("3,+2", 8), Ok((3, 4)));
assert_eq!(range("1,+1", 8), Ok((1, 1)));
}
#[test]
fn minus_n_is_n_lines_ending_at_start() {
assert_eq!(range("5,-2", 8), Ok((4, 5)));
assert_eq!(range("8,-3", 8), Ok((6, 8)));
assert_eq!(range("3,-1", 8), Ok((3, 3)));
assert_eq!(range("2,-5", 8), Ok((1, 2))); }
#[test]
fn minus_n_anchor_past_eof_still_validates_low_bound() {
assert!(range("12,-3", 8).unwrap_err().contains("only 8 lines"));
assert_eq!(range("8,-3", 8), Ok((6, 8)));
}
#[test]
fn open_ended_start_runs_to_eof() {
assert_eq!(range("4,", 8), Ok((4, 8)));
}
#[test]
fn bare_start_runs_to_eof() {
assert_eq!(range("3", 8), Ok((3, 8)));
}
#[test]
fn open_ended_end_starts_at_one() {
assert_eq!(range(",3", 8), Ok((1, 3)));
}
#[test]
fn inverted_range_is_swapped() {
assert_eq!(range("5,2", 8), Ok((2, 5)));
}
#[test]
fn end_past_eof_is_clamped() {
assert_eq!(range("3,99", 8), Ok((3, 8)));
}
#[test]
fn start_past_eof_errors() {
let err = range("99,100", 8).unwrap_err();
assert!(err.contains("only 8 lines"), "got {err:?}");
}
#[test]
fn empty_file_message_is_git_faithful_for_every_form() {
for spec in ["1,", "3", "1,3", "2,5"] {
let err = super::parse_line_range(spec, 0, "empty.txt").unwrap_err();
assert_eq!(err, "file empty.txt has only 0 lines", "spec {spec:?}");
}
for spec in [",0", "3,0"] {
let err = super::parse_line_range(spec, 0, "empty.txt").unwrap_err();
assert_eq!(err, "-L invalid line number: 0", "spec {spec:?}");
}
}
#[test]
fn zero_start_errors() {
assert_eq!(range("0,5", 8).unwrap_err(), "-L invalid line number: 0");
}
#[test]
fn zero_line_number_uses_git_message() {
for spec in [",0", "3,0", "0,0", "0", "0,"] {
assert_eq!(
range(spec, 8).unwrap_err(),
"-L invalid line number: 0",
"spec {spec:?}"
);
}
}
#[test]
fn negative_line_number_uses_git_message() {
assert_eq!(range("-3,5", 8).unwrap_err(), "-L invalid line number: -3");
}
#[test]
fn zero_offset_is_invalid_empty_range() {
assert_eq!(range("3,+0", 8).unwrap_err(), "-L invalid empty range");
assert_eq!(range("3,-0", 8).unwrap_err(), "-L invalid empty range");
}
#[test]
fn non_numeric_errors() {
assert!(range("a,b", 8).is_err());
assert!(range("3,+x", 8).is_err());
}
}