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