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 }