/ radicle-cli / src / commands / clone.rs
clone.rs
  1  #![allow(clippy::or_fun_call)]
  2  use std::ffi::OsString;
  3  use std::path::Path;
  4  use std::str::FromStr;
  5  use std::time;
  6  
  7  use anyhow::anyhow;
  8  use thiserror::Error;
  9  
 10  use radicle::cob;
 11  use radicle::git::raw;
 12  use radicle::identity::doc;
 13  use radicle::identity::doc::{DocError, Id};
 14  use radicle::node;
 15  use radicle::node::tracking::Scope;
 16  use radicle::node::{Handle as _, Node};
 17  use radicle::prelude::*;
 18  use radicle::rad;
 19  use radicle::storage;
 20  use radicle::storage::git::Storage;
 21  use radicle::storage::RepositoryError;
 22  
 23  use crate::commands::rad_checkout as checkout;
 24  use crate::commands::rad_sync as sync;
 25  use crate::project;
 26  use crate::terminal as term;
 27  use crate::terminal::args::{Args, Error, Help};
 28  use crate::terminal::Element as _;
 29  
 30  pub const HELP: Help = Help {
 31      name: "clone",
 32      description: "Clone a project",
 33      version: env!("CARGO_PKG_VERSION"),
 34      usage: r#"
 35  Usage
 36  
 37      rad clone <rid> [--scope <scope>] [<option>...]
 38  
 39  Options
 40  
 41      --scope <scope>   Tracking scope (default: all)
 42      --no-announce     Do not announce our new refs to the network
 43      --help            Print help
 44  
 45  "#,
 46  };
 47  
 48  #[derive(Debug)]
 49  pub struct Options {
 50      id: Id,
 51      announce: bool,
 52      scope: Scope,
 53  }
 54  
 55  impl Args for Options {
 56      fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
 57          use lexopt::prelude::*;
 58  
 59          let mut parser = lexopt::Parser::from_args(args);
 60          let mut id: Option<Id> = None;
 61          let mut announce = true;
 62          let mut scope = Scope::All;
 63  
 64          while let Some(arg) = parser.next()? {
 65              match arg {
 66                  Long("scope") => {
 67                      let value = parser.value()?;
 68  
 69                      scope = term::args::parse_value("scope", value)?;
 70                  }
 71                  Long("no-confirm") => {
 72                      // We keep this flag here for consistency though it doesn't have any effect,
 73                      // since the command is fully non-interactive.
 74                  }
 75                  Long("no-announce") => {
 76                      announce = false;
 77                  }
 78                  Long("announce") => {
 79                      announce = true;
 80                  }
 81                  Long("help") | Short('h') => {
 82                      return Err(Error::Help.into());
 83                  }
 84                  Value(val) if id.is_none() => {
 85                      let val = val.to_string_lossy();
 86                      let val = val.strip_prefix("rad://").unwrap_or(&val);
 87                      let val = Id::from_str(val)?;
 88  
 89                      id = Some(val);
 90                  }
 91                  _ => return Err(anyhow!(arg.unexpected())),
 92              }
 93          }
 94          let id =
 95              id.ok_or_else(|| anyhow!("to clone, an RID must be provided; see `rad clone --help`"))?;
 96  
 97          Ok((
 98              Options {
 99                  id,
100                  scope,
101                  announce,
102              },
103              vec![],
104          ))
105      }
106  }
107  
108  pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
109      let profile = ctx.profile()?;
110      let signer = term::signer(&profile)?;
111      let mut node = radicle::Node::new(profile.socket());
112  
113      if !node.is_running() {
114          anyhow::bail!(
115              "to clone a repository, your node must be running. To start it, run `rad node start`"
116          );
117      }
118  
119      let (working, repo, doc, proj) = clone(
120          options.id,
121          options.announce,
122          options.scope,
123          &mut node,
124          &signer,
125          &profile.storage,
126      )?;
127      let delegates = doc
128          .delegates
129          .iter()
130          .map(|d| **d)
131          .filter(|id| id != profile.id())
132          .collect::<Vec<_>>();
133      let default_branch = proj.default_branch().clone();
134      let path = working.workdir().unwrap(); // SAFETY: The working copy is not bare.
135  
136      // Configure repository and setup tracking for project delegates.
137      radicle::git::configure_repository(&working)?;
138      checkout::setup_remotes(
139          project::SetupRemote {
140              rid: options.id,
141              tracking: Some(default_branch),
142              repo: &working,
143              fetch: true,
144          },
145          &delegates,
146          &profile,
147      )?;
148  
149      term::success!(
150          "Repository successfully cloned under {}",
151          term::format::dim(Path::new(".").join(path).display())
152      );
153  
154      let mut info: term::Table<1, term::Line> = term::Table::new(term::TableOptions::bordered());
155      info.push([term::format::bold(proj.name()).into()]);
156      info.push([term::format::italic(proj.description()).into()]);
157  
158      let issues = cob::issue::Issues::open(&repo)?.counts()?;
159      let patches = cob::patch::Patches::open(&repo)?.counts()?;
160  
161      info.push([term::Line::spaced([
162          term::format::tertiary(issues.open).into(),
163          term::format::default("issues").into(),
164          term::format::dim("ยท").into(),
165          term::format::tertiary(patches.open).into(),
166          term::format::default("patches").into(),
167      ])]);
168      info.print();
169  
170      term::info!(
171          "Run {} to go to the project directory.",
172          term::format::command(format!("cd ./{}", proj.name())),
173      );
174  
175      Ok(())
176  }
177  
178  #[derive(Error, Debug)]
179  pub enum CloneError {
180      #[error("node: {0}")]
181      Node(#[from] node::Error),
182      #[error("fork: {0}")]
183      Fork(#[from] rad::ForkError),
184      #[error("storage: {0}")]
185      Storage(#[from] storage::Error),
186      #[error("checkout: {0}")]
187      Checkout(#[from] rad::CheckoutError),
188      #[error("identity document error: {0}")]
189      Doc(#[from] DocError),
190      #[error("payload: {0}")]
191      Payload(#[from] doc::PayloadError),
192      #[error(transparent)]
193      Repository(#[from] RepositoryError),
194      #[error("repository {0} not found")]
195      NotFound(Id),
196      #[error("no seeds found for {0}")]
197      NoSeeds(Id),
198  }
199  
200  pub fn clone<G: Signer>(
201      id: Id,
202      announce: bool,
203      scope: Scope,
204      node: &mut Node,
205      signer: &G,
206      storage: &Storage,
207  ) -> Result<
208      (
209          raw::Repository,
210          storage::git::Repository,
211          Doc<Verified>,
212          Project,
213      ),
214      CloneError,
215  > {
216      let me = *signer.public_key();
217  
218      // Track.
219      if node.track_repo(id, scope)? {
220          term::success!(
221              "Tracking relationship established for {} with scope '{scope}'",
222              term::format::tertiary(id)
223          );
224      }
225  
226      let results = sync::fetch(
227          id,
228          sync::RepoSync::default(),
229          time::Duration::from_secs(9),
230          node,
231      )?;
232      let Ok(repository) = storage.repository(id) else {
233          // If we don't have the project locally, even after attempting to fetch,
234          // there's nothing we can do.
235          if results.is_empty() {
236              return Err(CloneError::NoSeeds(id));
237          } else {
238              return Err(CloneError::NotFound(id));
239          }
240      };
241  
242      // Create a local fork of the project, under our own id, unless we have one already.
243      if repository.remote(signer.public_key()).is_err() {
244          let mut spinner = term::spinner(format!(
245              "Forking under {}..",
246              term::format::tertiary(term::format::node(&me))
247          ));
248          rad::fork(id, signer, &storage)?;
249  
250          if announce {
251              if let Err(e) = node.announce_refs(id) {
252                  spinner.message("Announcing fork..");
253                  spinner.error(e);
254              } else {
255                  spinner.finish();
256              }
257          } else {
258              spinner.finish();
259          }
260      }
261  
262      let doc = repository.identity_doc()?;
263      let proj = doc.project()?;
264      let path = Path::new(proj.name());
265  
266      if results.success().next().is_none() {
267          if results.failed().next().is_some() {
268              term::warning("Fetching failed, local copy is potentially stale");
269          } else {
270              term::warning("No seeds found, local copy is potentially stale");
271          }
272      }
273  
274      // Checkout.
275      let spinner = term::spinner(format!(
276          "Creating checkout in ./{}..",
277          term::format::tertiary(path.display())
278      ));
279      let working = rad::checkout(id, &me, path, &storage)?;
280  
281      spinner.finish();
282  
283      Ok((working, repository, doc.into(), proj))
284  }