1use std::io::Write;
49
50use clap::{Parser, ValueEnum};
51use mkit_core::hash::Hash;
52use mkit_core::object::Object;
53use mkit_core::refs::{self, Head};
54use mkit_core::store::ObjectStore;
55
56use crate::clap_shim;
57use crate::exit;
58use crate::format;
59use crate::signal;
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
62enum Format {
63 Default,
64 Json,
65}
66
67#[derive(Debug, Parser)]
68#[command(
69 name = "mkit reflog",
70 about = "Show a branch's recorded movement history (read-only).",
71 disable_version_flag = true
72)]
73struct ReflogOpts {
74 #[arg(value_name = "REF")]
78 reference: Option<String>,
79
80 #[arg(long, value_enum)]
82 format: Option<Format>,
83
84 #[arg(short = 'n')]
86 limit: Option<usize>,
87}
88
89#[must_use]
90pub fn run(args: &[String]) -> u8 {
91 let opts = match clap_shim::parse::<ReflogOpts>("mkit reflog", args) {
92 Ok(o) => o,
93 Err(code) => return code,
94 };
95 let fmt = opts.format.unwrap_or(Format::Default);
96
97 let cwd = match std::env::current_dir() {
98 Ok(p) => p,
99 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
100 };
101 let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
102 let store = match ObjectStore::open(&cwd) {
103 Ok(s) => s,
104 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
105 };
106
107 let branch = match resolve_branch(&mkit_dir, opts.reference.as_deref()) {
109 Ok(b) => b,
110 Err((m, c)) => return emit_err(&m, c),
111 };
112
113 let tip = match refs::read_ref(&mkit_dir, &branch) {
114 Ok(Some(h)) => h,
115 Ok(None) => {
116 if matches!(fmt, Format::Default) {
117 let mut stderr = std::io::stderr().lock();
118 let _ = writeln!(stderr, "no history for '{branch}': no commits yet");
119 }
120 return exit::OK;
121 }
122 Err(e) => return emit_err(&format!("read ref '{branch}': {e}"), exit::DATAERR),
123 };
124
125 let chain = match collect_chain(&store, tip) {
130 Ok(c) => c,
131 Err((m, c)) => return emit_err(&m, c),
132 };
133
134 let journal = open_journal(&mkit_dir, &branch);
138
139 let mut stdout = std::io::stdout().lock();
140 if let Format::Default = fmt
141 && let Some(j) = &journal
142 && let Some(summary) = j.summary_line(&branch)
143 {
144 let _ = writeln!(stdout, "{summary}");
145 }
146
147 for (i, &commit) in chain.iter().enumerate() {
148 if signal::is_shutdown() {
149 return exit::TEMPFAIL;
150 }
151 if let Some(lim) = opts.limit
152 && i >= lim
153 {
154 break;
155 }
156 let selector = i;
158 let verified = journal.as_ref().map(|j| j.verify_present(&commit));
166
167 let obj = match store.read_object(&commit) {
168 Ok(o) => o,
169 Err(e) => {
170 return emit_err(
171 &format!("read {}: {e}", format::hex_hash(&commit)),
172 exit::DATAERR,
173 );
174 }
175 };
176 let title = match &obj {
177 Object::Commit(c) => first_line(&c.message),
178 Object::Remix(r) => first_line(&r.message),
179 _ => {
180 return emit_err(
181 &format!("not a commit: {}", format::hex_hash(&commit)),
182 exit::DATAERR,
183 );
184 }
185 };
186
187 match fmt {
188 Format::Default => {
189 let mark = match verified {
190 Some(true) => " [journaled]",
191 Some(false) => " [NOT in journal]",
192 None => "",
193 };
194 let _ = writeln!(
195 stdout,
196 "{} {}@{{{selector}}}: {title}{mark}",
197 format::short_hash(&commit, 8),
198 branch,
199 );
200 }
201 Format::Json => {
202 emit_json_entry(&mut stdout, &branch, selector, &commit, &title, verified);
203 }
204 }
205 }
206 exit::OK
207}
208
209fn emit_json_entry(
219 out: &mut impl Write,
220 branch: &str,
221 index: usize,
222 hash: &Hash,
223 title: &str,
224 verified: Option<bool>,
225) {
226 let _ = out.write_all(b"{");
227 let _ = write!(out, "\"ref\":\"{}\"", format::json_escape(branch));
228 let _ = write!(
229 out,
230 ",\"selector\":\"{}@{{{index}}}\"",
231 format::json_escape(branch)
232 );
233 let _ = write!(out, ",\"index\":{index}");
234 let _ = write!(out, ",\"hash\":\"{}\"", format::hex_hash(hash));
235 let _ = write!(out, ",\"title\":\"{}\"", format::json_escape(title));
236 match verified {
237 Some(b) => {
238 let _ = write!(out, ",\"journaled\":{b}");
239 }
240 None => {
241 let _ = out.write_all(b",\"journaled\":null");
242 }
243 }
244 let _ = out.write_all(b"}\n");
245}
246
247fn resolve_branch(
249 mkit_dir: &std::path::Path,
250 explicit: Option<&str>,
251) -> Result<String, (String, u8)> {
252 if let Some(name) = explicit {
253 return Ok(name.to_owned());
254 }
255 match refs::read_head(mkit_dir) {
256 Ok(Head::Branch(name)) => Ok(name),
257 Ok(Head::Detached(_)) => Err((
258 "HEAD is detached; pass an explicit <ref> (the ref-history journal is per-branch)"
259 .to_owned(),
260 exit::USAGE,
261 )),
262 Err(e) => Err((format!("read HEAD: {e}"), exit::DATAERR)),
263 }
264}
265
266fn collect_chain(store: &ObjectStore, tip: Hash) -> Result<Vec<Hash>, (String, u8)> {
268 let mut chain = Vec::new();
269 let mut cursor = Some(tip);
270 while let Some(h) = cursor {
271 chain.push(h);
272 let parent = match store.read_object(&h) {
273 Ok(Object::Commit(c)) => c.parents.first().copied(),
274 Ok(Object::Remix(r)) => r.parents.first().copied(),
275 Ok(_) => {
276 return Err((
277 format!("not a commit: {}", format::hex_hash(&h)),
278 exit::DATAERR,
279 ));
280 }
281 Err(e) => {
282 return Err((format!("read {}: {e}", format::hex_hash(&h)), exit::DATAERR));
283 }
284 };
285 cursor = parent;
286 }
287 Ok(chain)
288}
289
290fn first_line(message: &[u8]) -> String {
291 String::from_utf8_lossy(message)
292 .lines()
293 .next()
294 .unwrap_or("")
295 .to_owned()
296}
297
298fn emit_err(msg: &str, code: u8) -> u8 {
299 let mut stderr = std::io::stderr().lock();
300 let _ = writeln!(stderr, "error: {msg}");
301 code
302}
303
304#[cfg(feature = "history-mmr")]
312struct Journal {
313 recorded_advances: u64,
314 root: Hash,
315 history: mkit_core::history::CommitHistory<mkit_core::history::TokioExecutor>,
316}
317
318#[cfg(feature = "history-mmr")]
319impl Journal {
320 #[allow(clippy::unnecessary_wraps)]
326 fn summary_line(&self, branch: &str) -> Option<String> {
327 Some(format!(
328 "# journal: {} recorded advance(s) on '{branch}', root {}",
329 self.recorded_advances,
330 format::short_hash(&self.root, 8)
331 ))
332 }
333
334 fn verify_present(&self, commit: &Hash) -> bool {
343 let mut position = self.recorded_advances;
344 while position > 0 {
345 position -= 1;
346 let pos = mkit_core::history::Position(position);
347 let Ok(proof) = self.history.prove(pos) else {
348 continue;
349 };
350 if mkit_core::history::verify_inclusion(commit, pos, &proof, &self.root) {
351 return true;
352 }
353 }
354 false
355 }
356}
357
358#[cfg(feature = "history-mmr")]
362fn open_journal(mkit_dir: &std::path::Path, branch: &str) -> Option<Journal> {
363 let exec = super::history_executor();
364 let history = mkit_core::history::CommitHistory::open_at(exec, mkit_dir, branch).ok()?;
365 Some(Journal {
366 recorded_advances: history.len(),
367 root: history.root(),
368 history,
369 })
370}
371
372#[cfg(not(feature = "history-mmr"))]
374struct Journal;
375
376#[cfg(not(feature = "history-mmr"))]
377impl Journal {
378 #[allow(clippy::unused_self)]
379 fn summary_line(&self, _branch: &str) -> Option<String> {
380 None
381 }
382
383 #[allow(clippy::unused_self)]
384 fn verify_present(&self, _commit: &Hash) -> bool {
385 false
386 }
387}
388
389#[cfg(not(feature = "history-mmr"))]
390fn open_journal(_mkit_dir: &std::path::Path, _branch: &str) -> Option<Journal> {
391 None
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 #[test]
399 fn first_line_takes_title_only() {
400 assert_eq!(first_line(b"title\n\nbody"), "title");
401 assert_eq!(first_line(b"only"), "only");
402 assert_eq!(first_line(b""), "");
403 }
404
405 #[test]
406 fn json_entry_shape_default_build_is_null_journaled() {
407 let mut buf = Vec::new();
408 emit_json_entry(&mut buf, "main", 0, &[0xab; 32], "hello", None);
409 let s = String::from_utf8(buf).unwrap();
410 assert!(s.contains("\"ref\":\"main\""));
411 assert!(s.contains("\"selector\":\"main@{0}\""));
412 assert!(s.contains("\"index\":0"));
413 assert!(s.contains("\"journaled\":null"));
414 assert!(s.ends_with("}\n"));
415 }
416
417 #[test]
418 fn json_entry_journaled_true_renders_bool() {
419 let mut buf = Vec::new();
420 emit_json_entry(&mut buf, "dev", 3, &[0x01; 32], "t", Some(true));
421 let s = String::from_utf8(buf).unwrap();
422 assert!(s.contains("\"selector\":\"dev@{3}\""));
423 assert!(s.contains("\"journaled\":true"));
424 }
425}