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    /// 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                opts.quiet,
176            );
177            remote_dispatch::pull_all_with(
178                &target,
179                tx.as_ref(),
180                &origin_name,
181                opts.branch.as_deref(),
182                require_signed,
183            )
184        }
185        Err(e) => return emit_err(&format!("open remote: {e}"), exit::PROTOCOL_ERROR),
186    };
187    let n = match pull_outcome {
188        Ok(n) => n,
189        Err(remote_dispatch::DispatchError::Interrupted) => {
190            return emit_err("clone: interrupted", exit::TEMPFAIL);
191        }
192        Err(e @ remote_dispatch::DispatchError::UnsignedOrInvalidObject { .. }) => {
193            return emit_err(&format!("pull: {e}"), exit::DATAERR);
194        }
195        Err(e) => return emit_err(&format!("pull: {e}"), exit::GENERAL_ERROR),
196    };
197
198    // If `--sparse` was supplied, persist the patterns to
199    // `.mkit/sparse-checkout` so the next checkout honours them, and
200    // run a verifiable sparse checkout against HEAD right now.
201    #[cfg(feature = "sparse-checkout")]
202    if !opts.sparse.is_empty()
203        && let Err((msg, code)) = apply_sparse_after_clone(&target, &opts.sparse)
204    {
205        return emit_err(&msg, code);
206    }
207
208    let mut stderr = std::io::stderr().lock();
209    let _ = writeln!(
210        stderr,
211        "cloned {n} ref(s) from {url} into {}",
212        target.display()
213    );
214    exit::OK
215}
216
217/// Persist the supplied sparse patterns to `.mkit/sparse-checkout` and
218/// drive a verifiable sparse re-materialise against the freshly-cloned
219/// HEAD. Mirrors the inline sparse path used by `mkit checkout
220/// --sparse`, but the entry point is "we just landed a full clone".
221#[cfg(feature = "sparse-checkout")]
222fn apply_sparse_after_clone(
223    target: &std::path::Path,
224    patterns: &[String],
225) -> Result<(), (String, u8)> {
226    use crate::sparse_cache::{SparseBuildError, SparseOutcome, load_or_build};
227    use mkit_core::object::Object as CoreObject;
228    use mkit_core::ops::restore::{
229        RestoreOptions, parse_sparse_patterns, restore_tree_to_worktree_with, write_sparse_checkout,
230    };
231    use mkit_core::store::ObjectStore;
232    use std::path::PathBuf as StdPathBuf;
233
234    let layout = mkit_core::layout::discover(target)
235        .map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))?;
236
237    // Persist patterns to .mkit/sparse-checkout for follow-up commands.
238    let pat_refs: Vec<&str> = patterns.iter().map(String::as_str).collect();
239    write_sparse_checkout(&layout, &pat_refs)
240        .map_err(|e| (format!("write sparse-checkout: {e}"), exit::CANTCREAT))?;
241
242    // Open store, resolve HEAD → tree.
243    let store = ObjectStore::open(&layout)
244        .map_err(|e| (format!("open store: {e}"), exit::GENERAL_ERROR))?;
245    let head = match mkit_core::refs::resolve_head(&layout) {
246        Ok(Some(h)) => h,
247        Ok(None) => return Ok(()), // fresh, no HEAD → nothing to materialise
248        Err(e) => return Err((format!("resolve HEAD: {e}"), exit::GENERAL_ERROR)),
249    };
250    let tree_hash = match store.read_object(&head) {
251        Ok(CoreObject::Commit(c)) => c.tree_hash,
252        Ok(CoreObject::Remix(r)) => r.tree_hash,
253        Ok(_) => return Err(("HEAD is not a commit".into(), exit::DATAERR)),
254        Err(e) => return Err((format!("read HEAD: {e}"), exit::GENERAL_ERROR)),
255    };
256
257    let tree = match store.read_object(&tree_hash) {
258        Ok(CoreObject::Tree(t)) => t,
259        Ok(_) => return Err(("HEAD tree not a tree".into(), exit::DATAERR)),
260        Err(e) => return Err((format!("read tree: {e}"), exit::GENERAL_ERROR)),
261    };
262
263    // Preserve the CLI grammar verbatim. Unsupported sparse prefixes use the
264    // authenticated full metadata already loaded above.
265    let filter: Vec<StdPathBuf> = patterns.iter().map(StdPathBuf::from).collect();
266    match load_or_build(&layout, &tree, &filter) {
267        Ok(SparseOutcome::CacheHit | SparseOutcome::FullMetadata) => {}
268        Ok(SparseOutcome::Built { store_error }) => {
269            if let Some(e) = store_error {
270                let mut stderr = std::io::stderr().lock();
271                let _ = writeln!(stderr, "warning: sparse cache write failed: {e}");
272            }
273        }
274        Err(SparseBuildError::Build(e)) => {
275            return Err((format!("sparse build: {e}"), exit::GENERAL_ERROR));
276        }
277        Err(SparseBuildError::VerifyFailed) => {
278            return Err((
279                "sparse build produced a manifest that fails verify".into(),
280                exit::GENERAL_ERROR,
281            ));
282        }
283    }
284
285    let joined = patterns.join("\n");
286    let restore_opts = RestoreOptions {
287        clean: true,
288        sparse_patterns: Some(parse_sparse_patterns(&joined)),
289    };
290    restore_tree_to_worktree_with(
291        &store,
292        &tree_hash,
293        target,
294        &restore_opts,
295        &crate::restore_fanout::read_chunks_fanout,
296    )
297    .map_err(|e| (format!("restore: {e}"), exit::CANTCREAT))?;
298    Ok(())
299}
300
301fn derive_dir_from_url(url: &str) -> String {
302    let trimmed = url.trim_end_matches('/');
303    let last = trimmed.rsplit('/').next().unwrap_or(trimmed);
304    let stripped = last.strip_suffix(".mkit").unwrap_or(last);
305    if stripped.is_empty() {
306        "repo".to_string()
307    } else {
308        stripped.to_string()
309    }
310}
311
312fn scheme_of(url: &str) -> Option<&'static str> {
313    for (prefix, kind) in [
314        ("mkit+file://", "file"),
315        ("mkit+https://", "http"),
316        ("mkit+s3://", "s3"),
317        ("mkit+ssh://", "ssh"),
318        ("mkit+memory://", "memory"),
319    ] {
320        if url.starts_with(prefix) {
321            return Some(kind);
322        }
323    }
324    None
325}
326
327/// Validate `--url`, `-o`/`--origin`, and `-b`/`--branch` before any
328/// filesystem or config side effect. `-o`/`--origin` names the remote
329/// that gets persisted to the new repo's `.mkit/config`; `-b`/`--branch`
330/// selects which advertised branch to land HEAD on. Both flow into
331/// config/ref writes, so they get the same config-injection guard as
332/// the URL, plus their own shape checks. Returns the resolved origin
333/// name (`"default"` when `-o` was not given) on success.
334fn validate_clone_inputs(opts: &CloneOpts) -> Result<String, u8> {
335    let url = opts.url.as_str();
336    // Reject control characters (newline et al.) before the URL is
337    // persisted to `.mkit/config` via `config::write` (which emits values
338    // raw) — a newline would inject extra `key = value` lines into the
339    // config (config injection). Mirrors the `mkit remote add` check.
340    if config::validate_value(url).is_err() {
341        return Err(emit_err(
342            &format!("invalid remote URL '{url}': contains control characters"),
343            exit::PROTOCOL_ERROR,
344        ));
345    }
346    let origin_name = match opts.origin.as_deref() {
347        Some(name) => {
348            validate_origin_name(name)?;
349            name.to_owned()
350        }
351        None => config::DEFAULT_REMOTE_NAME.to_owned(),
352    };
353    if let Some(branch) = opts.branch.as_deref() {
354        if config::validate_value(branch).is_err() {
355            return Err(emit_err(
356                &format!("invalid branch name '{branch}': contains control characters"),
357                exit::PROTOCOL_ERROR,
358            ));
359        }
360        if !refs::validate_ref_name(branch) {
361            return Err(emit_err(
362                &format!("invalid branch name '{branch}': not a valid ref name"),
363                exit::PROTOCOL_ERROR,
364            ));
365        }
366    }
367    Ok(origin_name)
368}
369
370/// Validate a `-o`/`--origin` name. Unlike `mkit remote add`'s
371/// `validate_remote_name`, the reserved name `default` IS accepted here
372/// — it is the (also valid) way to spell "use the flat default remote",
373/// matching clone's pre-flag behaviour. Any other name must be a
374/// dot-free ref-safe name, same as a named `remote add`, since it
375/// becomes a `remote.<name>.*` config key and a
376/// `refs/remotes/<name>/*` path component.
377fn validate_origin_name(name: &str) -> Result<(), u8> {
378    if config::validate_value(name).is_err() {
379        return Err(emit_err(
380            &format!("invalid remote name '{name}': contains control characters"),
381            exit::PROTOCOL_ERROR,
382        ));
383    }
384    if name != config::DEFAULT_REMOTE_NAME
385        && (!mkit_core::refs::validate_ref_name(name) || name.contains('.'))
386    {
387        return Err(emit_err(
388            &format!(
389                "invalid remote name '{name}': must be a dot-free ref-safe name \
390                 (or the reserved `default`)"
391            ),
392            exit::PROTOCOL_ERROR,
393        ));
394    }
395    Ok(())
396}
397
398use super::error as emit_err;