Skip to main content

mkit_cli/commands/
clone.rs

1//! `mkit clone <url> [<dir>]` — initialise a new repo and pull from
2//! the URL. The destination defaults to the final path segment of the
3//! URL when `<dir>` is omitted.
4//!
5//! Dispatches to the same transport-open path used by `mkit pull` —
6//! `file://`, `https://`, `s3://`, and `ssh://` are all wired via
7//! `remote_dispatch::open`. `--sparse` is implemented (behind the
8//! `sparse-checkout` feature): the patterns are persisted to
9//! `.mkit/sparse-checkout` and a verifiable sparse checkout is performed
10//! after the pull. `--depth` (shallow clone) is still deferred and is
11//! rejected with a clear message rather than silently ignored.
12
13use std::fs;
14use std::io::Write;
15use std::path::PathBuf;
16
17use clap::Parser;
18use mkit_core::refs;
19use mkit_core::store::{ObjectStore, StoreError};
20
21use crate::clap_shim;
22use crate::config::{self, Config, RemoteEntry};
23use crate::exit;
24use crate::remote_dispatch;
25
26#[derive(Debug, Parser)]
27#[command(
28    name = "mkit clone",
29    about = "Initialise a new repo and pull from a remote URL."
30)]
31struct CloneOpts {
32    /// Shallow clone depth (not yet wired).
33    #[arg(long, value_name = "N")]
34    depth: Option<u32>,
35    /// One or more sparse-checkout patterns (issue #158).
36    /// Pulls the full ref set + reachable pack, then runs the
37    /// verifiable sparse pipeline on the new working tree's HEAD,
38    /// caching the bitmap and materialising only the matching files.
39    /// Repeat the flag to add more patterns.
40    #[cfg(feature = "sparse-checkout")]
41    #[arg(long = "sparse", value_name = "PATTERN", num_args = 1..)]
42    sparse: Vec<String>,
43    /// Check out `<branch>` instead of the remote's default branch. Must
44    /// name a branch the remote actually advertises; unlike the default
45    /// heuristic (current default branch, falling back to whatever the
46    /// remote advertises first) this never silently substitutes another
47    /// branch.
48    #[arg(short = 'b', long = "branch", value_name = "NAME")]
49    branch: Option<String>,
50    /// Name the cloned remote `<name>` in the new repo's `.mkit/config`
51    /// instead of the implicit flat `default` remote (mirrors `mkit
52    /// remote add <name> <url>`).
53    #[arg(short = 'o', long = "origin", value_name = "NAME")]
54    origin: Option<String>,
55    /// Remote URL (e.g. `mkit+file:///abs/path`).
56    url: String,
57    /// Destination directory. Defaults to the final URL segment.
58    dir: Option<String>,
59    /// Skip Ed25519 signature verification on fetched commits/remixes/tags
60    /// (issue #692). Verification is ON by default and fails closed on an
61    /// unsigned or invalid signature — this flag, or the user-scoped
62    /// `pull.require_signed = false` config, is the only way to opt out.
63    #[arg(long = "no-verify-signatures")]
64    no_verify_signatures: bool,
65    /// Suppress transfer progress output on stderr (#711).
66    #[arg(short = 'q', long)]
67    quiet: bool,
68}
69
70#[must_use]
71#[allow(clippy::too_many_lines)] // linear flow: parse + init + pull + report
72pub fn run(args: &[String]) -> u8 {
73    let opts = match clap_shim::parse::<CloneOpts>("mkit clone", args) {
74        Ok(o) => o,
75        Err(code) => return code,
76    };
77    if opts.depth.is_some() {
78        return super::usage_error("mkit clone: --depth is not yet wired");
79    }
80    // `--sparse` no longer rejects — the patterns are persisted to
81    // `.mkit/sparse-checkout` after the pack pull lands, and the next
82    // `mkit checkout` honours them. Sparse fetch over the wire is
83    // wired through `mkit checkout --sparse` itself.
84    let url = opts.url.as_str();
85    let origin_name = match validate_clone_inputs(&opts) {
86        Ok(name) => name,
87        Err(code) => return code,
88    };
89    let target: PathBuf = match opts.dir.as_deref() {
90        Some(d) => PathBuf::from(d),
91        None => PathBuf::from(derive_dir_from_url(url)),
92    };
93    if target.exists() {
94        return emit_err(
95            &format!("destination '{}' already exists", target.display()),
96            exit::CANTCREAT,
97        );
98    }
99    // git prints this before doing any work; match the shape. Honest
100    // per-object transfer progress (#711) streams on stderr during the
101    // pull below, and mkit's own object-transfer summary follows at the
102    // end — mkit deliberately never fabricates git's
103    // Enumerating/Counting/Compressing/`Total N (delta D)` lines (see
104    // docs/PARITY.md).
105    {
106        let mut stderr = std::io::stderr().lock();
107        let _ = writeln!(stderr, "Cloning into '{}'...", target.display());
108    }
109    if let Err(e) = fs::create_dir_all(&target) {
110        return emit_err(
111            &format!("create {}: {e}", target.display()),
112            exit::CANTCREAT,
113        );
114    }
115    let target_layout = match crate::commands::resolve_layout(&target) {
116        Ok(layout) => layout,
117        Err(code) => return code,
118    };
119    match ObjectStore::init(&target_layout) {
120        Ok(_) => {}
121        Err(StoreError::AlreadyInitialized) => {
122            return emit_err("already a mkit repository", exit::GENERAL_ERROR);
123        }
124        Err(e) => return emit_err(&format!("init: {e}"), exit::CANTCREAT),
125    }
126    if let Err(e) = refs::init(&target_layout) {
127        return emit_err(&format!("refs init: {e}"), exit::CANTCREAT);
128    }
129    let mut cfg = Config::with_defaults();
130    if origin_name == config::DEFAULT_REMOTE_NAME {
131        url.clone_into(&mut cfg.remote_endpoint);
132        cfg.remote_type = scheme_of(url).unwrap_or_default().to_string();
133    } else {
134        // A non-default `-o <name>` is a genuine named remote — persist it
135        // the same way `mkit remote add <name> <url>` would, so `pull_all`
136        // below (called with this same name) resolves tracking refs under
137        // `refs/remotes/<name>/*` consistently with what `.mkit/config`
138        // records.
139        cfg.remotes.insert(
140            origin_name.clone(),
141            RemoteEntry {
142                url: url.to_string(),
143                remote_type: scheme_of(url).unwrap_or_default().to_string(),
144            },
145        );
146    }
147    if let Err(e) = config::write(&target_layout, &cfg) {
148        return emit_err(&format!("write config: {e}"), exit::CANTCREAT);
149    }
150
151    // Issue #389: clone establishes trust for a brand-new endpoint, so it
152    // bypasses `open_trusted`'s credential gate — but it must still thread
153    // the per-repo `ssh.*` trust-pinning keys into the spawned `ssh(1)`.
154    // Routing through `open_with_config` keeps that resolution in the one
155    // shared chokepoint instead of re-deriving it here. The keys are
156    // user-scoped (REPO_FORBIDDEN_KEYS), so `read_or_default` against the
157    // freshly-initialised destination still picks them up.
158    let merged = match config::read_or_default(&target_layout) {
159        Ok(merged) => merged,
160        Err(e) => return emit_err(&format!("read config: {e}"), exit::CONFIG_ERROR),
161    };
162    // Fail closed by default (issue #692): verify unless `--no-verify-signatures`
163    // or the user-scoped `pull.require_signed = false` config opted out.
164    // `merged` only ever carries user-scoped + built-in values here (the
165    // repo config we just wrote holds only `remote_endpoint`/`remote_type`),
166    // so a hostile remote cannot influence this via its own repo config —
167    // there isn't one yet.
168    let require_signed = !opts.no_verify_signatures && merged.pull_require_signed_or_default();
169    let pull_outcome = match remote_dispatch::open_with_config(url, &merged, &target_layout) {
170        Ok(tx) => {
171            let _progress = crate::progress::start(
172                "Unpacking objects",
173                None,
174                crate::progress::should_report(opts.quiet),
175            );
176            remote_dispatch::pull_all_with(
177                &target,
178                tx.as_ref(),
179                &origin_name,
180                opts.branch.as_deref(),
181                require_signed,
182            )
183        }
184        Err(e) => return emit_err(&format!("open remote: {e}"), exit::PROTOCOL_ERROR),
185    };
186    let n = match pull_outcome {
187        Ok(n) => n,
188        Err(remote_dispatch::DispatchError::Interrupted) => {
189            return emit_err("clone: interrupted", exit::TEMPFAIL);
190        }
191        Err(e @ remote_dispatch::DispatchError::UnsignedOrInvalidObject { .. }) => {
192            return emit_err(&format!("pull: {e}"), exit::DATAERR);
193        }
194        Err(e) => return emit_err(&format!("pull: {e}"), exit::GENERAL_ERROR),
195    };
196
197    // If `--sparse` was supplied, persist the patterns to
198    // `.mkit/sparse-checkout` so the next checkout honours them, and
199    // run a verifiable sparse checkout against HEAD right now.
200    #[cfg(feature = "sparse-checkout")]
201    if !opts.sparse.is_empty()
202        && let Err((msg, code)) = apply_sparse_after_clone(&target, &opts.sparse)
203    {
204        return emit_err(&msg, code);
205    }
206
207    let mut stderr = std::io::stderr().lock();
208    let _ = writeln!(
209        stderr,
210        "cloned {n} ref(s) from {url} into {}",
211        target.display()
212    );
213    exit::OK
214}
215
216/// Persist the supplied sparse patterns to `.mkit/sparse-checkout` and
217/// drive a verifiable sparse re-materialise against the freshly-cloned
218/// HEAD. Mirrors the inline sparse path used by `mkit checkout
219/// --sparse`, but the entry point is "we just landed a full clone".
220#[cfg(feature = "sparse-checkout")]
221fn apply_sparse_after_clone(
222    target: &std::path::Path,
223    patterns: &[String],
224) -> Result<(), (String, u8)> {
225    use crate::sparse_cache::{SparseBuildError, SparseOutcome, load_or_build};
226    use mkit_core::object::Object as CoreObject;
227    use mkit_core::ops::restore::{
228        RestoreOptions, parse_sparse_patterns, restore_tree_to_worktree, write_sparse_checkout,
229    };
230    use mkit_core::store::ObjectStore;
231    use std::path::PathBuf as StdPathBuf;
232
233    let layout = mkit_core::layout::discover(target)
234        .map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))?;
235
236    // Persist patterns to .mkit/sparse-checkout for follow-up commands.
237    let pat_refs: Vec<&str> = patterns.iter().map(String::as_str).collect();
238    write_sparse_checkout(&layout, &pat_refs)
239        .map_err(|e| (format!("write sparse-checkout: {e}"), exit::CANTCREAT))?;
240
241    // Open store, resolve HEAD → tree.
242    let store = ObjectStore::open(&layout)
243        .map_err(|e| (format!("open store: {e}"), exit::GENERAL_ERROR))?;
244    let head = match mkit_core::refs::resolve_head(&layout) {
245        Ok(Some(h)) => h,
246        Ok(None) => return Ok(()), // fresh, no HEAD → nothing to materialise
247        Err(e) => return Err((format!("resolve HEAD: {e}"), exit::GENERAL_ERROR)),
248    };
249    let tree_hash = match store.read_object(&head) {
250        Ok(CoreObject::Commit(c)) => c.tree_hash,
251        Ok(CoreObject::Remix(r)) => r.tree_hash,
252        Ok(_) => return Err(("HEAD is not a commit".into(), exit::DATAERR)),
253        Err(e) => return Err((format!("read HEAD: {e}"), exit::GENERAL_ERROR)),
254    };
255
256    let tree = match store.read_object(&tree_hash) {
257        Ok(CoreObject::Tree(t)) => t,
258        Ok(_) => return Err(("HEAD tree not a tree".into(), exit::DATAERR)),
259        Err(e) => return Err((format!("read tree: {e}"), exit::GENERAL_ERROR)),
260    };
261
262    // Build + verify against the same filter the manifest binds to.
263    let mut filter: Vec<StdPathBuf> = Vec::with_capacity(patterns.len());
264    for raw in patterns {
265        let trimmed = raw.trim_start_matches('/').trim_end_matches('/');
266        if trimmed.is_empty() || trimmed.starts_with('!') {
267            continue;
268        }
269        filter.push(StdPathBuf::from(trimmed));
270    }
271    // Cache-aware: a hit for this exact (tree, filter) skips the
272    // expensive build_sparse + verify_sparse Merkle-bitmap
273    // reconstruction entirely (SPEC-SPARSE-CHECKOUT §8). A miss
274    // (including a stale filter or a corrupt cache entry) falls
275    // through to a fresh build and rewrites the cache.
276    match load_or_build(&layout, &tree, &filter) {
277        Ok(SparseOutcome::CacheHit) => {}
278        Ok(SparseOutcome::Built { store_error }) => {
279            if let Some(e) = store_error {
280                let mut stderr = std::io::stderr().lock();
281                let _ = writeln!(stderr, "warning: sparse cache write failed: {e}");
282            }
283        }
284        Err(SparseBuildError::Build(e)) => {
285            return Err((format!("sparse build: {e}"), exit::GENERAL_ERROR));
286        }
287        Err(SparseBuildError::VerifyFailed) => {
288            return Err((
289                "sparse build produced a manifest that fails verify".into(),
290                exit::GENERAL_ERROR,
291            ));
292        }
293    }
294
295    let joined = patterns.join("\n");
296    let restore_opts = RestoreOptions {
297        clean: true,
298        sparse_patterns: Some(parse_sparse_patterns(&joined)),
299    };
300    restore_tree_to_worktree(&store, &tree_hash, target, &restore_opts)
301        .map_err(|e| (format!("restore: {e}"), exit::CANTCREAT))?;
302    Ok(())
303}
304
305fn derive_dir_from_url(url: &str) -> String {
306    let trimmed = url.trim_end_matches('/');
307    let last = trimmed.rsplit('/').next().unwrap_or(trimmed);
308    let stripped = last.strip_suffix(".mkit").unwrap_or(last);
309    if stripped.is_empty() {
310        "repo".to_string()
311    } else {
312        stripped.to_string()
313    }
314}
315
316fn scheme_of(url: &str) -> Option<&'static str> {
317    for (prefix, kind) in [
318        ("mkit+file://", "file"),
319        ("mkit+https://", "http"),
320        ("mkit+s3://", "s3"),
321        ("mkit+ssh://", "ssh"),
322        ("mkit+memory://", "memory"),
323    ] {
324        if url.starts_with(prefix) {
325            return Some(kind);
326        }
327    }
328    None
329}
330
331/// Validate `--url`, `-o`/`--origin`, and `-b`/`--branch` before any
332/// filesystem or config side effect. `-o`/`--origin` names the remote
333/// that gets persisted to the new repo's `.mkit/config`; `-b`/`--branch`
334/// selects which advertised branch to land HEAD on. Both flow into
335/// config/ref writes, so they get the same config-injection guard as
336/// the URL, plus their own shape checks. Returns the resolved origin
337/// name (`"default"` when `-o` was not given) on success.
338fn validate_clone_inputs(opts: &CloneOpts) -> Result<String, u8> {
339    let url = opts.url.as_str();
340    // Reject control characters (newline et al.) before the URL is
341    // persisted to `.mkit/config` via `config::write` (which emits values
342    // raw) — a newline would inject extra `key = value` lines into the
343    // config (config injection). Mirrors the `mkit remote add` check.
344    if config::validate_value(url).is_err() {
345        return Err(emit_err(
346            &format!("invalid remote URL '{url}': contains control characters"),
347            exit::PROTOCOL_ERROR,
348        ));
349    }
350    let origin_name = match opts.origin.as_deref() {
351        Some(name) => {
352            validate_origin_name(name)?;
353            name.to_owned()
354        }
355        None => config::DEFAULT_REMOTE_NAME.to_owned(),
356    };
357    if let Some(branch) = opts.branch.as_deref() {
358        if config::validate_value(branch).is_err() {
359            return Err(emit_err(
360                &format!("invalid branch name '{branch}': contains control characters"),
361                exit::PROTOCOL_ERROR,
362            ));
363        }
364        if !refs::validate_ref_name(branch) {
365            return Err(emit_err(
366                &format!("invalid branch name '{branch}': not a valid ref name"),
367                exit::PROTOCOL_ERROR,
368            ));
369        }
370    }
371    Ok(origin_name)
372}
373
374/// Validate a `-o`/`--origin` name. Unlike `mkit remote add`'s
375/// `validate_remote_name`, the reserved name `default` IS accepted here
376/// — it is the (also valid) way to spell "use the flat default remote",
377/// matching clone's pre-flag behaviour. Any other name must be a
378/// dot-free ref-safe name, same as a named `remote add`, since it
379/// becomes a `remote.<name>.*` config key and a
380/// `refs/remotes/<name>/*` path component.
381fn validate_origin_name(name: &str) -> Result<(), u8> {
382    if config::validate_value(name).is_err() {
383        return Err(emit_err(
384            &format!("invalid remote name '{name}': contains control characters"),
385            exit::PROTOCOL_ERROR,
386        ));
387    }
388    if name != config::DEFAULT_REMOTE_NAME
389        && (!mkit_core::refs::validate_ref_name(name) || name.contains('.'))
390    {
391        return Err(emit_err(
392            &format!(
393                "invalid remote name '{name}': must be a dot-free ref-safe name \
394                 (or the reserved `default`)"
395            ),
396            exit::PROTOCOL_ERROR,
397        ));
398    }
399    Ok(())
400}
401
402use super::error as emit_err;