Skip to main content

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`). On *every* branch
9//! advance — commit, branch creation, merge, rebase, cherry-pick,
10//! amend, fetch/pull tip update — the new tip hash is appended as one
11//! leaf. The journal therefore stores:
12//!
13//! - the **count** of recorded advances (`len()`), and
14//! - a tamper-evident **root** plus per-leaf inclusion proofs.
15//!
16//! It deliberately does **not** store what a Git reflog stores: there
17//! is no op label, no old→new pair, no per-entry timestamp or message,
18//! and — crucially — the leaf digests are BLAKE3 values with the leaf
19//! position mixed in, so the original commit hashes **cannot be read
20//! back out of the MMR**. The MMR can only *confirm* a hash you already
21//! hold (via `verify_inclusion`).
22//!
23//! # What `mkit reflog` therefore surfaces
24//!
25//! Because the readable hashes can only come from the object store, not
26//! the MMR, `reflog` walks the branch tip's **first-parent chain**
27//! (newest → oldest) — the same reconstruction
28//! `history::rebuild_from_chain` uses — and presents it as the branch's
29//! movement history, addressed `<branch>@{N}` with `@{0}` = current
30//! tip. On a build with `--features history-mmr` it additionally
31//! **cross-checks each commit against the journaled MMR root**: it asks
32//! the journal to confirm, via an inclusion proof, that the commit was
33//! recorded as a branch advance at some leaf position. The
34//! recorded-advance count is reported in the summary line. The check is
35//! rewrite-robust — a reachable commit shows `[journaled]` as long as it
36//! was journaled at some point, even after a later amend/reset shifted
37//! the journal's leaf count past the reachable chain length.
38//!
39//! This is **not** a full Git reflog: `@{N}` indexes the reachable
40//! first-parent chain (which drops superseded commits — e.g. after an
41//! `--amend` or a reset the old tip is no longer listed), not the raw
42//! append log of every movement. See the help text / `man mkit` for the
43//! exact contract.
44//!
45//! Read-only: this command never mutates refs, the journal, or any
46//! object.
47
48use 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    /// Branch whose history to show. Defaults to the branch HEAD points
75    /// at. The journal is keyed per-branch, so a detached HEAD needs an
76    /// explicit ref.
77    #[arg(value_name = "REF")]
78    reference: Option<String>,
79
80    /// Output format. `json` emits one JSONL record per entry.
81    #[arg(long, value_enum)]
82    format: Option<Format>,
83
84    /// Cap the number of entries printed.
85    #[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    // Resolve the target branch: explicit arg, else HEAD's branch.
108    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    // Walk the first-parent chain newest → oldest. This is the readable
126    // reconstruction of the branch's movement history; the MMR journal
127    // itself stores opaque leaf digests that cannot be decoded back to
128    // hashes (see module docs).
129    let chain = match collect_chain(&store, tip) {
130        Ok(c) => c,
131        Err((m, c)) => return emit_err(&m, c),
132    };
133
134    // Optional journal cross-check (only meaningful on history-mmr
135    // builds). `journal` carries `(recorded_advances, root)` and a
136    // verifier closure that confirms a commit's inclusion at a position.
137    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        // `@{0}` is the current tip (chain[0]); `@{N}` walks back.
157        let selector = i;
158        // Journal cross-check: was this reachable commit ever recorded
159        // as a journaled branch advance? We can't decode the opaque MMR
160        // leaves, so we ask the journal to *confirm* the commit at some
161        // leaf position via an inclusion proof. This is rewrite-robust:
162        // a commit reachable today verifies as long as it was journaled
163        // at some point (even if a later amend/reset shifted leaf
164        // counts). `None` on a default build (no journal).
165        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
209/// JSONL record per entry. Schema:
210///
211/// ```json
212/// {"ref":"main","selector":"main@{0}","index":0,
213///  "hash":"<64-hex>","title":"...","journaled":true|false|null}
214/// ```
215///
216/// `journaled` is `null` on a default build (no history-mmr feature, so
217/// no journal to verify against).
218fn 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
247/// Resolve the branch whose history to show.
248fn 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
266/// Walk the first-parent chain from `tip`, newest first.
267fn 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// ---------------------------------------------------------------------
305// Journal cross-check (feature: history-mmr)
306// ---------------------------------------------------------------------
307
308/// A handle to the opened ref-history journal used to cross-check the
309/// reconstructed chain. Carries the recorded-advance count and root for
310/// display, plus the live `CommitHistory` to build inclusion proofs.
311#[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    /// One-line journal summary printed above the entries in the default
321    /// format: the recorded-advance count and the journal root.
322    ///
323    /// Returns `Option` to share the signature with the default-build
324    /// `Journal` (which has no journal and returns `None`).
325    #[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    /// `true` iff `commit` was recorded as a journaled branch advance —
335    /// i.e. it verifies, against the current journal root, as the leaf
336    /// at *some* position. Scans newest-leaf-first (the common case is
337    /// the tip / a recent advance) and stops at the first match.
338    ///
339    /// O(advances) inclusion proofs in the worst case; reflog is a
340    /// diagnostic command and callers cap it with `-n`. `false` for an
341    /// empty journal or a commit that was never journaled.
342    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/// Open the per-branch ref-history journal for cross-checking, if the
359/// build has the `history-mmr` feature and the journal opens cleanly.
360/// Read-only: opening does not append.
361#[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/// Default build: no journal to verify against.
373#[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}