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`). 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}