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 layout = match super::resolve_layout(&cwd) {
50        Ok(layout) => layout,
51        Err(code) => return code,
52    };
53    let store = match ObjectStore::open(&layout) {
54        Ok(s) => s,
55        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
56    };
57
58    let tree_hash = match resolve_tree(&store, &layout, spec) {
59        Ok(h) => h,
60        Err(msg) => return emit_err(&msg, exit::GENERAL_ERROR),
61    };
62
63    // Each pathspec is (normalized-path, had-trailing-slash). A trailing
64    // slash (`sub/`) means "list the directory's contents".
65    let specs: Vec<(String, bool)> = pathspecs.iter().map(|p| normalize(p)).collect();
66    let mut stdout = std::io::stdout().lock();
67    if let Err(msg) = list(
68        &store,
69        &tree_hash,
70        "",
71        opts.recursive,
72        opts.z,
73        &specs,
74        &mut stdout,
75    ) {
76        return emit_err(&msg, exit::GENERAL_ERROR);
77    }
78    exit::OK
79}
80
81/// Recursively emit a tree's entries, honoring pathspecs like git.
82///
83/// Without pathspecs: list immediate entries (a sub-tree prints as
84/// `040000 tree`), recursing only under `-r`. With pathspecs we descend
85/// into a sub-tree whenever a pathspec lies **within** it (e.g.
86/// `sub/inner.txt` descends through `sub`), when `-r` recurses a selected
87/// tree, or when a `sub/` pathspec asks to list its contents; a sub-tree
88/// named exactly by a pathspec (no trailing slash, no `-r`) prints as a
89/// tree line. An entry is printed when it equals or lies under a pathspec.
90fn list(
91    store: &ObjectStore,
92    tree_hash: &Hash,
93    prefix: &str,
94    recursive: bool,
95    z: bool,
96    pathspecs: &[(String, bool)],
97    out: &mut impl Write,
98) -> Result<(), String> {
99    let Object::Tree(tree) = store
100        .read_object(tree_hash)
101        .map_err(|e| format!("read tree: {e}"))?
102    else {
103        return Err(format!("{} is not a tree", format::hex_hash(tree_hash)));
104    };
105    for e in &tree.entries {
106        let Ok(name) = std::str::from_utf8(&e.name) else {
107            return Err("tree entry name is not valid UTF-8".to_string());
108        };
109        let path = if prefix.is_empty() {
110            name.to_string()
111        } else {
112            format!("{prefix}/{name}")
113        };
114        let is_tree = e.mode == EntryMode::Tree;
115
116        if pathspecs.is_empty() {
117            if is_tree && recursive {
118                list(store, &e.object_hash, &path, recursive, z, pathspecs, out)?;
119            } else {
120                emit_entry(e, &path, z, out);
121            }
122            continue;
123        }
124
125        // `path` equals or lies under a pathspec.
126        let matched = pathspecs
127            .iter()
128            .any(|(s, _)| super::index_path_matches_or_descends(&path, s));
129        // A pathspec lies strictly under `path` (a dir on the way to a
130        // deeper target) — descend to reach it.
131        let ancestor = pathspecs
132            .iter()
133            .any(|(s, _)| super::index_path_descends_from(s, &path));
134        // `path` is named with a trailing slash → list its contents.
135        let list_contents = pathspecs.iter().any(|(s, slash)| *slash && &path == s);
136
137        if is_tree {
138            if ancestor || list_contents || (matched && recursive) {
139                list(store, &e.object_hash, &path, recursive, z, pathspecs, out)?;
140            } else if matched {
141                emit_entry(e, &path, z, out);
142            }
143        } else if matched {
144            emit_entry(e, &path, z, out);
145        }
146    }
147    Ok(())
148}
149
150/// Emit one `<mode> <type> <hash>\t<name>` record (NUL-terminated raw under
151/// `-z`, else newline-terminated with the name C-style quoted if needed).
152fn emit_entry(e: &mkit_core::object::TreeEntry, path: &str, z: bool, out: &mut impl Write) {
153    let (mode, ty) = git_mode_and_type(e.mode);
154    let hash = format::hex_hash(&e.object_hash);
155    if z {
156        let _ = write!(out, "{mode} {ty} {hash}\t{path}\0");
157    } else {
158        let shown = super::c_quote_path(path);
159        let shown = shown.as_deref().unwrap_or(path);
160        let _ = writeln!(out, "{mode} {ty} {hash}\t{shown}");
161    }
162}
163
164/// Map an [`EntryMode`] to git's octal mode + object type token.
165fn git_mode_and_type(mode: EntryMode) -> (&'static str, &'static str) {
166    match mode {
167        EntryMode::Blob => ("100644", "blob"),
168        EntryMode::Executable => ("100755", "blob"),
169        EntryMode::Symlink => ("120000", "blob"),
170        EntryMode::Tree => ("040000", "tree"),
171    }
172}
173
174/// Resolve a tree-ish spec to a tree hash: commit/remix → its tree, tag →
175/// its target's tree, a tree → itself.
176fn resolve_tree(
177    store: &ObjectStore,
178    layout: &mkit_core::layout::RepoLayout,
179    spec: &str,
180) -> Result<Hash, String> {
181    let h = revspec::resolve_revision(store, layout, spec)
182        .map_err(|e| format!("bad revision '{spec}': {e}"))?;
183    object_to_tree(store, &h)
184}
185
186fn object_to_tree(store: &ObjectStore, h: &Hash) -> Result<Hash, String> {
187    match store
188        .read_object(h)
189        .map_err(|e| format!("read object: {e}"))?
190    {
191        Object::Commit(c) => Ok(c.tree_hash),
192        Object::Remix(r) => Ok(r.tree_hash),
193        Object::Tree(_) => Ok(*h),
194        Object::Tag(t) => object_to_tree(store, &t.target),
195        _ => Err(format!("{} is not a tree-ish", format::hex_hash(h))),
196    }
197}
198
199/// Normalize a pathspec to `(repo-relative path, had-trailing-slash)`.
200fn normalize(spec: &str) -> (String, bool) {
201    let s = spec.replace('\\', "/");
202    let s = s.strip_prefix("./").unwrap_or(&s);
203    let dir_slash = s.ends_with('/');
204    let s = s.strip_suffix('/').unwrap_or(s);
205    (s.to_string(), dir_slash)
206}
207
208use super::error as emit_err;