mkit-cli 0.4.2

The mkit command-line tool: a content-addressed VCS with native attestation support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! `mkit reflog [<ref>]` — read-only view over the persisted
//! ref-history journal (issue #231).
//!
//! # What the journal actually records
//!
//! mkit's ref-history is the per-branch, append-only **commit-history
//! MMR** (`mkit_core::history::CommitHistory`, the `refs-history.lock`
//! journal written by `write_ref_recording_history`). It records one
//! leaf per **branch ref WRITE**, not one leaf per commit that ends up
//! reachable from the tip. For most operations — a plain commit,
//! branch creation, merge, cherry-pick, amend, a rebase `--abort`
//! rollback, fetch/pull tip update — each new commit corresponds to
//! exactly one ref write, so "one leaf per advance" and "one leaf per
//! commit" coincide in practice.
//!
//! **`rebase` is the documented exception** (issue #648): a
//! multi-commit rebase detaches HEAD for the whole operation and moves
//! it once per replayed commit (`refs::write_head_detached`, NOT
//! `write_ref_recording_history`), then performs exactly ONE branch ref
//! write at finalize. A rebase that replays five commits therefore
//! appends exactly one leaf, not five — the intermediate replayed
//! commits are perfectly valid, reachable, mkit-created commits that
//! were simply never in scope for per-commit journaling. The same gap
//! applies to any future op that moves detached HEAD through multiple
//! commits before a single branch-ref finalize. The journal therefore
//! stores:
//!
//! - the **count** of recorded ref writes (`len()`), and
//! - a tamper-evident **root** plus per-leaf inclusion proofs.
//!
//! It deliberately does **not** store what a Git reflog stores: there
//! is no op label, no old→new pair, no per-entry timestamp or message,
//! and — crucially — the leaf digests are BLAKE3 values with the leaf
//! position mixed in, so the original commit hashes **cannot be read
//! back out of the MMR**. The MMR can only *confirm* a hash you already
//! hold (via `verify_inclusion`).
//!
//! # What `mkit reflog` therefore surfaces
//!
//! Because the readable hashes can only come from the object store, not
//! the MMR, `reflog` walks the branch tip's **first-parent chain**
//! (newest → oldest) — the same reconstruction
//! `history::rebuild_from_chain` uses — and presents it as the branch's
//! movement history, addressed `<branch>@{N}` with `@{0}` = current
//! tip. On a build with `--features history-mmr` it additionally
//! **cross-checks each commit against the journaled MMR root**: it asks
//! the journal to confirm, via an inclusion proof, that the commit was
//! recorded as a branch advance at some leaf position. The
//! recorded-advance count is reported in the summary line. The check is
//! rewrite-robust — a reachable commit shows `[journaled]` as long as it
//! was journaled at some point, even after a later amend/reset shifted
//! the journal's leaf count past the reachable chain length.
//!
//! A reachable commit that does **not** verify is printed as `[not
//! journaled]`, deliberately worded to describe absence rather than
//! imply tampering: per the rebase gap above, an intermediate
//! rebase-replayed commit is expected to show this marker every time —
//! it is a normal consequence of one-leaf-per-ref-write, not evidence
//! of anything wrong with the commit or the journal. A `[not journaled]`
//! marker on a commit that was NOT created by a mid-rebase replay (e.g.
//! a plain commit, or a rebase's own finalize tip) is the more
//! interesting case worth investigating.
//!
//! This is **not** a full Git reflog: `@{N}` indexes the reachable
//! first-parent chain (which drops superseded commits — e.g. after an
//! `--amend` or a reset the old tip is no longer listed), not the raw
//! append log of every movement. See the help text / `man mkit` for the
//! exact contract.
//!
//! Read-only: this command never mutates refs, the journal, or any
//! object.

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 {
    /// Branch whose history to show. Defaults to the branch HEAD points
    /// at. The journal is keyed per-branch, so a detached HEAD needs an
    /// explicit ref.
    #[arg(value_name = "REF")]
    reference: Option<String>,

    /// Output format. `json` emits one JSONL record per entry.
    #[arg(long, value_enum)]
    format: Option<Format>,

    /// Cap the number of entries printed.
    #[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),
    };

    // Resolve the target branch: explicit arg, else HEAD's branch.
    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),
    };

    // Walk the first-parent chain newest → oldest. This is the readable
    // reconstruction of the branch's movement history; the MMR journal
    // itself stores opaque leaf digests that cannot be decoded back to
    // hashes (see module docs).
    let chain = match collect_chain(&store, tip) {
        Ok(c) => c,
        Err((m, c)) => return emit_err(&m, c),
    };

    // Optional journal cross-check (only meaningful on history-mmr
    // builds). `journal` carries `(recorded_advances, root)` and a
    // verifier closure that confirms a commit's inclusion at a position.
    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;
        }
        // `@{0}` is the current tip (chain[0]); `@{N}` walks back.
        let selector = i;
        // Journal cross-check: was this reachable commit ever recorded
        // as a journaled branch ref write? We can't decode the opaque
        // MMR leaves, so we ask the journal to *confirm* the commit at
        // some leaf position via an inclusion proof. This is
        // rewrite-robust: a commit reachable today verifies as long as
        // it was journaled at some point (even if a later amend/reset
        // shifted leaf counts). `None` on a default build (no journal).
        //
        // `Some(false)` is NOT a tamper signal by itself (see the
        // module doc's rebase gap, issue #648): mkit journals one leaf
        // per branch REF WRITE, not one per commit, so an intermediate
        // commit created by a multi-commit rebase's detached-HEAD
        // replay is expected to come back `Some(false)` every time —
        // only the rebase's own finalize tip gets a leaf.
        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]",
                    // Deliberately "not journaled" rather than "NOT in
                    // journal" — this describes an absence, not a
                    // tamper signal. It is the EXPECTED marker for an
                    // intermediate rebase-replayed commit (module doc,
                    // issue #648): mkit records one leaf per branch ref
                    // write, not one per commit.
                    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
}

