mkit_cli/commands/reflog.rs
1//! `mkit reflog [<ref>]` — read-only view over the persisted
2//! ref-history journal (issue #231).
3//!
4//! # What the journal actually records
5//!
6//! mkit's ref-history is the per-branch, append-only **commit-history
7//! MMR** (`mkit_core::history::CommitHistory`, the `refs-history.lock`
8//! journal written by `write_ref_recording_history`). It records one
9//! leaf per **branch ref WRITE**, not one leaf per commit that ends up
10//! reachable from the tip. For most operations — a plain commit,
11//! branch creation, merge, cherry-pick, amend, a rebase `--abort`
12//! rollback, fetch/pull tip update — each new commit corresponds to
13//! exactly one ref write, so "one leaf per advance" and "one leaf per
14//! commit" coincide in practice.
15//!
16//! **`rebase` is the documented exception** (issue #648): a
17//! multi-commit rebase detaches HEAD for the whole operation and moves
18//! it once per replayed commit (`refs::write_head_detached`, NOT
19//! `write_ref_recording_history`), then performs exactly ONE branch ref
20//! write at finalize. A rebase that replays five commits therefore
21//! appends exactly one leaf, not five — the intermediate replayed
22//! commits are perfectly valid, reachable, mkit-created commits that
23//! were simply never in scope for per-commit journaling. The same gap
24//! applies to any future op that moves detached HEAD through multiple
25//! commits before a single branch-ref finalize. The journal therefore
26//! stores:
27//!
28//! - the **count** of recorded ref writes (`len()`), and
29//! - a tamper-evident **root** plus per-leaf inclusion proofs.
30//!
31//! It deliberately does **not** store what a Git reflog stores: there
32//! is no op label, no old→new pair, no per-entry timestamp or message,
33//! and — crucially — the leaf digests are BLAKE3 values with the leaf
34//! position mixed in, so the original commit hashes **cannot be read
35//! back out of the MMR**. The MMR can only *confirm* a hash you already
36//! hold (via `verify_inclusion`).
37//!
38//! # What `mkit reflog` therefore surfaces
39//!
40//! Because the readable hashes can only come from the object store, not
41//! the MMR, `reflog` walks the branch tip's **first-parent chain**
42//! (newest → oldest) — the same reconstruction
43//! `history::rebuild_from_chain` uses — and presents it as the branch's
44//! movement history, addressed `<branch>@{N}` with `@{0}` = current
45//! tip. On a build with `--features history-mmr` it additionally
46//! **cross-checks each commit against the journaled MMR root**: it asks
47//! the journal to confirm, via an inclusion proof, that the commit was
48//! recorded as a branch advance at some leaf position. The
49//! recorded-advance count is reported in the summary line. The check is
50//! rewrite-robust — a reachable commit shows `[journaled]` as long as it
51//! was journaled at some point, even after a later amend/reset shifted
52//! the journal's leaf count past the reachable chain length.
53//!
54//! A reachable commit that does **not** verify is printed as `[not
55//! journaled]`, deliberately worded to describe absence rather than
56//! imply tampering: per the rebase gap above, an intermediate
57//! rebase-replayed commit is expected to show this marker every time —
58//! it is a normal consequence of one-leaf-per-ref-write, not evidence
59//! of anything wrong with the commit or the journal. A `[not journaled]`
60//! marker on a commit that was NOT created by a mid-rebase replay (e.g.
61//! a plain commit, or a rebase's own finalize tip) is the more
62//! interesting case worth investigating.
63//!
64//! This is **not** a full Git reflog: `@{N}` indexes the reachable
65//! first-parent chain (which drops superseded commits — e.g. after an
66//! `--amend` or a reset the old tip is no longer listed), not the raw
67//! append log of every movement. See the help text / `man mkit` for the
68//! exact contract.
69//!
70//! Read-only: this command never mutates refs, the journal, or any
71//! object.
72
73use std::io::Write;
74
75use clap::{Parser, ValueEnum};
76use mkit_core::hash::Hash;
77use mkit_core::object::Object;
78use mkit_core::refs::{self, Head};
79use mkit_core::store::ObjectStore;
80
81use crate::clap_shim;
82use crate::exit;
83use crate::format;
84use crate::signal;
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
87enum Format {
88 Default,
89 Json,
90}
91
92#[derive(Debug, Parser)]
93#[command(
94 name = "mkit reflog",
95 about = "Show a branch's recorded movement history (read-only).",
96 disable_version_flag = true
97)]
98struct ReflogOpts {
99 /// Branch whose history to show. Defaults to the branch HEAD points
100 /// at. The journal is keyed per-branch, so a detached HEAD needs an
101 /// explicit ref.
102 #[arg(value_name = "REF")]
103 reference: Option<String>,
104
105 /// Output format. `json` emits one JSONL record per entry.
106 #[arg(long, value_enum)]
107 format: Option<Format>,
108
109 /// Cap the number of entries printed.
110 #[arg(short = 'n')]
111 limit: Option<usize>,
112}
113
114#[must_use]
115pub fn run(args: &[String]) -> u8 {
116 let opts = match clap_shim::parse::<ReflogOpts>("mkit reflog", args) {
117 Ok(o) => o,
118 Err(code) => return code,
119 };
120 let fmt = opts.format.unwrap_or(Format::Default);
121
122 let cwd = match std::env::current_dir() {
123 Ok(p) => p,
124 Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
125 };
126 let layout = match super::resolve_layout(&cwd) {
127 Ok(layout) => layout,
128 Err(code) => return code,
129 };
130 let store = match ObjectStore::open(&layout) {
131 Ok(s) => s,
132 Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
133 };
134
135 // Resolve the target branch: explicit arg, else HEAD's branch.
136 let branch = match resolve_branch(&layout, opts.reference.as_deref()) {
137 Ok(b) => b,
138 Err((m, c)) => return emit_err(&m, c),
139 };
140
141 let tip = match refs::read_ref(&layout, &branch) {
142 Ok(Some(h)) => h,
143 Ok(None) => {
144 if matches!(fmt, Format::Default) {
145 let mut stderr = std::io::stderr().lock();
146 let _ = writeln!(stderr, "no history for '{branch}': no commits yet");
147 }
148 return exit::OK;
149 }
150 Err(e) => return emit_err(&format!("read ref '{branch}': {e}"), exit::DATAERR),
151 };
152
153 // Walk the first-parent chain newest → oldest. This is the readable
154 // reconstruction of the branch's movement history; the MMR journal
155 // itself stores opaque leaf digests that cannot be decoded back to
156 // hashes (see module docs).
157 let chain = match collect_chain(&store, tip) {
158 Ok(c) => c,
159 Err((m, c)) => return emit_err(&m, c),
160 };
161
162 // Optional journal cross-check (only meaningful on history-mmr
163 // builds). `journal` carries `(recorded_advances, root)` and a
164 // verifier closure that confirms a commit's inclusion at a position.
165 let journal = open_journal(&layout, &branch);
166
167 let mut stdout = std::io::stdout().lock();
168 if let Format::Default = fmt
169 && let Some(j) = &journal
170 && let Some(summary) = j.summary_line(&branch)
171 {
172 let _ = writeln!(stdout, "{summary}");
173 }
174
175 for (i, &commit) in chain.iter().enumerate() {
176 if signal::is_shutdown() {
177 return exit::TEMPFAIL;
178 }
179 if let Some(lim) = opts.limit
180 && i >= lim
181 {
182 break;
183 }
184 // `@{0}` is the current tip (chain[0]); `@{N}` walks back.
185 let selector = i;
186 // Journal cross-check: was this reachable commit ever recorded
187 // as a journaled branch ref write? We can't decode the opaque
188 // MMR leaves, so we ask the journal to *confirm* the commit at
189 // some leaf position via an inclusion proof. This is
190 // rewrite-robust: a commit reachable today verifies as long as
191 // it was journaled at some point (even if a later amend/reset
192 // shifted leaf counts). `None` on a default build (no journal).
193 //
194 // `Some(false)` is NOT a tamper signal by itself (see the
195 // module doc's rebase gap, issue #648): mkit journals one leaf
196 // per branch REF WRITE, not one per commit, so an intermediate
197 // commit created by a multi-commit rebase's detached-HEAD
198 // replay is expected to come back `Some(false)` every time —
199 // only the rebase's own finalize tip gets a leaf.
200 let verified = journal.as_ref().map(|j| j.verify_present(&commit));
201
202 let obj = match store.read_object(&commit) {
203 Ok(o) => o,
204 Err(e) => {
205 return emit_err(
206 &format!("read {}: {e}", format::hex_hash(&commit)),
207 exit::DATAERR,
208 );
209 }
210 };
211 let title = match &obj {
212 Object::Commit(c) => first_line(&c.message),
213 Object::Remix(r) => first_line(&r.message),
214 _ => {
215 return emit_err(
216 &format!("not a commit: {}", format::hex_hash(&commit)),
217 exit::DATAERR,
218 );
219 }
220 };
221
222 match fmt {
223 Format::Default => {
224 let mark = match verified {
225 Some(true) => " [journaled]",
226 // Deliberately "not journaled" rather than "NOT in
227 // journal" — this describes an absence, not a
228 // tamper signal. It is the EXPECTED marker for an
229 // intermediate rebase-replayed commit (module doc,
230 // issue #648): mkit records one leaf per branch ref
231 // write, not one per commit.
232 Some(false) => " [not journaled]",
233 None => "",
234 };
235 let _ = writeln!(
236 stdout,
237 "{} {}@{{{selector}}}: {title}{mark}",
238 format::short_hash(&commit, 8),
239 branch,
240 );
241 }
242 Format::Json => {
243 emit_json_entry(&mut stdout, &branch, selector, &commit, &title, verified);
244 }
245 }
246 }
247 exit::OK
248}
249
250/// JSONL record per entry. Schema:
251///
252/// ```json
253/// {"ref":"main","selector":"main@{0}","index":0,
254/// "hash":"<64-hex>","title":"...","journaled":true|false|null}
255/// ```
256///
257/// `journaled` is `null` on a default build (no history-mmr feature, so
258/// no journal to verify against).
259fn emit_json_entry(
260 out: &mut impl Write,
261 branch: &str,
262 index: usize,
263 hash: &Hash,
264 title: &str,
265 verified: Option<bool>,
266) {
267 let _ = out.write_all(b"{");
268 let _ = write!(out, "\"ref\":\"{}\"", format::json_escape(branch));
269 let _ = write!(
270 out,
271 ",\"selector\":\"{}@{{{index}}}\"",
272 format::json_escape(branch)
273 );
274 let _ = write!(out, ",\"index\":{index}");
275 let _ = write!(out, ",\"hash\":\"{}\"", format::hex_hash(hash));
276 let _ = write!(out, ",\"title\":\"{}\"", format::json_escape(title));
277 match verified {
278 Some(b) => {
279 let _ = write!(out, ",\"journaled\":{b}");
280 }
281 None => {
282 let _ = out.write_all(b",\"journaled\":null");
283 }
284 }
285 let _ = out.write_all(b"}\n");
286}
287
288/// Resolve the branch whose history to show.
289fn resolve_branch(
290 layout: &mkit_core::layout::RepoLayout,
291 explicit: Option<&str>,
292) -> Result<String, (String, u8)> {
293 if let Some(name) = explicit {
294 return Ok(name.to_owned());
295 }
296 match refs::read_head(layout) {
297 Ok(Head::Branch(name)) => Ok(name),
298 Ok(Head::Detached(_)) => Err((
299 "HEAD is detached; pass an explicit <ref> (the ref-history journal is per-branch)"
300 .to_owned(),
301 exit::USAGE,
302 )),
303 Err(e) => Err((format!("read HEAD: {e}"), exit::DATAERR)),
304 }
305}
306
307/// Walk the first-parent chain from `tip`, newest first.
308fn collect_chain(store: &ObjectStore, tip: Hash) -> Result<Vec<Hash>, (String, u8)> {
309 let mut chain = Vec::new();
310 let mut cursor = Some(tip);
311 while let Some(h) = cursor {
312 chain.push(h);
313 let parent = match store.read_object(&h) {
314 Ok(Object::Commit(c)) => c.parents.first().copied(),
315 Ok(Object::Remix(r)) => r.parents.first().copied(),
316 Ok(_) => {
317 return Err((
318 format!("not a commit: {}", format::hex_hash(&h)),
319 exit::DATAERR,
320 ));
321 }
322 Err(e) => {
323 return Err((format!("read {}: {e}", format::hex_hash(&h)), exit::DATAERR));
324 }
325 };
326 cursor = parent;
327 }
328 Ok(chain)
329}
330
331fn first_line(message: &[u8]) -> String {
332 String::from_utf8_lossy(message)
333 .lines()
334 .next()
335 .unwrap_or("")
336 .to_owned()
337}
338
339use super::error as emit_err;
340
341// ---------------------------------------------------------------------
342// Journal cross-check (feature: history-mmr)
343// ---------------------------------------------------------------------
344
345/// A handle to the opened ref-history journal used to cross-check the
346/// reconstructed chain. Carries the recorded-advance count and root for
347/// display, plus the live `CommitHistory` to build inclusion proofs.
348#[cfg(feature = "history-mmr")]
349struct Journal {
350 recorded_advances: u64,
351 root: Hash,
352 history: mkit_core::history::CommitHistory<mkit_core::history::TokioExecutor>,
353}
354
355#[cfg(feature = "history-mmr")]
356impl Journal {
357 /// One-line journal summary printed above the entries in the default
358 /// format: the recorded-advance count and the journal root.
359 ///
360 /// Returns `Option` to share the signature with the default-build
361 /// `Journal` (which has no journal and returns `None`).
362 #[allow(clippy::unnecessary_wraps)]
363 fn summary_line(&self, branch: &str) -> Option<String> {
364 Some(format!(
365 "# journal: {} recorded advance(s) on '{branch}', root {}",
366 self.recorded_advances,
367 format::short_hash(&self.root, 8)
368 ))
369 }
370
371 /// `true` iff `commit` was recorded as a journaled branch advance —
372 /// i.e. it verifies, against the current journal root, as the leaf
373 /// at *some* position. Scans newest-leaf-first (the common case is
374 /// the tip / a recent advance) and stops at the first match.
375 ///
376 /// O(advances) inclusion proofs in the worst case; reflog is a
377 /// diagnostic command and callers cap it with `-n`. `false` for an
378 /// empty journal or a commit that was never journaled.
379 fn verify_present(&self, commit: &Hash) -> bool {
380 let mut position = self.recorded_advances;
381 while position > 0 {
382 position -= 1;
383 let pos = mkit_core::history::Position(position);
384 let Ok(proof) = self.history.prove(pos) else {
385 continue;
386 };
387 if mkit_core::history::verify_inclusion(commit, pos, &proof, &self.root) {
388 return true;
389 }
390 }
391 false
392 }
393}
394
395/// Open the per-branch ref-history journal for cross-checking, if the
396/// build has the `history-mmr` feature and the journal opens cleanly.
397/// Read-only: opening does not append.
398#[cfg(feature = "history-mmr")]
399fn open_journal(layout: &mkit_core::layout::RepoLayout, branch: &str) -> Option<Journal> {
400 let exec = super::history_executor();
401 let history = mkit_core::history::CommitHistory::open_at(exec, layout, branch).ok()?;
402 Some(Journal {
403 recorded_advances: history.len(),
404 root: history.root(),
405 history,
406 })
407}
408
409/// Default build: no journal to verify against.
410#[cfg(not(feature = "history-mmr"))]
411struct Journal;
412
413#[cfg(not(feature = "history-mmr"))]
414impl Journal {
415 #[allow(clippy::unused_self)]
416 fn summary_line(&self, _branch: &str) -> Option<String> {
417 None
418 }
419
420 #[allow(clippy::unused_self)]
421 fn verify_present(&self, _commit: &Hash) -> bool {
422 false
423 }
424}
425
426#[cfg(not(feature = "history-mmr"))]
427fn open_journal(_layout: &mkit_core::layout::RepoLayout, _branch: &str) -> Option<Journal> {
428 None
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 #[test]
436 fn first_line_takes_title_only() {
437 assert_eq!(first_line(b"title\n\nbody"), "title");
438 assert_eq!(first_line(b"only"), "only");
439 assert_eq!(first_line(b""), "");
440 }
441
442 #[test]
443 fn json_entry_shape_default_build_is_null_journaled() {
444 let mut buf = Vec::new();
445 emit_json_entry(&mut buf, "main", 0, &[0xab; 32], "hello", None);
446 let s = String::from_utf8(buf).unwrap();
447 assert!(s.contains("\"ref\":\"main\""));
448 assert!(s.contains("\"selector\":\"main@{0}\""));
449 assert!(s.contains("\"index\":0"));
450 assert!(s.contains("\"journaled\":null"));
451 assert!(s.ends_with("}\n"));
452 }
453
454 #[test]
455 fn json_entry_journaled_true_renders_bool() {
456 let mut buf = Vec::new();
457 emit_json_entry(&mut buf, "dev", 3, &[0x01; 32], "t", Some(true));
458 let s = String::from_utf8(buf).unwrap();
459 assert!(s.contains("\"selector\":\"dev@{3}\""));
460 assert!(s.contains("\"journaled\":true"));
461 }
462}