Skip to main content

mkit_cli/commands/
show.rs

1//! `mkit show [<object>...]` — display objects (default `HEAD`).
2//!
3//! Mirrors `git show`:
4//! - **commit** / **remix**: a header (`commit <hash>` / `Author` / `Date` /
5//!   indented message, matching `mkit log`) followed by the unified diff
6//!   against its first parent. The diff body is produced by the same code as
7//!   `mkit diff`, so `show <commit>` is byte-identical to
8//!   `diff <parent> <commit>` (modulo the abbreviated `index` ids).
9//! - **tag**: the tag header, then the peeled target object.
10//! - **tree**: an `ls-tree`-style listing.
11//! - **blob**: the raw contents.
12//!
13//! Like `mkit log`, the commit/tag headers carry mkit's signed `Identity`
14//! and 64-hex BLAKE3 ids, so the header lines diverge from git's
15//! `Author: Name <email>` / 40-hex form — the same documented divergence as
16//! `log`. The diff body, tree listing, and blob output match git.
17
18use std::io::Write;
19
20use clap::Parser;
21use mkit_core::hash::Hash;
22use mkit_core::object::{Identity, Object, Tag};
23use mkit_core::ops::diff_trees;
24use mkit_core::store::{DisplaySource, ObjectStore};
25use mkit_core::worktree;
26
27use super::revspec;
28use crate::clap_shim;
29use crate::exit;
30use crate::format;
31
32/// Bound on tag-of-tag recursion, mirroring `diff`/`log`'s peel depth.
33const MAX_TAG_DEPTH: usize = 16;
34
35#[derive(Debug, Parser)]
36#[command(
37    name = "mkit show",
38    about = "Display objects (default HEAD): commits with their diff, tags, trees, blobs."
39)]
40struct ShowOpts {
41    /// Show a diffstat instead of the full patch for commit/remix objects
42    /// (like `git show --stat`): per-file changed-line counts, a `+`/`-`
43    /// graph, and a summary line. Non-commit objects are shown as usual.
44    #[arg(long)]
45    stat: bool,
46    /// Objects to show — revisions, refs, or hashes (e.g. `HEAD`, `main`,
47    /// `HEAD~2`, `<hash>`, `<tag>`). Defaults to `HEAD`.
48    objects: Vec<String>,
49}
50
51#[must_use]
52pub fn run(args: &[String]) -> u8 {
53    let opts = match clap_shim::parse::<ShowOpts>("mkit show", args) {
54        Ok(o) => o,
55        Err(code) => return code,
56    };
57    let cwd = match std::env::current_dir() {
58        Ok(p) => p,
59        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
60    };
61    let layout = match super::resolve_layout(&cwd) {
62        Ok(layout) => layout,
63        Err(code) => return code,
64    };
65    let store = match ObjectStore::open(&layout) {
66        Ok(s) => s,
67        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
68    };
69
70    let specs: Vec<String> = if opts.objects.is_empty() {
71        vec!["HEAD".to_string()]
72    } else {
73        opts.objects.clone()
74    };
75
76    let mut stdout = std::io::stdout().lock();
77    for spec in &specs {
78        let h = match revspec::resolve_revision(&store, &layout, spec) {
79            Ok(h) => h,
80            Err(e) => return emit_err(&e.to_string(), exit::GENERAL_ERROR),
81        };
82        if let Err((msg, code)) = show_object(&mut stdout, &store, &h, 0, opts.stat) {
83            return emit_err(&msg, code);
84        }
85    }
86    exit::OK
87}
88
89/// Display one object. `tag_depth` bounds tag-of-tag recursion.
90fn show_object(
91    out: &mut impl Write,
92    store: &ObjectStore,
93    h: &Hash,
94    tag_depth: usize,
95    stat: bool,
96) -> Result<(), (String, u8)> {
97    let obj = store
98        .read_object(h)
99        .map_err(|e| (format!("read object: {e}"), exit::GENERAL_ERROR))?;
100    match obj {
101        Object::Commit(c) => show_commit_like(
102            out,
103            store,
104            h,
105            "commit",
106            &c.author,
107            c.timestamp,
108            &c.message,
109            c.parents.first().copied(),
110            c.tree_hash,
111            stat,
112        ),
113        Object::Remix(r) => show_commit_like(
114            out,
115            store,
116            h,
117            "remix",
118            &r.author,
119            r.timestamp,
120            &r.message,
121            r.parents.first().copied(),
122            r.tree_hash,
123            stat,
124        ),
125        Object::Tag(t) => show_tag(out, store, &t, tag_depth, stat),
126        Object::Tree(t) => {
127            for e in &t.entries {
128                let (mode, ty) = super::cat_file::git_mode_and_type(e.mode);
129                let _ = writeln!(
130                    out,
131                    "{mode} {ty} {}\t{}",
132                    format::hex_hash(&e.object_hash),
133                    String::from_utf8_lossy(&e.name)
134                );
135            }
136            Ok(())
137        }
138        Object::Blob(b) => {
139            let _ = out.write_all(&b.data);
140            Ok(())
141        }
142        Object::ChunkedBlob(_) => {
143            let data = worktree::read_blob(store, h)
144                .map_err(|e| (format!("reassemble: {e}"), exit::GENERAL_ERROR))?;
145            let _ = out.write_all(&data);
146            Ok(())
147        }
148        // `Delta` is a pack-only encoding and never the result of a plain
149        // `read_object`, but handle it explicitly rather than via a wildcard.
150        Object::Delta(_) => Err(("cannot show a delta object".to_string(), exit::DATAERR)),
151    }
152}
153
154/// Render a commit or remix: a `mkit log`-style header followed by the
155/// unified diff against the first parent (an empty tree for a root commit).
156#[allow(clippy::too_many_arguments)]
157fn show_commit_like(
158    out: &mut impl Write,
159    store: &ObjectStore,
160    hash: &Hash,
161    label: &str,
162    author: &Identity,
163    timestamp: u64,
164    message: &[u8],
165    parent: Option<Hash>,
166    tree: Hash,
167    stat: bool,
168) -> Result<(), (String, u8)> {
169    let _ = writeln!(out, "{label} {}", format::hex_hash(hash));
170    let _ = writeln!(out, "Author: {}", format::short_identity(author));
171    let _ = writeln!(out, "Date:   {}", format::human_date_utc(timestamp));
172    let _ = writeln!(out);
173    write_indented_message(out, message);
174    let _ = writeln!(out);
175
176    // Diff the first parent's tree against this tree (None ⇒ empty, so a
177    // root commit shows every file as added), reusing `diff`'s renderer.
178    let parent_tree = match parent {
179        Some(p) => {
180            Some(super::diff::object_to_tree(store, &p).map_err(|e| (e, exit::GENERAL_ERROR))?)
181        }
182        None => None,
183    };
184    let result = diff_trees(store, parent_tree, Some(tree))
185        .map_err(|e| (format!("diff: {e}"), exit::GENERAL_ERROR))?;
186    // `--stat` renders the diffstat instead of the full patch (like
187    // `git show --stat`), reusing `diff`'s byte-exact stat renderer;
188    // `render_stat` hoists its own `DisplaySource` wrapping (#625).
189    if stat {
190        return super::diff::render_stat(out, store, result.entries.iter())
191            .map_err(|e| (e, exit::GENERAL_ERROR));
192    }
193    // The patch loop below only ever prints what it renders here — nothing
194    // durable is published from this path — so skip the BLAKE3 re-verify
195    // on every changed blob (#625).
196    let display = DisplaySource::new(store);
197    for e in &result.entries {
198        super::diff::emit_entry_patch(
199            out,
200            &display,
201            e,
202            mkit_core::ops::DEFAULT_CONTEXT_LINES,
203            mkit_core::ops::WhitespaceMode::Exact,
204        )
205        .map_err(|e| (e, exit::GENERAL_ERROR))?;
206    }
207    Ok(())
208}
209
210/// Render an annotated/signed tag header, then the peeled target object.
211fn show_tag(
212    out: &mut impl Write,
213    store: &ObjectStore,
214    t: &Tag,
215    tag_depth: usize,
216    stat: bool,
217) -> Result<(), (String, u8)> {
218    let _ = writeln!(out, "tag {}", String::from_utf8_lossy(&t.name));
219    let _ = writeln!(out, "Tagger: {}", format::short_identity(&t.tagger));
220    let _ = writeln!(out, "Date:   {}", format::human_date_utc(t.timestamp));
221    let _ = writeln!(out);
222    // git prints the tag message un-indented, then a blank line, then the
223    // target object.
224    let msg = String::from_utf8_lossy(&t.message);
225    for line in msg.lines() {
226        let _ = writeln!(out, "{line}");
227    }
228    let _ = writeln!(out);
229
230    if tag_depth + 1 >= MAX_TAG_DEPTH {
231        return Err(("tag chain too deep".to_string(), exit::DATAERR));
232    }
233    show_object(out, store, &t.target, tag_depth + 1, stat)
234}
235
236/// Write a commit message indented four spaces per line (blank lines stay
237/// blank), matching `mkit log`'s default format.
238fn write_indented_message(out: &mut impl Write, message: &[u8]) {
239    let text = String::from_utf8_lossy(message);
240    for line in text.lines() {
241        if line.is_empty() {
242            let _ = writeln!(out);
243        } else {
244            let _ = writeln!(out, "    {line}");
245        }
246    }
247}
248
249use super::error as emit_err;