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::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    /// The RID of the repository.
61    id: RepoId,
62    /// The target directory for the repository to be cloned into.
63    directory: Option<PathBuf>,
64    /// The seeding scope of the repository.
65    scope: Scope,
66    /// Sync settings.
67    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                    // We keep this flag here for consistency though it doesn't have any effect,
103                    // since the command is fully non-interactive.
104                }
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                // Parse <directory> once <rid> has been parsed
119                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    // Configure repository and setup tracking for repository delegates.
181    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        // N.b. fail if the path exists and is not empty
263        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        // Checkout.
288        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    // Seed repository.
324    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            // N.b. We only need to reach 1 replica in order for a clone to be
334            // considered successful.
335            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}