Skip to main content

radicle_cli/commands/
clone.rs

1pub mod args;
2
3use std::path::{Path, PathBuf};
4
5use radicle::issue::cache::Issues as _;
6use radicle::patch::cache::Patches as _;
7use thiserror::Error;
8
9use radicle::git::raw;
10use radicle::identity::doc;
11use radicle::identity::doc::RepoId;
12use radicle::node;
13use radicle::node::policy;
14use radicle::node::policy::Scope;
15use radicle::node::{Handle as _, Node};
16use radicle::prelude::*;
17use radicle::rad;
18use radicle::storage;
19use radicle::storage::RemoteId;
20use radicle::storage::{HasRepoId, RepositoryError};
21
22use crate::commands::checkout;
23use crate::commands::sync;
24use crate::node::SyncSettings;
25use crate::project;
26use crate::terminal as term;
27use crate::terminal::Element as _;
28
29pub use args::Args;
30
31pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
32    let profile = ctx.profile()?;
33    let mut node = radicle::Node::new(profile.socket());
34
35    if !node.is_running() {
36        anyhow::bail!(
37            "to clone a repository, your node must be running. To start it, run `rad node start`"
38        );
39    }
40
41    let Success {
42        working_copy: working,
43        repository: repo,
44        doc,
45        project: proj,
46    } = clone(
47        args.repo,
48        args.directory.clone(),
49        args.scope,
50        SyncSettings::from(args.sync).with_profile(&profile),
51        &mut node,
52        &profile,
53        args.bare,
54    )?
55    .print_or_success()
56    .ok_or_else(|| anyhow::anyhow!("failed to clone {}", args.repo))?;
57    let delegates = doc
58        .delegates()
59        .iter()
60        .map(|d| **d)
61        .filter(|id| id != profile.id())
62        .collect::<Vec<_>>();
63    let default_branch = proj.default_branch().clone();
64    let path = if !args.bare {
65        working.workdir().unwrap()
66    } else {
67        working.path()
68    };
69
70    // Configure repository and setup tracking for repository delegates.
71    radicle::git::configure_repository(&working)?;
72    checkout::setup_remotes(
73        project::SetupRemote {
74            rid: args.repo,
75            tracking: Some(default_branch),
76            repo: &working,
77            fetch: true,
78        },
79        &delegates,
80        &profile,
81    )?;
82
83    term::success!(
84        "Repository successfully cloned under {}",
85        term::format::dim(Path::new(".").join(path).display())
86    );
87
88    let mut info: term::Table<1, term::Line> = term::Table::new(term::TableOptions::bordered());
89    info.push([term::format::bold(proj.name()).into()]);
90    info.push([term::format::italic(proj.description()).into()]);
91
92    let issues = term::cob::issues(&profile, &repo)?.counts()?;
93    let patches = term::cob::patches(&profile, &repo)?.counts()?;
94
95    info.push([term::Line::spaced([
96        term::format::tertiary(issues.open).into(),
97        term::format::default("issues").into(),
98        term::format::dim("ยท").into(),
99        term::format::tertiary(patches.open).into(),
100        term::format::default("patches").into(),
101    ])]);
102    info.print();
103
104    let location = args
105        .directory
106        .map_or(proj.name().to_string(), |loc| loc.display().to_string());
107    term::info!(
108        "Run {} to go to the repository directory.",
109        term::format::command(format!("cd ./{location}")),
110    );
111
112    Ok(())
113}
114
115#[derive(Error, Debug)]
116enum CloneError {
117    #[error("node: {0}")]
118    Node(#[from] node::Error),
119    #[error("checkout: {0}")]
120    Checkout(#[from] rad::CheckoutError),
121    #[error("no seeds found for {0}")]
122    NoSeeds(RepoId),
123    #[error("fetch: {0}")]
124    Fetch(#[from] sync::FetchError),
125    #[error("policy store: {0}")]
126    PolicyStore(#[from] policy::store::Error),
127}
128
129struct Checkout {
130    id: RepoId,
131    remote: RemoteId,
132    path: PathBuf,
133    repository: storage::git::Repository,
134    doc: Doc,
135    project: Project,
136    bare: bool,
137}
138
139impl Checkout {
140    fn new(
141        repository: storage::git::Repository,
142        profile: &Profile,
143        directory: Option<PathBuf>,
144        bare: bool,
145    ) -> Result<Self, CheckoutFailure> {
146        let rid = repository.rid();
147        let doc = repository
148            .identity_doc()
149            .map_err(|err| CheckoutFailure::Identity { rid, err })?;
150        let proj = doc
151            .project()
152            .map_err(|err| CheckoutFailure::Payload { rid, err })?;
153        let path = directory.unwrap_or_else(|| PathBuf::from(proj.name()));
154        // N.b. fail if the path exists and is not empty
155        if path.exists() && path.read_dir().map_or(true, |mut dir| dir.next().is_some()) {
156            return Err(CheckoutFailure::Exists { rid, path });
157        }
158
159        Ok(Self {
160            id: rid,
161            remote: *profile.id(),
162            path,
163            repository,
164            doc: doc.doc,
165            project: proj,
166            bare,
167        })
168    }
169
170    fn destination(&self) -> &PathBuf {
171        &self.path
172    }
173
174    fn run<S>(self, storage: &S) -> Result<CloneResult, rad::CheckoutError>
175    where
176        S: storage::ReadStorage,
177    {
178        let destination = self.destination().to_path_buf();
179        // Checkout.
180        let mut spinner = term::spinner(format!(
181            "Creating checkout in ./{}..",
182            term::format::tertiary(destination.display())
183        ));
184        match rad::checkout(self.id, &self.remote, self.path, storage, self.bare) {
185            Err(err) => {
186                spinner.message(format!(
187                    "Failed to checkout in ./{}",
188                    term::format::tertiary(destination.display())
189                ));
190                spinner.failed();
191                Err(err)
192            }
193            Ok(working_copy) => {
194                spinner.finish();
195                Ok(CloneResult::Success(Success {
196                    working_copy,
197                    repository: self.repository,
198                    doc: self.doc,
199                    project: self.project,
200                }))
201            }
202        }
203    }
204}
205
206fn clone(
207    id: RepoId,
208    directory: Option<PathBuf>,
209    scope: Option<Scope>,
210    settings: SyncSettings,
211    node: &mut Node,
212    profile: &Profile,
213    bare: bool,
214) -> Result<CloneResult, CloneError> {
215    let scope = match scope {
216        Some(scope) => scope,
217        None => profile
218            .policies()?
219            .seed_policy(&id)?
220            .scope()
221            .unwrap_or(Scope::Followed),
222    };
223
224    // Seed repository.
225    if node.seed(id, scope)? {
226        term::success!(
227            "Seeding policy updated for {} with scope '{scope}'",
228            term::format::tertiary(id)
229        );
230    }
231
232    match profile.storage.repository(id) {
233        Err(_) => {
234            // N.b. We only need to reach 1 replica in order for a clone to be
235            // considered successful.
236            let settings = settings.replicas(node::sync::ReplicationFactor::must_reach(1));
237            let result = sync::fetch(id, settings, node, profile)?;
238            match &result {
239                node::sync::FetcherResult::TargetReached(_) => {
240                    profile.storage.repository(id).map_or_else(
241                        |err| Ok(CloneResult::RepositoryMissing { rid: id, err }),
242                        |repository| Ok(perform_checkout(repository, profile, directory, bare)?),
243                    )
244                }
245                node::sync::FetcherResult::TargetError(failure) => {
246                    Err(handle_fetch_error(id, failure))
247                }
248            }
249        }
250        Ok(repository) => Ok(perform_checkout(repository, profile, directory, bare)?),
251    }
252}
253
254fn perform_checkout(
255    repository: storage::git::Repository,
256    profile: &Profile,
257    directory: Option<PathBuf>,
258    bare: bool,
259) -> Result<CloneResult, rad::CheckoutError> {
260    Checkout::new(repository, profile, directory, bare).map_or_else(
261        |failure| Ok(CloneResult::Failure(failure)),
262        |checkout| checkout.run(&profile.storage),
263    )
264}
265
266fn handle_fetch_error(id: RepoId, failure: &node::sync::fetch::TargetMissed) -> CloneError {
267    term::warning(format!(
268        "Failed to fetch from {} seed(s).",
269        failure.progress().failed()
270    ));
271    for (node, reason) in failure.fetch_results().failed() {
272        term::warning(format!(
273            "{}: {}",
274            term::format::node_id_human(node),
275            term::format::yellow(reason),
276        ))
277    }
278    CloneError::NoSeeds(id)
279}
280
281enum CloneResult {
282    Success(Success),
283    RepositoryMissing { rid: RepoId, err: RepositoryError },
284    Failure(CheckoutFailure),
285}
286
287struct Success {
288    working_copy: raw::Repository,
289    repository: storage::git::Repository,
290    doc: Doc,
291    project: Project,
292}
293
294impl CloneResult {
295    fn print_or_success(self) -> Option<Success> {
296        match self {
297            CloneResult::Success(success) => Some(success),
298            CloneResult::RepositoryMissing { rid, err } => {
299                term::error(format!(
300                    "failed to find repository in storage after fetching: {err}"
301                ));
302                term::hint(format!(
303                    "try `rad inspect {rid}` to see if the repository exists"
304                ));
305                None
306            }
307            CloneResult::Failure(failure) => {
308                failure.print();
309                None
310            }
311        }
312    }
313}
314
315#[derive(Debug)]
316pub enum CheckoutFailure {
317    Identity { rid: RepoId, err: RepositoryError },
318    Payload { rid: RepoId, err: doc::PayloadError },
319    Exists { rid: RepoId, path: PathBuf },
320}
321
322impl CheckoutFailure {
323    fn print(&self) {
324        match self {
325            CheckoutFailure::Identity { rid, err } => {
326                term::error(format!(
327                    "failed to get the identity document of {rid} after fetching: {err}"
328                ));
329                term::hint(format!(
330                    "try `rad inspect {rid} --identity`, if this works then try `rad checkout {rid}`"
331                ));
332            }
333            CheckoutFailure::Payload { rid, err } => {
334                term::error(format!(
335                    "failed to get the project payload of {rid} after fetching: {err}"
336                ));
337                term::hint(format!(
338                    "try `rad inspect {rid} --payload`, if this works then try `rad checkout {rid}`"
339                ));
340            }
341            CheckoutFailure::Exists { rid, path } => {
342                term::error(format!(
343                    "refusing to checkout repository to {}, since it already exists",
344                    path.display()
345                ));
346                term::hint(format!("try `rad checkout {rid}` in a new directory"))
347            }
348        }
349    }
350}