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 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 #[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#[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 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 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(()), 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 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
327fn validate_clone_inputs(opts: &CloneOpts) -> Result<String, u8> {
335 let url = opts.url.as_str();
336 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
370fn 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;