1#![allow(clippy::or_fun_call)]
2use std::ffi::OsString;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5use std::time;
6
7use anyhow::anyhow;
8use radicle::issue::cache::Issues as _;
9use radicle::patch::cache::Patches as _;
10use thiserror::Error;
11
12use radicle::git::raw;
13use radicle::identity::doc;
14use radicle::identity::doc::RepoId;
15use radicle::node;
16use radicle::node::policy::Scope;
17use radicle::node::{Handle as _, Node};
18use radicle::prelude::*;
19use radicle::rad;
20use radicle::storage;
21use radicle::storage::RemoteId;
22use radicle::storage::{HasRepoId, RepositoryError};
23
24use crate::commands::checkout;
25use crate::commands::sync;
26use crate::node::SyncSettings;
27use crate::project;
28use crate::terminal as term;
29use crate::terminal::args::{Args, Error, Help};
30use crate::terminal::Element as _;
31
32pub const HELP: Help = Help {
33 name: "clone",
34 description: "Clone a Radicle repository",
35 version: env!("RADICLE_VERSION"),
36 usage: r#"
37Usage
38
39 rad clone <rid> [<directory>] [--scope <scope>] [<option>...]
40
41 The `clone` command will use your local node's routing table to find seeds from
42 which it can clone the repository.
43
44 For private repositories, use the `--seed` options, to clone directly
45 from known seeds in the privacy set.
46
47Options
48
49 --bare Make a bare repository
50 --scope <scope> Follow scope: `followed` or `all` (default: all)
51 -s, --seed <nid> Clone from this seed (may be specified multiple times)
52 --timeout <secs> Timeout for fetching repository (default: 9)
53 --help Print help
54
55"#,
56};
57
58#[derive(Debug)]
59pub struct Options {
60 id: RepoId,
62 directory: Option<PathBuf>,
64 scope: Scope,
66 sync: SyncSettings,
68 bare: bool,
69}
70
71impl Args for Options {
72 fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
73 use lexopt::prelude::*;
74
75 let mut parser = lexopt::Parser::from_args(args);
76 let mut id: Option<RepoId> = None;
77 let mut scope = Scope::All;
78 let mut sync = SyncSettings::default();
79 let mut directory = None;
80 let mut bare = false;
81
82 while let Some(arg) = parser.next()? {
83 match arg {
84 Long("seed") | Short('s') => {
85 let value = parser.value()?;
86 let value = term::args::nid(&value)?;
87
88 sync.seeds.insert(value);
89 }
90 Long("scope") => {
91 let value = parser.value()?;
92
93 scope = term::args::parse_value("scope", value)?;
94 }
95 Long("timeout") => {
96 let value = parser.value()?;
97 let secs = term::args::number(&value)?;
98
99 sync.timeout = time::Duration::from_secs(secs as u64);
100 }
101 Long("no-confirm") => {
102 }
105 Long("bare") => {
106 bare = true;
107 }
108 Long("help") | Short('h') => {
109 return Err(Error::Help.into());
110 }
111 Value(val) if id.is_none() => {
112 let val = val.to_string_lossy();
113 let val = val.strip_prefix("rad://").unwrap_or(&val);
114 let val = RepoId::from_str(val)?;
115
116 id = Some(val);
117 }
118 Value(val) if id.is_some() && directory.is_none() => {
120 directory = Some(Path::new(&val).to_path_buf());
121 }
122 _ => return Err(anyhow!(arg.unexpected())),
123 }
124 }
125 let id =
126 id.ok_or_else(|| anyhow!("to clone, an RID must be provided; see `rad clone --help`"))?;
127
128 Ok((
129 Options {
130 id,
131 directory,
132 scope,
133 sync,
134 bare,
135 },
136 vec![],
137 ))
138 }
139}
140
141pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
142 let profile = ctx.profile()?;
143 let mut node = radicle::Node::new(profile.socket());
144
145 if !node.is_running() {
146 anyhow::bail!(
147 "to clone a repository, your node must be running. To start it, run `rad node start`"
148 );
149 }
150
151 let Success {
152 working_copy: working,
153 repository: repo,
154 doc,
155 project: proj,
156 } = clone(
157 options.id,
158 options.directory.clone(),
159 options.scope,
160 options.sync.with_profile(&profile),
161 &mut node,
162 &profile,
163 options.bare,
164 )?
165 .print_or_success()
166 .ok_or_else(|| anyhow::anyhow!("failed to clone {}", options.id))?;
167 let delegates = doc
168 .delegates()
169 .iter()
170 .map(|d| **d)
171 .filter(|id| id != profile.id())
172 .collect::<Vec<_>>();
173 let default_branch = proj.default_branch().clone();
174 let path = if !options.bare {
175 working.workdir().unwrap()
176 } else {
177 working.path()
178 };
179
180 radicle::git::configure_repository(&working)?;
182 checkout::setup_remotes(
183 project::SetupRemote {
184 rid: options.id,
185 tracking: Some(default_branch),
186 repo: &working,
187 fetch: true,
188 },
189 &delegates,
190 &profile,
191 )?;
192
193 term::success!(
194 "Repository successfully cloned under {}",
195 term::format::dim(Path::new(".").join(path).display())
196 );
197
198 let mut info: term::Table<1, term::Line> = term::Table::new(term::TableOptions::bordered());
199 info.push([term::format::bold(proj.name()).into()]);
200 info.push([term::format::italic(proj.description()).into()]);
201
202 let issues = term::cob::issues(&profile, &repo)?.counts()?;
203 let patches = term::cob::patches(&profile, &repo)?.counts()?;
204
205 info.push([term::Line::spaced([
206 term::format::tertiary(issues.open).into(),
207 term::format::default("issues").into(),
208 term::format::dim("ยท").into(),
209 term::format::tertiary(patches.open).into(),
210 term::format::default("patches").into(),
211 ])]);
212 info.print();
213
214 let location = options
215 .directory
216 .map_or(proj.name().to_string(), |loc| loc.display().to_string());
217 term::info!(
218 "Run {} to go to the repository directory.",
219 term::format::command(format!("cd ./{location}")),
220 );
221
222 Ok(())
223}
224
225#[derive(Error, Debug)]
226enum CloneError {
227 #[error("node: {0}")]
228 Node(#[from] node::Error),
229 #[error("checkout: {0}")]
230 Checkout(#[from] rad::CheckoutError),
231 #[error("no seeds found for {0}")]
232 NoSeeds(RepoId),
233 #[error("fetch: {0}")]
234 Fetch(#[from] sync::FetchError),
235}
236
237struct Checkout {
238 id: RepoId,
239 remote: RemoteId,
240 path: PathBuf,
241 repository: storage::git::Repository,
242 doc: Doc,
243 project: Project,
244 bare: bool,
245}
246
247impl Checkout {
248 fn new(
249 repository: storage::git::Repository,
250 profile: &Profile,
251 directory: Option<PathBuf>,
252 bare: bool,
253 ) -> Result<Self, CheckoutFailure> {
254 let rid = repository.rid();
255 let doc = repository
256 .identity_doc()
257 .map_err(|err| CheckoutFailure::Identity { rid, err })?;
258 let proj = doc
259 .project()
260 .map_err(|err| CheckoutFailure::Payload { rid, err })?;
261 let path = directory.unwrap_or_else(|| PathBuf::from(proj.name()));
262 if path.exists() && path.read_dir().map_or(true, |mut dir| dir.next().is_some()) {
264 return Err(CheckoutFailure::Exists { rid, path });
265 }
266
267 Ok(Self {
268 id: rid,
269 remote: *profile.id(),
270 path,
271 repository,
272 doc: doc.doc,
273 project: proj,
274 bare,
275 })
276 }
277
278 fn destination(&self) -> &PathBuf {
279 &self.path
280 }
281
282 fn run<S>(self, storage: &S) -> Result<CloneResult, rad::CheckoutError>
283 where
284 S: storage::ReadStorage,
285 {
286 let destination = self.destination().to_path_buf();
287 let mut spinner = term::spinner(format!(
289 "Creating checkout in ./{}..",
290 term::format::tertiary(destination.display())
291 ));
292 match rad::checkout(self.id, &self.remote, self.path, storage, self.bare) {
293 Err(err) => {
294 spinner.message(format!(
295 "Failed to checkout in ./{}",
296 term::format::tertiary(destination.display())
297 ));
298 spinner.failed();
299 Err(err)
300 }
301 Ok(working_copy) => {
302 spinner.finish();
303 Ok(CloneResult::Success(Success {
304 working_copy,
305 repository: self.repository,
306 doc: self.doc,
307 project: self.project,
308 }))
309 }
310 }
311 }
312}
313
314fn clone(
315 id: RepoId,
316 directory: Option<PathBuf>,
317 scope: Scope,
318 settings: SyncSettings,
319 node: &mut Node,
320 profile: &Profile,
321 bare: bool,
322) -> Result<CloneResult, CloneError> {
323 if node.seed(id, scope)? {
325 term::success!(
326 "Seeding policy updated for {} with scope '{scope}'",
327 term::format::tertiary(id)
328 );
329 }
330
331 match profile.storage.repository(id) {
332 Err(_) => {
333 let settings = settings.replicas(node::sync::ReplicationFactor::must_reach(1));
336 let result = sync::fetch(id, settings, node, profile)?;
337 match &result {
338 node::sync::FetcherResult::TargetReached(_) => {
339 profile.storage.repository(id).map_or_else(
340 |err| Ok(CloneResult::RepositoryMissing { rid: id, err }),
341 |repository| Ok(perform_checkout(repository, profile, directory, bare)?),
342 )
343 }
344 node::sync::FetcherResult::TargetError(failure) => {
345 Err(handle_fetch_error(id, failure))
346 }
347 }
348 }
349 Ok(repository) => Ok(perform_checkout(repository, profile, directory, bare)?),
350 }
351}
352
353fn perform_checkout(
354 repository: storage::git::Repository,
355 profile: &Profile,
356 directory: Option<PathBuf>,
357 bare: bool,
358) -> Result<CloneResult, rad::CheckoutError> {
359 Checkout::new(repository, profile, directory, bare).map_or_else(
360 |failure| Ok(CloneResult::Failure(failure)),
361 |checkout| checkout.run(&profile.storage),
362 )
363}
364
365fn handle_fetch_error(id: RepoId, failure: &node::sync::fetch::TargetMissed) -> CloneError {
366 term::warning(format!(
367 "Failed to fetch from {} seed(s).",
368 failure.progress().failed()
369 ));
370 for (node, reason) in failure.fetch_results().failed() {
371 term::warning(format!(
372 "{}: {}",
373 term::format::node_id_human(node),
374 term::format::yellow(reason),
375 ))
376 }
377 CloneError::NoSeeds(id)
378}
379
380enum CloneResult {
381 Success(Success),
382 RepositoryMissing { rid: RepoId, err: RepositoryError },
383 Failure(CheckoutFailure),
384}
385
386struct Success {
387 working_copy: raw::Repository,
388 repository: storage::git::Repository,
389 doc: Doc,
390 project: Project,
391}
392
393impl CloneResult {
394 fn print_or_success(self) -> Option<Success> {
395 match self {
396 CloneResult::Success(success) => Some(success),
397 CloneResult::RepositoryMissing { rid, err } => {
398 term::error(format!(
399 "failed to find repository in storage after fetching: {err}"
400 ));
401 term::hint(format!(
402 "try `rad inspect {rid}` to see if the repository exists"
403 ));
404 None
405 }
406 CloneResult::Failure(failure) => {
407 failure.print();
408 None
409 }
410 }
411 }
412}
413
414#[derive(Debug)]
415pub enum CheckoutFailure {
416 Identity { rid: RepoId, err: RepositoryError },
417 Payload { rid: RepoId, err: doc::PayloadError },
418 Exists { rid: RepoId, path: PathBuf },
419}
420
421impl CheckoutFailure {
422 fn print(&self) {
423 match self {
424 CheckoutFailure::Identity { rid, err } => {
425 term::error(format!(
426 "failed to get the identity document of {rid} after fetching: {err}"
427 ));
428 term::hint(format!(
429 "try `rad inspect {rid} --identity`, if this works then try `rad checkout {rid}`"
430 ));
431 }
432 CheckoutFailure::Payload { rid, err } => {
433 term::error(format!(
434 "failed to get the project payload of {rid} after fetching: {err}"
435 ));
436 term::hint(format!(
437 "try `rad inspect {rid} --payload`, if this works then try `rad checkout {rid}`"
438 ));
439 }
440 CheckoutFailure::Exists { rid, path } => {
441 term::error(format!(
442 "refusing to checkout repository to {}, since it already exists",
443 path.display()
444 ));
445 term::hint(format!("try `rad checkout {rid}` in a new directory"))
446 }
447 }
448 }
449}