Skip to main content

mkit_cli/
lib.rs

1#![doc = include_str!("../README.md")]
2//!
3//! `mkit` CLI crate, exposed as a library so integration tests can
4//! drive commands in-process.
5//!
6//! The binary is `src/main.rs`; everything else is a module here so
7//! unit tests and integration tests can link without shelling out.
8//! `mkit-cli` IS published to crates.io so `cargo install mkit-cli`
9//! works, but its library surface (`mkit_cli::…`) is unstable, exists
10//! only for in-process testing, and is deliberately excluded from
11//! `cargo-semver-checks` — do not depend on it as a stable API.
12
13// `deny` rather than `forbid` so the (currently single) `getpwuid_r`
14// home-dir lookup in `config::home_dir_for_euid` can call libc. That
15// function defeats the `HOME=/` parent-process trick when validating
16// an absolute `signing_key` path: env-derived home would admit every
17// path; passwd-derived home is bound to the same uid the file-mode
18// checks use. All other modules remain effectively `forbid`'d via
19// review; new `unsafe` sites need both an `#[allow]` opt-in and a
20// SAFETY comment on the block.
21#![deny(unsafe_code)]
22
23pub mod clap_shim;
24pub mod cli;
25pub mod commands;
26pub mod config;
27pub mod editor;
28pub mod exit;
29pub mod format;
30pub mod progress;
31pub mod remote_dispatch;
32pub mod signal;
33#[cfg(feature = "sparse-checkout")]
34pub mod sparse_cache;
35pub mod term;
36
37use std::io::Write;
38
39/// Dispatch a single argv invocation. Takes the full argv including
40/// `argv[0]`. Returns the exit code the binary should pass to
41/// `std::process::exit`.
42///
43/// All I/O goes through stdout/stderr so integration tests either
44/// spawn the binary (full end-to-end) or drive this entry point
45/// directly (in-process, faster). We keep this function small and
46/// dispatch-only so the command modules remain easy to snapshot.
47#[must_use]
48#[allow(clippy::too_many_lines)] // flat command-dispatch match; splitting it would only hurt readability
49pub fn dispatch(argv: &[String]) -> u8 {
50    // Consume leading global flags (`-C <path>`, `-c <key>=<val>`, and the
51    // accepted-as-no-op pager flags) BEFORE resolving the subcommand, so
52    // they apply to every command and to repo discovery — like git.
53    let (cmd_idx, overrides) = match parse_global_flags(argv) {
54        Ok(parsed) => parsed,
55        Err(code) => return code,
56    };
57    config::set_cli_overrides(overrides);
58
59    if cmd_idx >= argv.len() {
60        print_usage_stderr();
61        return exit::USAGE;
62    }
63    let cmd = &argv[cmd_idx];
64    let rest: Vec<String> = argv.iter().skip(cmd_idx + 1).cloned().collect();
65
66    match cmd.as_str() {
67        "-h" | "--help" | "help" => {
68            let mut stdout = std::io::stdout().lock();
69            let _ = stdout.write_all(cli::HELP_TEXT.as_bytes());
70            exit::OK
71        }
72        "version" | "--version" | "-V" => {
73            let mut stdout = std::io::stdout().lock();
74            // Byte-exact `"mkit <X.Y.Z>\n"` — pinned by the snapshot
75            // test in tests/version_snapshot.rs AND by Homebrew /
76            // Scoop shell asserts. Any refactor that widens this must
77            // update docs/CLI.md and ship a 1.0 major bump. The
78            // top-level `--version`/`-V` flags are aliases of the
79            // `version` subcommand (git-parity, #248) and emit the same
80            // canonical string.
81            let _ = writeln!(stdout, "mkit {}", cli::CLI_VERSION);
82            exit::OK
83        }
84        "init" => commands::init::run(&rest),
85        "key" => commands::key::run(&rest),
86        "keygen" => commands::keygen::run(&rest),
87        "hash" => commands::hash_cmd::run(&rest),
88        "cat" => commands::cat::run(&rest),
89        "cat-file" => commands::cat_file::run(&rest),
90        "ls-tree" => commands::ls_tree::run(&rest),
91        "ls-files" => commands::ls_files::run(&rest),
92        "rev-parse" => commands::rev_parse::run(&rest),
93        "show" => commands::show::run(&rest),
94        "show-ref" => commands::show_ref::run(&rest),
95        "for-each-ref" => commands::for_each_ref::run(&rest),
96        "symbolic-ref" => commands::symbolic_ref::run(&rest),
97        "update-ref" => commands::update_ref::run(&rest),
98        "ref" => commands::ref_cmd::run(&rest),
99        "tree" => commands::tree::run(&rest),
100        "add" => commands::add::run(&rest),
101        "rm" => commands::rm::run(&rest),
102        "mv" => commands::mv::run(&rest),
103        "restore" => commands::restore::run(&rest),
104        "reset" => commands::reset::run(&rest),
105        "status" => commands::status::run(&rest),
106        "commit" => commands::commit::run(&rest),
107        "log" => commands::log::run(&rest),
108        "reflog" => commands::reflog::run(&rest),
109        "branch" => commands::branch::run(&rest),
110        "tag" => commands::tag::run(&rest),
111        "checkout" => commands::checkout::run(&rest),
112        "switch" => commands::switch::run(&rest),
113        "merge-base" => commands::merge_base::run(&rest),
114        "rev-list" => commands::rev_list::run(&rest),
115        "clean" => commands::clean::run(&rest),
116        "diff" => commands::diff::run(&rest),
117        "verify" => commands::verify::run(&rest),
118        "attest" => commands::attest::run(&rest),
119        "verify-attest" => commands::verify_attest::run(&rest),
120        "trust" => commands::trust::run(&rest),
121        "config" => commands::config_cmd::run(&rest),
122        "remote" => commands::remote::run(&rest),
123        "push" => commands::push::run(&rest),
124        "pull" => commands::pull::run(&rest),
125        "fetch" => commands::fetch::run(&rest),
126        "clone" => commands::clone::run(&rest),
127        "mcp" => commands::mcp::run(&rest),
128        "merge" => commands::merge::run(&rest),
129        "cherry-pick" => commands::cherry_pick::run(&rest),
130        "revert" => commands::revert::run(&rest),
131        "rebase" => commands::rebase::run(&rest),
132        "bisect" => commands::bisect::run(&rest),
133        "gc" => commands::gc::run(&rest),
134        "stash" => commands::stash::run(&rest),
135        "worktree" => commands::worktree::run(&rest),
136        "blame" => commands::blame::run(&rest),
137        "self" => commands::self_update::run(&rest),
138        "serve" => commands::serve::run(&rest),
139        #[cfg(feature = "git-bridge")]
140        "git" => commands::git::run(&rest),
141        #[cfg(not(feature = "git-bridge"))]
142        "git" => {
143            let mut stderr = std::io::stderr().lock();
144            let _ = writeln!(
145                stderr,
146                "error: the git bridge is not compiled into this binary; \
147                 rebuild with `--features git-bridge` (see docs/specs/SPEC-GIT-BRIDGE.md)"
148            );
149            exit::UNAVAILABLE
150        }
151        "sparse-checkout" => commands::sparse_checkout::run(&rest),
152        #[cfg(feature = "pack-shards")]
153        "pack-shard" => commands::pack_shard::run(&rest),
154        #[cfg(not(feature = "pack-shards"))]
155        "pack-shard" => {
156            // `pack-shard` is advertised in HELP_TEXT as a feature-gated
157            // command; mirror the `git` fallback so an advertised-but-
158            // disabled command fails with a clear "not compiled in"
159            // message rather than a misleading "unknown command".
160            let mut stderr = std::io::stderr().lock();
161            let _ = writeln!(
162                stderr,
163                "error: pack-shard is not compiled into this binary; \
164                 rebuild with `--features pack-shards`"
165            );
166            exit::UNAVAILABLE
167        }
168        other => {
169            let mut stderr = std::io::stderr().lock();
170            let _ = writeln!(
171                stderr,
172                "error: unknown command '{other}' (run 'mkit --help' for a list of commands)"
173            );
174            exit::USAGE
175        }
176    }
177}
178
179/// Consume the leading global flags from `argv` (after `argv[0]`):
180/// `-C <path>` / `-C<path>` changes directory (repeatable, relative
181/// resolution like git), `-c <key>=<val>` / `-c<key>=<val>` records a
182/// one-shot config override, and `--no-pager` / `-P` / `--paginate` are
183/// accepted as no-ops (mkit never paginates). Returns the index of the
184/// subcommand token and the collected overrides, or an exit code on a
185/// malformed flag / failed `chdir`.
186fn parse_global_flags(argv: &[String]) -> Result<(usize, Vec<(String, String)>), u8> {
187    let mut i = 1; // skip argv[0]
188    let mut overrides: Vec<(String, String)> = Vec::new();
189    while i < argv.len() {
190        let arg = argv[i].as_str();
191        if arg == "-C" {
192            let Some(path) = argv.get(i + 1) else {
193                return Err(global_flag_err("option `-C` requires a path"));
194            };
195            chdir(path)?;
196            i += 2;
197        } else if let Some(path) = arg.strip_prefix("-C").filter(|p| !p.is_empty()) {
198            chdir(path)?;
199            i += 1;
200        } else if arg == "-c" {
201            let Some(kv) = argv.get(i + 1) else {
202                return Err(global_flag_err("option `-c` requires <key>=<value>"));
203            };
204            overrides.push(split_config_override(kv)?);
205            i += 2;
206        } else if let Some(kv) = arg.strip_prefix("-c").filter(|kv| !kv.is_empty()) {
207            overrides.push(split_config_override(kv)?);
208            i += 1;
209        } else if matches!(arg, "--no-pager" | "-P" | "--paginate") {
210            // mkit never paginates; accept the flags so defensive
211            // `mkit --no-pager log` doesn't error out.
212            i += 1;
213        } else {
214            break;
215        }
216    }
217    Ok((i, overrides))
218}
219
220/// `chdir` for `-C`, resolving relative paths against the current dir
221/// (repeatable `-C` composes, like git).
222fn chdir(path: &str) -> Result<(), u8> {
223    std::env::set_current_dir(path).map_err(|e| {
224        let mut stderr = std::io::stderr().lock();
225        let _ = writeln!(stderr, "error: cannot change to '{path}': {e}");
226        exit::NOINPUT
227    })
228}
229
230/// Split a `-c key=value` argument; the value may itself contain `=`.
231fn split_config_override(kv: &str) -> Result<(String, String), u8> {
232    match kv.split_once('=') {
233        Some((k, v)) if !k.is_empty() => Ok((k.to_string(), v.to_string())),
234        _ => Err(global_flag_err(
235            "option `-c` expects <key>=<value> (e.g. -c user.email=ci@example.com)",
236        )),
237    }
238}
239
240fn global_flag_err(msg: &str) -> u8 {
241    let mut stderr = std::io::stderr().lock();
242    let _ = writeln!(stderr, "error: {msg}");
243    exit::USAGE
244}
245
246fn print_usage_stderr() {
247    let mut stderr = std::io::stderr().lock();
248    let _ = stderr.write_all(cli::HELP_TEXT.as_bytes());
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn dispatch_version_returns_ok() {
257        // Even without a repo, `version` should succeed.
258        let argv = vec!["mkit".to_string(), "version".to_string()];
259        assert_eq!(dispatch(&argv), exit::OK);
260    }
261
262    #[test]
263    fn dispatch_unknown_command_returns_usage() {
264        let argv = vec!["mkit".to_string(), "definitely-not-a-command".to_string()];
265        assert_eq!(dispatch(&argv), exit::USAGE);
266    }
267
268    #[test]
269    fn dispatch_bare_binary_returns_usage() {
270        let argv = vec!["mkit".to_string()];
271        assert_eq!(dispatch(&argv), exit::USAGE);
272    }
273}