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