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