use std::io::Write;
use clap::{Parser, ValueEnum};
use mkit_core::hash::Hash;
use mkit_core::object::Object;
use mkit_core::refs::{self, Head};
use mkit_core::store::ObjectStore;
use crate::clap_shim;
use crate::exit;
use crate::format;
use crate::signal;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Format {
Default,
Json,
}
#[derive(Debug, Parser)]
#[command(
name = "mkit reflog",
about = "Show a branch's recorded movement history (read-only).",
disable_version_flag = true
)]
struct ReflogOpts {
#[arg(value_name = "REF")]
reference: Option<String>,
#[arg(long, value_enum)]
format: Option<Format>,
#[arg(short = 'n')]
limit: Option<usize>,
}
#[must_use]
pub fn run(args: &[String]) -> u8 {
let opts = match clap_shim::parse::<ReflogOpts>("mkit reflog", args) {
Ok(o) => o,
Err(code) => return code,
};
let fmt = opts.format.unwrap_or(Format::Default);
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 branch = match resolve_branch(&layout, opts.reference.as_deref()) {
Ok(b) => b,
Err((m, c)) => return emit_err(&m, c),
};
let tip = match refs::read_ref(&layout, &branch) {
Ok(Some(h)) => h,
Ok(None) => {
if matches!(fmt, Format::Default) {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "no history for '{branch}': no commits yet");
}
return exit::OK;
}
Err(e) => return emit_err(&format!("read ref '{branch}': {e}"), exit::DATAERR),
};
let chain = match collect_chain(&store, tip) {
Ok(c) => c,
Err((m, c)) => return emit_err(&m, c),
};
let journal = open_journal(&layout, &branch);
let mut stdout = std::io::stdout().lock();
if let Format::Default = fmt
&& let Some(j) = &journal
&& let Some(summary) = j.summary_line(&branch)
{
let _ = writeln!(stdout, "{summary}");
}
for (i, &commit) in chain.iter().enumerate() {
if signal::is_shutdown() {
return exit::TEMPFAIL;
}
if let Some(lim) = opts.limit
&& i >= lim
{
break;
}
let selector = i;
let verified = journal.as_ref().map(|j| j.verify_present(&commit));
let obj = match store.read_object(&commit) {
Ok(o) => o,
Err(e) => {
return emit_err(
&format!("read {}: {e}", format::hex_hash(&commit)),
exit::DATAERR,
);
}
};
let title = match &obj {
Object::Commit(c) => first_line(&c.message),
Object::Remix(r) => first_line(&r.message),
_ => {
return emit_err(
&format!("not a commit: {}", format::hex_hash(&commit)),
exit::DATAERR,
);
}
};
match fmt {
Format::Default => {
let mark = match verified {
Some(true) => " [journaled]",
Some(false) => " [not journaled]",
None => "",
};
let _ = writeln!(
stdout,
"{} {}@{{{selector}}}: {title}{mark}",
format::short_hash(&commit, 8),
branch,
);
}
Format::Json => {
emit_json_entry(&mut stdout, &branch, selector, &commit, &title, verified);
}
}
}
exit::OK
}
fn emit_json_entry(
out: &mut impl Write,
branch: &str,
index: usize,
hash: &Hash,
title: &str,
verified: Option<bool>,
) {
let _ = out.write_all(b"{");
let _ = write!(out, "\"ref\":\"{}\"", format::json_escape(branch));
let _ = write!(
out,
",\"selector\":\"{}@{{{index}}}\"",
format::json_escape(branch)
);
let _ = write!(out, ",\"index\":{index}");
let _ = write!(out, ",\"hash\":\"{}\"", format::hex_hash(hash));
let _ = write!(out, ",\"title\":\"{}\"", format::json_escape(title));
match verified {
Some(b) => {
let _ = write!(out, ",\"journaled\":{b}");
}
None => {
let _ = out.write_all(b",\"journaled\":null");
}
}
let _ = out.write_all(b"}\n");
}
fn resolve_branch(
layout: &mkit_core::layout::RepoLayout,
explicit: Option<&str>,
) -> Result<String, (String, u8)> {
if let Some(name) = explicit {
return Ok(name.to_owned());
}
match refs::read_head(layout) {
Ok(Head::Branch(name)) => Ok(name),
Ok(Head::Detached(_)) => Err((
"HEAD is detached; pass an explicit <ref> (the ref-history journal is per-branch)"
.to_owned(),
exit::USAGE,
)),
Err(e) => Err((format!("read HEAD: {e}"), exit::DATAERR)),
}
}
fn collect_chain(store: &ObjectStore, tip: Hash) -> Result<Vec<Hash>, (String, u8)> {
let mut chain = Vec::new();
let mut cursor = Some(tip);
while let Some(h) = cursor {
chain.push(h);
let parent = match store.read_object(&h) {
Ok(Object::Commit(c)) => c.parents.first().copied(),
Ok(Object::Remix(r)) => r.parents.first().copied(),
Ok(_) => {
return Err((
format!("not a commit: {}", format::hex_hash(&h)),
exit::DATAERR,
));
}
Err(e) => {
return Err((format!("read {}: {e}", format::hex_hash(&h)), exit::DATAERR));
}
};
cursor = parent;
}
Ok(chain)
}
fn first_line(message: &[u8]) -> String {
String::from_utf8_lossy(message)
.lines()
.next()
.unwrap_or("")
.to_owned()
}
use super::error as emit_err;
#[cfg(feature = "history-mmr")]
struct Journal {
recorded_advances: u64,
root: Hash,
history: mkit_core::history::CommitHistory<mkit_core::history::TokioExecutor>,
}
#[cfg(feature = "history-mmr")]
impl Journal {
#[allow(clippy::unnecessary_wraps)]
fn summary_line(&self, branch: &str) -> Option<String> {
Some(format!(
"# journal: {} recorded advance(s) on '{branch}', root {}",
self.recorded_advances,
format::short_hash(&self.root, 8)
))
}
fn verify_present(&self, commit: &Hash) -> bool {
let mut position = self.recorded_advances;
while position > 0 {
position -= 1;
let pos = mkit_core::history::Position(position);
let Ok(proof) = self.history.prove(pos) else {
continue;
};
if mkit_core::history::verify_inclusion(commit, pos, &proof, &self.root) {
return true;
}
}
false
}
}
#[cfg(feature = "history-mmr")]
fn open_journal(layout: &mkit_core::layout::RepoLayout, branch: &str) -> Option<Journal> {
let exec = super::history_executor();
let history = mkit_core::history::CommitHistory::open_at(exec, layout, branch).ok()?;
Some(Journal {
recorded_advances: history.len(),
root: history.root(),
history,
})
}
#[cfg(not(feature = "history-mmr"))]
struct Journal;
#[cfg(not(feature = "history-mmr"))]
impl Journal {
#[allow(clippy::unused_self)]
fn summary_line(&self, _branch: &str) -> Option<String> {
None
}
#[allow(clippy::unused_self)]
fn verify_present(&self, _commit: &Hash) -> bool {
false
}
}
#[cfg(not(feature = "history-mmr"))]
fn open_journal(_layout: &mkit_core::layout::RepoLayout, _branch: &str) -> Option<Journal> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_line_takes_title_only() {
assert_eq!(first_line(b"title\n\nbody"), "title");
assert_eq!(first_line(b"only"), "only");
assert_eq!(first_line(b""), "");
}
#[test]
fn json_entry_shape_default_build_is_null_journaled() {
let mut buf = Vec::new();
emit_json_entry(&mut buf, "main", 0, &[0xab; 32], "hello", None);
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("\"ref\":\"main\""));
assert!(s.contains("\"selector\":\"main@{0}\""));
assert!(s.contains("\"index\":0"));
assert!(s.contains("\"journaled\":null"));
assert!(s.ends_with("}\n"));
}
#[test]
fn json_entry_journaled_true_renders_bool() {
let mut buf = Vec::new();
emit_json_entry(&mut buf, "dev", 3, &[0x01; 32], "t", Some(true));
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("\"selector\":\"dev@{3}\""));
assert!(s.contains("\"journaled\":true"));
}
}