1use 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 #[arg(long, value_name = "N")]
34 depth: Option<u32>,
35 #[cfg(feature = "sparse-checkout")]
41 #[arg(long = "sparse", value_name = "PATTERN", num_args = 1..)]
42 sparse: Vec<String>,
43 #[arg(short = 'b', long = "branch", value_name = "NAME")]
49 branch: Option<String>,
50 #[arg(short = 'o', long = "origin", value_name = "NAME")]
54 origin: Option<String>,
55 url: String,
57 dir: Option<String>,
59 #[arg(long = "no-verify-signatures")]
64 no_verify_signatures: bool,
65 #[arg(short = 'q', long)]
67 quiet: bool,
68}
69
70#[must_use]
71#[allow(clippy::too_many_lines)] pub 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 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 {
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 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 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 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 #[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#[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 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 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(()), 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 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 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
331fn validate_clone_inputs(opts: &CloneOpts) -> Result<String, u8> {
339 let url = opts.url.as_str();
340 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
374fn 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;