radicle_cli/commands/
clone.rs

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    /// The RID of the repository.
60    id: RepoId,
61    /// The target directory for the repository to be cloned into.
62    directory: Option<PathBuf>,
63    /// The seeding scope of the repository.
64    scope: Scope,
65    /// Sync settings.
66    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                    // We keep this flag here for consistency though it doesn't have any effect,
100                    // since the command is fully non-interactive.
101                }
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                // Parse <directory> once <rid> has been parsed
113                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(); // SAFETY: The working copy is not bare.
167
168    // Configure repository and setup tracking for repository delegates.
169    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        // N.b. fail if the path exists and is not empty
249        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        // Checkout.
275        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    // Seed repository.
310    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            // N.b. We only need to reach 1 replica in order for a clone to be
320            // considered successful.
321            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}