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 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 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 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 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 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}