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 store = match ObjectStore::open(&cwd) {
55        Ok(s) => s,
56        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
57    };
58    let idx = match super::read_or_seed_index_from_head(&cwd, &store) {
59        Ok(i) => i,
60        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
61    };
62
63    // `--ignored` (and an exclude filter) only mean something for the
64    // `--others` walk; git rejects `-i` outside an `-o`/`-c` selection rather
65    // than silently printing the tracked listing, so we fail closed too.
66    if opts.ignored && !opts.others {
67        return super::usage_error("mkit ls-files --ignored must be used with --others");
68    }
69
70    let mut stdout = std::io::stdout().lock();
71    let sep = if opts.z { '\0' } else { '\n' };
72
73    if opts.others {
74        let ignore = match ignore::load(&cwd) {
75            Ok(i) => i,
76            Err(e) => return emit_err(&format!("read ignore file: {e}"), exit::GENERAL_ERROR),
77        };
78        let mut others: Vec<String> = Vec::new();
79        if let Err(e) = collect_others(&cwd, &cwd, "", false, &idx, &ignore, &opts, &mut others) {
80            return emit_err(&format!("scan worktree: {e}"), exit::GENERAL_ERROR);
81        }
82        others.sort();
83        for path in &others {
84            write_path(&mut stdout, path, opts.z, sep);
85        }
86        return exit::OK;
87    }
88
89    // Tracked entries, sorted by path.
90    let mut entries: Vec<&index::IndexEntry> = idx
91        .entries
92        .iter()
93        .filter(|e| e.status != EntryStatus::Removed)
94        .collect();
95    entries.sort_by(|a, b| a.path.cmp(&b.path));
96    for e in entries {
97        if opts.stage {
98            let mode = git_mode(e.status);
99            // Like the default listing, the `-s` pathname is C-style quoted
100            // when not in `-z` mode (git's `core.quotePath` default).
101            let _ = write!(
102                stdout,
103                "{mode} {} 0\t{}{sep}",
104                format::hex_hash(&e.object_hash),
105                shown_path(&e.path, opts.z)
106            );
107        } else {
108            write_path(&mut stdout, &e.path, opts.z, sep);
109        }
110    }
111    exit::OK
112}
113
114/// The displayed form of a path: raw bytes under `-z`, otherwise git-style
115/// C-quoted when it contains special bytes.
116fn shown_path(path: &str, z: bool) -> std::borrow::Cow<'_, str> {
117    if z {
118        std::borrow::Cow::Borrowed(path)
119    } else {
120        match super::c_quote_path(path) {
121            Some(q) => std::borrow::Cow::Owned(q),
122            None => std::borrow::Cow::Borrowed(path),
123        }
124    }
125}
126
127fn write_path(out: &mut impl Write, path: &str, z: bool, sep: char) {
128    let _ = write!(out, "{}{sep}", shown_path(path, z));
129}
130
131/// git octal mode for a tracked index entry.
132fn git_mode(status: EntryStatus) -> &'static str {
133    match status {
134        EntryStatus::Executable => "100755",
135        EntryStatus::Symlink => "120000",
136        _ => "100644",
137    }
138}
139
140/// Recursively gather untracked worktree files under `dir`, applying the
141/// `--exclude-standard` / `--ignored` filters.
142///
143/// `parent_ignored` carries down whether an ancestor directory is ignored:
144/// git treats everything under an excluded directory as excluded (you cannot
145/// re-include a file whose parent dir is ignored), so a file is ignored if
146/// any ancestor is or it matches a pattern itself. Matching is against the
147/// repo-relative path so anchored/multi-segment patterns apply.
148fn collect_others(
149    root: &Path,
150    dir: &Path,
151    prefix: &str,
152    parent_ignored: bool,
153    idx: &index::Index,
154    ignore: &IgnoreList,
155    opts: &LsFilesOpts,
156    out: &mut Vec<String>,
157) -> std::io::Result<()> {
158    let read = match std::fs::read_dir(dir) {
159        Ok(r) => r,
160        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
161        Err(e) => return Err(e),
162    };
163    for entry in read {
164        let entry = entry?;
165        let name = entry.file_name();
166        let Some(name) = name.to_str() else { continue };
167        if name.eq_ignore_ascii_case(".mkit") || name.eq_ignore_ascii_case(".git") {
168            continue;
169        }
170        let path = if prefix.is_empty() {
171            name.to_string()
172        } else {
173            format!("{prefix}/{name}")
174        };
175        let abs = root.join(&path);
176        let is_dir = std::fs::symlink_metadata(&abs)?.is_dir();
177        let entry_ignored = parent_ignored || ignore.is_ignored(&path, is_dir);
178        if is_dir {
179            // NOTE: unlike `status` and `clean`, git's `ls-files --others`
180            // does NOT suppress a directory that shadows a tracked file — it
181            // lists the contents (`f/child`) as raw untracked plumbing. So no
182            // collision check here; descend normally (#288).
183            collect_others(root, &abs, &path, entry_ignored, idx, ignore, opts, out)?;
184            continue;
185        }
186        // Untracked = not present in the index (any non-removed entry).
187        if super::index_tracks_path_or_descendant(idx, &path) {
188            continue;
189        }
190        let include = if opts.ignored {
191            entry_ignored // --ignored: only ignored
192        } else if opts.exclude_standard {
193            !entry_ignored // drop ignored
194        } else {
195            true // all untracked
196        };
197        if include {
198            out.push(path);
199        }
200    }
201    Ok(())
202}
203
204fn emit_err(msg: &str, code: u8) -> u8 {
205    let mut stderr = std::io::stderr().lock();
206    let _ = writeln!(stderr, "error: {msg}");
207    code
208}