Skip to main content

mkit_cli/commands/
ls_tree.rs

1//! `mkit ls-tree [-r] [-z] <tree-ish> [<path>...]` — list the entries of a
2//! tree, like `git ls-tree`.
3//!
4//! Each entry prints as `<mode> <type> <hash>\t<name>` where `<mode>` is
5//! the git octal mode (`100644`/`100755`/`120000`/`040000`) and `<hash>`
6//! is a 64-hex BLAKE3 id. `-r` recurses (showing leaf blobs with full
7//! paths and omitting tree lines, like git); `-z` NUL-terminates records
8//! and emits raw paths (otherwise special-byte paths are C-style quoted).
9
10use std::io::Write;
11
12use clap::Parser;
13use mkit_core::hash::Hash;
14use mkit_core::object::{EntryMode, Object};
15use mkit_core::store::ObjectStore;
16
17use super::revspec;
18use crate::clap_shim;
19use crate::exit;
20use crate::format;
21
22#[derive(Debug, Parser)]
23#[command(name = "mkit ls-tree", about = "List the contents of a tree object.")]
24struct LsTreeOpts {
25    /// Recurse into sub-trees (show leaf blobs with full paths).
26    #[arg(short = 'r')]
27    recursive: bool,
28    /// NUL-terminate records and emit raw (unquoted) paths.
29    #[arg(short = 'z')]
30    z: bool,
31    /// Tree-ish (commit, tag, tree, ref, or hash) followed by optional
32    /// pathspecs limiting the listing.
33    args: Vec<String>,
34}
35
36#[must_use]
37pub fn run(args: &[String]) -> u8 {
38    let opts = match clap_shim::parse::<LsTreeOpts>("mkit ls-tree", args) {
39        Ok(o) => o,
40        Err(code) => return code,
41    };
42    let Some((spec, pathspecs)) = opts.args.split_first() else {
43        return super::usage_error("usage: mkit ls-tree [-r] [-z] <tree-ish> [<path>...]");
44    };
45    let cwd = match std::env::current_dir() {
46        Ok(p) => p,
47        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
48    };
49    let store = match ObjectStore::open(&cwd) {
50        Ok(s) => s,
51        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
52    };
53    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
54
55    let tree_hash = match resolve_tree(&store, &mkit_dir, spec) {
56        Ok(h) => h,
57        Err(msg) => return emit_err(&msg, exit::GENERAL_ERROR),
58    };
59
60    // Each pathspec is (normalized-path, had-trailing-slash). A trailing
61    // slash (`sub/`) means "list the directory's contents".
62    let specs: Vec<(String, bool)> = pathspecs.iter().map(|p| normalize(p)).collect();
63    let mut stdout = std::io::stdout().lock();
64    if let Err(msg) = list(
65        &store,
66        &tree_hash,
67        "",
68        opts.recursive,
69        opts.z,
70        &specs,
71        &mut stdout,
72    ) {
73        return emit_err(&msg, exit::GENERAL_ERROR);
74    }
75    exit::OK
76}
77
78/// Recursively emit a tree's entries, honoring pathspecs like git.
79///
80/// Without pathspecs: list immediate entries (a sub-tree prints as
81/// `040000 tree`), recursing only under `-r`. With pathspecs we descend
82/// into a sub-tree whenever a pathspec lies **within** it (e.g.
83/// `sub/inner.txt` descends through `sub`), when `-r` recurses a selected
84/// tree, or when a `sub/` pathspec asks to list its contents; a sub-tree
85/// named exactly by a pathspec (no trailing slash, no `-r`) prints as a
86/// tree line. An entry is printed when it equals or lies under a pathspec.
87fn list(
88    store: &ObjectStore,
89    tree_hash: &Hash,
90    prefix: &str,
91    recursive: bool,
92    z: bool,
93    pathspecs: &[(String, bool)],
94    out: &mut impl Write,
95) -> Result<(), String> {
96    let Object::Tree(tree) = store
97        .read_object(tree_hash)
98        .map_err(|e| format!("read tree: {e}"))?
99    else {
100        return Err(format!("{} is not a tree", format::hex_hash(tree_hash)));
101    };
102    for e in &tree.entries {
103        let Ok(name) = std::str::from_utf8(&e.name) else {
104            return Err("tree entry name is not valid UTF-8".to_string());
105        };
106        let path = if prefix.is_empty() {
107            name.to_string()
108        } else {
109            format!("{prefix}/{name}")
110        };
111        let is_tree = e.mode == EntryMode::Tree;
112
113        if pathspecs.is_empty() {
114            if is_tree && recursive {
115                list(store, &e.object_hash, &path, recursive, z, pathspecs, out)?;
116            } else {
117                emit_entry(e, &path, z, out);
118            }
119            continue;
120        }
121
122        // `path` equals or lies under a pathspec.
123        let matched = pathspecs
124            .iter()
125            .any(|(s, _)| super::index_path_matches_or_descends(&path, s));
126        // A pathspec lies strictly under `path` (a dir on the way to a
127        // deeper target) — descend to reach it.
128        let ancestor = pathspecs
129            .iter()
130            .any(|(s, _)| super::index_path_descends_from(s, &path));
131        // `path` is named with a trailing slash → list its contents.
132        let list_contents = pathspecs.iter().any(|(s, slash)| *slash && &path == s);
133
134        if is_tree {
135            if ancestor || list_contents || (matched && recursive) {
136                list(store, &e.object_hash, &path, recursive, z, pathspecs, out)?;
137            } else if matched {
138                emit_entry(e, &path, z, out);
139            }
140        } else if matched {
141            emit_entry(e, &path, z, out);
142        }
143    }
144    Ok(())
145}
146
147/// Emit one `<mode> <type> <hash>\t<name>` record (NUL-terminated raw under
148/// `-z`, else newline-terminated with the name C-style quoted if needed).
149fn emit_entry(e: &mkit_core::object::TreeEntry, path: &str, z: bool, out: &mut impl Write) {
150    let (mode, ty) = git_mode_and_type(e.mode);
151    let hash = format::hex_hash(&e.object_hash);
152    if z {
153        let _ = write!(out, "{mode} {ty} {hash}\t{path}\0");
154    } else {
155        let shown = super::c_quote_path(path);
156        let shown = shown.as_deref().unwrap_or(path);
157        let _ = writeln!(out, "{mode} {ty} {hash}\t{shown}");
158    }
159}
160
161/// Map an [`EntryMode`] to git's octal mode + object type token.
162fn git_mode_and_type(mode: EntryMode) -> (&'static str, &'static str) {
163    match mode {
164        EntryMode::Blob => ("100644", "blob"),
165        EntryMode::Executable => ("100755", "blob"),
166        EntryMode::Symlink => ("120000", "blob"),
167        EntryMode::Tree => ("040000", "tree"),
168    }
169}
170
171/// Resolve a tree-ish spec to a tree hash: commit/remix → its tree, tag →
172/// its target's tree, a tree → itself.
173fn resolve_tree(
174    store: &ObjectStore,
175    mkit_dir: &std::path::Path,
176    spec: &str,
177) -> Result<Hash, String> {
178    let h = revspec::resolve_revision(store, mkit_dir, spec)
179        .map_err(|e| format!("bad revision '{spec}': {e}"))?;
180    object_to_tree(store, &h)
181}
182
183fn object_to_tree(store: &ObjectStore, h: &Hash) -> Result<Hash, String> {
184    match store
185        .read_object(h)
186        .map_err(|e| format!("read object: {e}"))?
187    {
188        Object::Commit(c) => Ok(c.tree_hash),
189        Object::Remix(r) => Ok(r.tree_hash),
190        Object::Tree(_) => Ok(*h),
191        Object::Tag(t) => object_to_tree(store, &t.target),
192        _ => Err(format!("{} is not a tree-ish", format::hex_hash(h))),
193    }
194}
195
196/// Normalize a pathspec to `(repo-relative path, had-trailing-slash)`.
197fn normalize(spec: &str) -> (String, bool) {
198    let s = spec.replace('\\', "/");
199    let s = s.strip_prefix("./").unwrap_or(&s);
200    let dir_slash = s.ends_with('/');
201    let s = s.strip_suffix('/').unwrap_or(s);
202    (s.to_string(), dir_slash)
203}
204
205fn emit_err(msg: &str, code: u8) -> u8 {
206    let mut stderr = std::io::stderr().lock();
207    let _ = writeln!(stderr, "error: {msg}");
208    code
209}