Skip to main content

mkit_cli/commands/
ls_files.rs

1//! `mkit ls-files [-s] [-z] [--others] [--ignored] [--exclude-standard]`
2//! — list files in the index or untracked worktree files, like
3//! `git ls-files`.
4//!
5//! Default: tracked paths (one per line, sorted). `-s` prints stage info
6//! (`<mode> <hash> <stage>\t<path>`; stage is always 0 — mkit has no merge
7//! stages). `--others` lists untracked worktree files instead;
8//! `--exclude-standard` drops `.mkitignore`-ignored ones, and `--ignored`
9//! inverts to show only the ignored. `-z` NUL-terminates with raw paths.
10
11use std::io::Write;
12use std::path::Path;
13
14use clap::Parser;
15use mkit_core::ignore::{self, IgnoreList};
16use mkit_core::index::{self, EntryStatus};
17use mkit_core::store::ObjectStore;
18
19use crate::clap_shim;
20use crate::exit;
21use crate::format;
22
23#[derive(Debug, Parser)]
24#[command(name = "mkit ls-files", about = "List tracked or untracked files.")]
25#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
26struct LsFilesOpts {
27    /// Show stage info: `<mode> <hash> <stage>\t<path>`.
28    #[arg(short = 's', long = "stage")]
29    stage: bool,
30    /// NUL-terminate records and emit raw paths.
31    #[arg(short = 'z')]
32    z: bool,
33    /// List untracked worktree files instead of tracked ones.
34    #[arg(long)]
35    others: bool,
36    /// Drop `.mkitignore`-ignored files (with `--others`).
37    #[arg(long = "exclude-standard")]
38    exclude_standard: bool,
39    /// Show only ignored files (requires `--others`).
40    #[arg(long)]
41    ignored: bool,
42}
43
44#[must_use]
45pub fn run(args: &[String]) -> u8 {
46    let opts = match clap_shim::parse::<LsFilesOpts>("mkit ls-files", args) {
47        Ok(o) => o,
48        Err(code) => return code,
49    };
50    let cwd = match std::env::current_dir() {
51        Ok(p) => p,
52        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
53    };
54    let layout = match super::resolve_layout(&cwd) {
55        Ok(layout) => layout,
56        Err(code) => return code,
57    };
58    let store = match ObjectStore::open(&layout) {
59        Ok(s) => s,
60        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
61    };
62    let idx = match super::read_or_seed_index_from_head(&layout, &store) {
63        Ok(i) => i,
64        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
65    };
66
67    // `--ignored` (and an exclude filter) only mean something for the
68    // `--others` walk; git rejects `-i` outside an `-o`/`-c` selection rather
69    // than silently printing the tracked listing, so we fail closed too.
70    if opts.ignored && !opts.others {
71        return super::usage_error("mkit ls-files --ignored must be used with --others");
72    }
73
74    let mut stdout = std::io::stdout().lock();
75    let sep = if opts.z { '\0' } else { '\n' };
76
77    if opts.others {
78        let ignore = match ignore::load(&cwd) {
79            Ok(i) => i,
80            Err(e) => return emit_err(&format!("read ignore file: {e}"), exit::GENERAL_ERROR),
81        };
82        let mut others: Vec<String> = Vec::new();
83        if let Err(e) = collect_others(&cwd, &cwd, "", false, &idx, &ignore, &opts, &mut others) {
84            return emit_err(&format!("scan worktree: {e}"), exit::GENERAL_ERROR);
85        }
86        others.sort();
87        for path in &others {
88            write_path(&mut stdout, path, opts.z, sep);
89        }
90        return exit::OK;
91    }
92
93    // Tracked entries, sorted by path.
94    let mut entries: Vec<&index::IndexEntry> = idx
95        .entries
96        .iter()
97        .filter(|e| e.status != EntryStatus::Removed)
98        .collect();
99    entries.sort_by(|a, b| a.path.cmp(&b.path));
100    for e in entries {
101        if opts.stage {
102            let mode = git_mode(e.status);
103            // Like the default listing, the `-s` pathname is C-style quoted
104            // when not in `-z` mode (git's `core.quotePath` default).
105            let _ = write!(
106                stdout,
107                "{mode} {} 0\t{}{sep}",
108                format::hex_hash(&e.object_hash),
109                shown_path(&e.path, opts.z)
110            );
111        } else {
112            write_path(&mut stdout, &e.path, opts.z, sep);
113        }
114    }
115    exit::OK
116}
117
118/// The displayed form of a path: raw bytes under `-z`, otherwise git-style
119/// C-quoted when it contains special bytes.
120fn shown_path(path: &str, z: bool) -> std::borrow::Cow<'_, str> {
121    if z {
122        std::borrow::Cow::Borrowed(path)
123    } else {
124        match super::c_quote_path(path) {
125            Some(q) => std::borrow::Cow::Owned(q),
126            None => std::borrow::Cow::Borrowed(path),
127        }
128    }
129}
130
131fn write_path(out: &mut impl Write, path: &str, z: bool, sep: char) {
132    let _ = write!(out, "{}{sep}", shown_path(path, z));
133}
134
135/// git octal mode for a tracked index entry.
136fn git_mode(status: EntryStatus) -> &'static str {
137    match status {
138        EntryStatus::Executable => "100755",
139        EntryStatus::Symlink => "120000",
140        _ => "100644",
141    }
142}
143
144/// Recursively gather untracked worktree files under `dir`, applying the
145/// `--exclude-standard` / `--ignored` filters.
146///
147/// `parent_ignored` carries down whether an ancestor directory is ignored:
148/// git treats everything under an excluded directory as excluded (you cannot
149/// re-include a file whose parent dir is ignored), so a file is ignored if
150/// any ancestor is or it matches a pattern itself. Matching is against the
151/// repo-relative path so anchored/multi-segment patterns apply.
152fn collect_others(
153    root: &Path,
154    dir: &Path,
155    prefix: &str,
156    parent_ignored: bool,
157    idx: &index::Index,
158    ignore: &IgnoreList,
159    opts: &LsFilesOpts,
160    out: &mut Vec<String>,
161) -> std::io::Result<()> {
162    let read = match std::fs::read_dir(dir) {
163        Ok(r) => r,
164        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
165        Err(e) => return Err(e),
166    };
167    for entry in read {
168        let entry = entry?;
169        let name = entry.file_name();
170        let Some(name) = name.to_str() else { continue };
171        if name.eq_ignore_ascii_case(".mkit") || name.eq_ignore_ascii_case(".git") {
172            continue;
173        }
174        let path = if prefix.is_empty() {
175            name.to_string()
176        } else {
177            format!("{prefix}/{name}")
178        };
179        let abs = root.join(&path);
180        let is_dir = std::fs::symlink_metadata(&abs)?.is_dir();
181        let entry_ignored = parent_ignored || ignore.is_ignored(&path, is_dir);
182        if is_dir {
183            // NOTE: unlike `status` and `clean`, git's `ls-files --others`
184            // does NOT suppress a directory that shadows a tracked file — it
185            // lists the contents (`f/child`) as raw untracked plumbing. So no
186            // collision check here; descend normally (#288).
187            collect_others(root, &abs, &path, entry_ignored, idx, ignore, opts, out)?;
188            continue;
189        }
190        // Untracked = not present in the index (any non-removed entry).
191        if super::index_tracks_path_or_descendant(idx, &path) {
192            continue;
193        }
194        let include = if opts.ignored {
195            entry_ignored // --ignored: only ignored
196        } else if opts.exclude_standard {
197            !entry_ignored // drop ignored
198        } else {
199            true // all untracked
200        };
201        if include {
202            out.push(path);
203        }
204    }
205    Ok(())
206}
207
208use super::error as emit_err;