use std::cmp::Ordering;
use std::collections::{BinaryHeap, HashMap, HashSet};
use std::io::Write;
use clap::{Parser, ValueEnum};
use mkit_core::Hash;
use mkit_core::layout::RepoLayout;
use mkit_core::object::{Commit, Object};
use mkit_core::ops::graph::collect_ancestor_set;
use mkit_core::ops::merge::find_merge_base;
use mkit_core::refs;
use mkit_core::store::ObjectStore;
use super::revspec;
use crate::clap_shim;
use crate::exit;
use crate::format;
use crate::signal;
mod dateparse;
const DEFAULT_ABBREV: usize = 7;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Format {
Default,
Oneline,
Json,
}
#[derive(Debug, Parser)]
#[command(
name = "mkit log",
about = "Show commit history.",
disable_help_flag = false,
disable_version_flag = true
)]
#[allow(clippy::struct_excessive_bools)] struct LogOpts {
#[arg(long)]
oneline: bool,
#[arg(long, value_enum)]
format: Option<Format>,
#[arg(short = 'n')]
limit: Option<usize>,
#[arg(long = "abbrev-commit")]
abbrev_commit: bool,
#[arg(long, value_name = "N", num_args = 0..=1, default_missing_value = "7")]
abbrev: Option<usize>,
#[arg(long)]
graph: bool,
#[arg(long, value_name = "PATTERN")]
author: Option<String>,
#[arg(long, value_name = "PATTERN")]
grep: Option<String>,
#[arg(long, value_name = "DATE")]
since: Option<String>,
#[arg(long, value_name = "DATE")]
until: Option<String>,
#[arg(long = "no-merges")]
no_merges: bool,
#[arg(long = "first-parent")]
first_parent: bool,
start: Option<String>,
}
impl LogOpts {
fn render_format(&self) -> Format {
match self.format {
Some(f) => f,
None if self.oneline => Format::Oneline,
None => Format::Default,
}
}
fn abbrev_len(&self) -> Option<usize> {
if let Some(n) = self.abbrev {
return Some(n);
}
if self.abbrev_commit || self.render_format() == Format::Oneline {
return Some(DEFAULT_ABBREV);
}
None
}
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<LogOpts>("mkit log", args) {
Ok(o) => o,
Err(code) => return code,
};
let fmt = opts.render_format();
let abbrev = opts.abbrev_len();
let _ = opts.graph;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let since = match opts.since.as_deref() {
Some(s) => match dateparse::parse_date(s, now) {
Ok(t) => Some(t),
Err(msg) => return emit_err(&format!("--since: {msg}"), exit::USAGE),
},
None => None,
};
let until = match opts.until.as_deref() {
Some(s) => match dateparse::parse_date(s, now) {
Ok(t) => Some(t),
Err(msg) => return emit_err(&format!("--until: {msg}"), exit::USAGE),
},
None => None,
};
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),
};
let selection = parse_rev_arg(opts.start.as_deref());
let (tips, excluded) = match resolve_selection(&store, &layout, &selection) {
Ok(Some(v)) => v,
Ok(None) => {
if opts.start.is_none() && matches!(fmt, Format::Default | Format::Oneline) {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "no commits yet");
}
return exit::OK;
}
Err(msg) => return emit_err(&msg, exit::DATAERR),
};
let ordered = match ordered_commits_opts(&store, &tips, &excluded, opts.first_parent) {
Ok(v) => v,
Err(code) => return code,
};
let mut stdout = std::io::stdout().lock();
let limit = opts.limit.unwrap_or(usize::MAX);
let mut shown = 0usize;
for (hash, c) in &ordered {
if signal::is_shutdown() {
return exit::TEMPFAIL;
}
if shown >= limit {
break;
}
if !commit_matches(
c,
opts.author.as_deref(),
opts.grep.as_deref(),
since,
until,
opts.no_merges,
) {
continue;
}
render_commit(&mut stdout, fmt, abbrev, hash, c);
shown += 1;
}
exit::OK
}
fn commit_matches(
c: &Commit,
author: Option<&str>,
grep: Option<&str>,
since: Option<u64>,
until: Option<u64>,
no_merges: bool,
) -> bool {
if no_merges && c.parents.len() > 1 {
return false;
}
if since.is_some_and(|s| c.timestamp < s) {
return false;
}
if until.is_some_and(|u| c.timestamp > u) {
return false;
}
if let Some(pat) = author
&& !(format::short_identity(&c.author).contains(pat)
|| format::full_identity(&c.author).contains(pat))
{
return false;
}
if let Some(pat) = grep {
let msg = String::from_utf8_lossy(&c.message);
if !msg.contains(pat) {
return false;
}
}
true
}
type WalkSet = (Vec<Hash>, HashSet<Hash>);
enum RevSelection {
Default,
Single(String),
Range { exclude: String, include: String },
Symmetric { a: String, b: String },
}
fn parse_rev_arg(arg: Option<&str>) -> RevSelection {
let Some(s) = arg else {
return RevSelection::Default;
};
let to_spec = |side: &str| {
if side.is_empty() {
"HEAD".to_string()
} else {
side.to_string()
}
};
if let Some((a, b)) = s.split_once("...") {
return RevSelection::Symmetric {
a: to_spec(a),
b: to_spec(b),
};
}
if let Some((a, b)) = s.split_once("..") {
return RevSelection::Range {
exclude: to_spec(a),
include: to_spec(b),
};
}
RevSelection::Single(s.to_string())
}
fn resolve_selection(
store: &ObjectStore,
layout: &RepoLayout,
sel: &RevSelection,
) -> Result<Option<WalkSet>, String> {
let mut excluded: HashSet<Hash> = HashSet::new();
let tips: Vec<Hash> = match sel {
RevSelection::Default => match resolve_tip(store, layout, None)? {
Some(h) => vec![h],
None => return Ok(None),
},
RevSelection::Single(spec) => match resolve_tip(store, layout, Some(spec))? {
Some(h) => vec![h],
None => return Ok(None),
},
RevSelection::Range { exclude, include } => {
let Some(inc) = resolve_tip(store, layout, Some(include))? else {
return Ok(None);
};
if let Some(a) = resolve_tip(store, layout, Some(exclude))? {
collect_ancestor_set(store, a, &mut excluded)
.map_err(|e| format!("walk range base: {e}"))?;
}
vec![inc]
}
RevSelection::Symmetric { a, b } => {
let ra = resolve_tip(store, layout, Some(a))?;
let rb = resolve_tip(store, layout, Some(b))?;
if let (Some(x), Some(y)) = (ra, rb)
&& let Some(mb) =
find_merge_base(store, x, y).map_err(|e| format!("merge base: {e}"))?
{
collect_ancestor_set(store, mb, &mut excluded)
.map_err(|e| format!("walk merge base: {e}"))?;
}
let tips: Vec<Hash> = ra.into_iter().chain(rb).collect();
if tips.is_empty() {
return Ok(None);
}
tips
}
};
Ok(Some((tips, excluded)))
}
fn resolve_tip(
store: &ObjectStore,
layout: &RepoLayout,
spec: Option<&str>,
) -> Result<Option<Hash>, String> {
let raw = match spec {
None | Some("HEAD") => refs::resolve_head(layout).ok().flatten(),
Some(s) => Some(
revspec::resolve_revision(store, layout, s)
.map_err(|e| format!("bad revision '{s}': {e}"))?,
),
};
Ok(raw.map(|h| peel_tags(store, h)))
}
const MAX_TAG_DEPTH: usize = 16;
pub(super) fn peel_tags(store: &ObjectStore, mut h: Hash) -> Hash {
for _ in 0..MAX_TAG_DEPTH {
match store.read_object(&h) {
Ok(Object::Tag(t)) => h = t.target,
_ => break,
}
}
h
}
struct HeapItem {
timestamp: u64,
hash: Hash,
}
impl Ord for HeapItem {
fn cmp(&self, other: &Self) -> Ordering {
self.timestamp
.cmp(&other.timestamp)
.then_with(|| self.hash.cmp(&other.hash))
}
}
impl PartialOrd for HeapItem {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for HeapItem {
fn eq(&self, other: &Self) -> bool {
self.cmp(other) == Ordering::Equal
}
}
impl Eq for HeapItem {}
const MAX_LOG_COMMITS: usize = 1_000_000;
pub(super) fn ordered_commits(
store: &ObjectStore,
tips: &[Hash],
excluded: &HashSet<Hash>,
) -> Result<Vec<(Hash, Commit)>, u8> {
ordered_commits_opts(store, tips, excluded, false)
}
pub(super) fn ordered_commits_opts(
store: &ObjectStore,
tips: &[Hash],
excluded: &HashSet<Hash>,
first_parent: bool,
) -> Result<Vec<(Hash, Commit)>, u8> {
let mut commits: HashMap<Hash, Commit> = HashMap::new();
let mut stack: Vec<Hash> = tips.to_vec();
while let Some(h) = stack.pop() {
if excluded.contains(&h) || commits.contains_key(&h) {
continue;
}
if commits.len() >= MAX_LOG_COMMITS {
break;
}
let c = match store.read_object(&h) {
Ok(Object::Commit(c)) => c,
Ok(_) => {
return Err(emit_err(
&format!("not a commit: {}", format::hex_hash(&h)),
exit::DATAERR,
));
}
Err(e) => {
return Err(emit_err(
&format!("read {}: {e}", format::hex_hash(&h)),
exit::DATAERR,
));
}
};
let parents: &[Hash] = if first_parent {
c.parents.get(..1).unwrap_or(&[])
} else {
&c.parents
};
for p in parents {
if !excluded.contains(p) {
stack.push(*p);
}
}
commits.insert(h, c);
}
let mut indeg: HashMap<Hash, usize> = commits.keys().map(|h| (*h, 0usize)).collect();
for c in commits.values() {
for p in &c.parents {
if let Some(d) = indeg.get_mut(p) {
*d += 1;
}
}
}
let mut heap: BinaryHeap<HeapItem> = BinaryHeap::new();
for (h, c) in &commits {
if indeg[h] == 0 {
heap.push(HeapItem {
timestamp: c.timestamp,
hash: *h,
});
}
}
let mut out: Vec<(Hash, Commit)> = Vec::with_capacity(commits.len());
while let Some(item) = heap.pop() {
let c = commits[&item.hash].clone();
for p in &c.parents {
if let Some(d) = indeg.get_mut(p) {
*d -= 1;
if *d == 0 {
heap.push(HeapItem {
timestamp: commits[p].timestamp,
hash: *p,
});
}
}
}
out.push((item.hash, c));
}
Ok(out)
}
fn render_commit(
out: &mut impl Write,
fmt: Format,
abbrev: Option<usize>,
hash: &Hash,
c: &Commit,
) {
let full_message: String = String::from_utf8_lossy(&c.message).into_owned();
let title = full_message.lines().next().unwrap_or("");
match fmt {
Format::Oneline => {
let id = format::short_hash(hash, abbrev.unwrap_or(DEFAULT_ABBREV));
let _ = writeln!(out, "{id} {title}");
}
Format::Default => {
let id = match abbrev {
Some(n) => format::short_hash(hash, n),
None => format::hex_hash(hash),
};
let _ = writeln!(out, "commit {id}");
let _ = writeln!(out, "Author: {}", format::short_identity(&c.author));
let _ = writeln!(out, "Date: {}", format::human_date_utc(c.timestamp));
let _ = writeln!(out);
for line in full_message.lines() {
if line.is_empty() {
let _ = writeln!(out);
} else {
let _ = writeln!(out, " {line}");
}
}
let _ = writeln!(out);
}
Format::Json => {
emit_json_entry(out, hash, c, title, &full_message);
}
}
}
fn emit_json_entry(
out: &mut impl Write,
hash: &mkit_core::Hash,
c: &mkit_core::object::Commit,
title: &str,
full_message: &str,
) {
let _ = out.write_all(b"{");
let _ = write!(out, "\"hash\":\"{}\"", format::hex_hash(hash));
let _ = out.write_all(b",\"parents\":[");
for (i, p) in c.parents.iter().enumerate() {
if i > 0 {
let _ = out.write_all(b",");
}
let _ = write!(out, "\"{}\"", format::hex_hash(p));
}
let _ = out.write_all(b"]");
let _ = write!(out, ",\"tree\":\"{}\"", format::hex_hash(&c.tree_hash));
let _ = write!(
out,
",\"author\":\"{}\"",
format::json_escape(&format::full_identity(&c.author))
);
let _ = write!(out, ",\"timestamp\":{}", c.timestamp);
let _ = write!(out, ",\"title\":\"{}\"", format::json_escape(title));
let _ = write!(
out,
",\"message\":\"{}\"",
format::json_escape(full_message)
);
let _ = out.write_all(b"}\n");
}
use super::error as emit_err;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_format_explicit_format_wins_over_oneline() {
let opts = LogOpts {
oneline: true,
format: Some(Format::Default),
limit: None,
abbrev_commit: false,
abbrev: None,
graph: false,
author: None,
grep: None,
since: None,
until: None,
no_merges: false,
first_parent: false,
start: None,
};
assert_eq!(opts.render_format(), Format::Default);
}
#[test]
fn render_format_oneline_alone_resolves_to_oneline() {
let opts = LogOpts {
oneline: true,
format: None,
limit: None,
abbrev_commit: false,
abbrev: None,
graph: false,
author: None,
grep: None,
since: None,
until: None,
no_merges: false,
first_parent: false,
start: None,
};
assert_eq!(opts.render_format(), Format::Oneline);
}
#[test]
fn render_format_default_when_no_flags() {
let opts = LogOpts {
oneline: false,
format: None,
limit: None,
abbrev_commit: false,
abbrev: None,
graph: false,
author: None,
grep: None,
since: None,
until: None,
no_merges: false,
first_parent: false,
start: None,
};
assert_eq!(opts.render_format(), Format::Default);
}
#[test]
fn render_format_json_via_format_flag() {
let opts = LogOpts {
oneline: false,
format: Some(Format::Json),
limit: None,
abbrev_commit: false,
abbrev: None,
graph: false,
author: None,
grep: None,
since: None,
until: None,
no_merges: false,
first_parent: false,
start: None,
};
assert_eq!(opts.render_format(), Format::Json);
}
fn opts_for_abbrev(oneline: bool, abbrev_commit: bool, abbrev: Option<usize>) -> LogOpts {
LogOpts {
oneline,
format: None,
limit: None,
abbrev_commit,
abbrev,
graph: false,
author: None,
grep: None,
since: None,
until: None,
no_merges: false,
first_parent: false,
start: None,
}
}
#[test]
fn abbrev_len_off_by_default() {
assert_eq!(opts_for_abbrev(false, false, None).abbrev_len(), None);
}
#[test]
fn abbrev_len_default_for_oneline_and_abbrev_commit() {
assert_eq!(
opts_for_abbrev(true, false, None).abbrev_len(),
Some(DEFAULT_ABBREV)
);
assert_eq!(
opts_for_abbrev(false, true, None).abbrev_len(),
Some(DEFAULT_ABBREV)
);
}
#[test]
fn abbrev_len_explicit_value_wins() {
assert_eq!(
opts_for_abbrev(true, false, Some(12)).abbrev_len(),
Some(12)
);
}
fn commit_for(
parents: Vec<Hash>,
author: mkit_core::object::Identity,
message: &str,
timestamp: u64,
) -> Commit {
Commit::new_unannotated(
mkit_core::hash::ZERO,
parents,
author,
[0u8; 32],
message.as_bytes().to_vec(),
timestamp,
[0u8; 64],
)
}
#[test]
fn commit_matches_no_filters_passes_everything() {
let c = commit_for(
vec![],
mkit_core::object::Identity::opaque(b"alice".to_vec()),
"m",
100,
);
assert!(commit_matches(&c, None, None, None, None, false));
}
#[test]
fn commit_matches_author_is_substring_against_short_and_full_identity() {
let c = commit_for(
vec![],
mkit_core::object::Identity::opaque(b"alice".to_vec()),
"m",
100,
);
assert!(commit_matches(&c, Some("alice"), None, None, None, false));
assert!(!commit_matches(&c, Some("bob"), None, None, None, false));
}
#[test]
fn commit_matches_grep_checks_message_substring() {
let c = commit_for(
vec![],
mkit_core::object::Identity::opaque(b"alice".to_vec()),
"fix: widget overflow",
100,
);
assert!(commit_matches(&c, None, Some("widget"), None, None, false));
assert!(!commit_matches(&c, None, Some("gadget"), None, None, false));
}
#[test]
fn commit_matches_since_until_bound_the_timestamp() {
let c = commit_for(
vec![],
mkit_core::object::Identity::opaque(b"a".to_vec()),
"m",
500,
);
assert!(commit_matches(&c, None, None, Some(500), Some(500), false));
assert!(commit_matches(&c, None, None, Some(499), Some(501), false));
assert!(!commit_matches(&c, None, None, Some(501), None, false));
assert!(!commit_matches(&c, None, None, None, Some(499), false));
}
#[test]
fn commit_matches_no_merges_excludes_multi_parent_commits() {
let solo = commit_for(
vec![],
mkit_core::object::Identity::opaque(b"a".to_vec()),
"m",
1,
);
let merge = commit_for(
vec![mkit_core::hash::ZERO, mkit_core::hash::ZERO],
mkit_core::object::Identity::opaque(b"a".to_vec()),
"m",
1,
);
assert!(commit_matches(&solo, None, None, None, None, true));
assert!(!commit_matches(&merge, None, None, None, None, true));
assert!(commit_matches(&merge, None, None, None, None, false));
}
}