/// JSONL record per entry. Schema:
///
/// ```json
/// {"ref":"main","selector":"main@{0}","index":0,
///  "hash":"<64-hex>","title":"...","journaled":true|false|null}
/// ```
///
/// `journaled` is `null` on a default build (no history-mmr feature, so
/// no journal to verify against).
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");
}

/// Resolve the branch whose history to show.
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)),
    }
}

/// Walk the first-parent chain from `tip`, newest first.
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;

// ---------------------------------------------------------------------
// Journal cross-check (feature: history-mmr)
// ---------------------------------------------------------------------

/// A handle to the opened ref-history journal used to cross-check the
/// reconstructed chain. Carries the recorded-advance count and root for
/// display, plus the live `CommitHistory` to build inclusion proofs.
#[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 {
    /// One-line journal summary printed above the entries in the default
    /// format: the recorded-advance count and the journal root.
    ///
    /// Returns `Option` to share the signature with the default-build
    /// `Journal` (which has no journal and returns `None`).
    #[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)
        ))
    }

    /// `true` iff `commit` was recorded as a journaled branch advance —
    /// i.e. it verifies, against the current journal root, as the leaf
    /// at *some* position. Scans newest-leaf-first (the common case is
    /// the tip / a recent advance) and stops at the first match.
    ///
    /// O(advances) inclusion proofs in the worst case; reflog is a
    /// diagnostic command and callers cap it with `-n`. `false` for an
    /// empty journal or a commit that was never journaled.
    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
    }
}

/// Open the per-branch ref-history journal for cross-checking, if the
/// build has the `history-mmr` feature and the journal opens cleanly.
/// Read-only: opening does not append.
#[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,
    })
}

/// Default build: no journal to verify against.
#[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"));
    }
}