1#![allow(clippy::or_fun_call)]
2#![allow(clippy::collapsible_else_if)]
3
4mod args;
5
6pub use args::Args;
7
8use std::collections::HashSet;
9use std::convert::TryFrom;
10use std::env;
11use std::str::FromStr;
12
13use anyhow::{anyhow, bail, Context as _};
14use serde_json as json;
15
16use radicle::crypto::ssh;
17use radicle::explorer::ExplorerUrl;
18use radicle::git::fmt::RefString;
19use radicle::git::raw;
20use radicle::git::raw::ErrorExt as _;
21use radicle::identity::project::ProjectName;
22use radicle::identity::{Doc, RepoId, Visibility};
23use radicle::node::events::UploadPack;
24use radicle::node::{Event, Handle, NodeId, DEFAULT_SUBSCRIBE_TIMEOUT};
25use radicle::storage::ReadStorage as _;
26use radicle::{profile, Node};
27
28use crate::commands;
29use crate::git;
30use crate::terminal as term;
31use crate::terminal::Interactive;
32
33pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
34 let profile = ctx.profile()?;
35 let cwd = env::current_dir()?;
36 let path = args.path.as_deref().unwrap_or(cwd.as_path());
37 let repo = match git::Repository::open(path) {
38 Ok(r) => r,
39 Err(e) if e.is_not_found() => {
40 anyhow::bail!("a Git repository was not found at the given path")
41 }
42 Err(e) => return Err(e.into()),
43 };
44 if let Ok((remote, _)) = git::rad_remote(&repo) {
45 if let Some(remote) = remote.url() {
46 bail!("repository is already initialized with remote {remote}");
47 }
48 }
49
50 if let Some(rid) = args.existing {
51 init_existing(repo, rid, args, &profile)
52 } else {
53 init(repo, args, &profile)
54 }
55}
56
57pub fn init(repo: git::Repository, args: Args, profile: &profile::Profile) -> anyhow::Result<()> {
58 let path = dunce::canonicalize(repo.workdir().unwrap_or_else(|| repo.path()))?;
59 let interactive = args.interactive();
60 let visibility = args.visibility();
61 let seed = args.seed();
62
63 let default_branch = match find_default_branch(&repo) {
64 Err(err @ DefaultBranchError::Head) => {
65 term::error(err);
66 term::hint("try `git checkout <default branch>` or set `git config set --local init.defaultBranch <default branch>`");
67 anyhow::bail!("aborting `rad init`")
68 }
69 Err(err @ DefaultBranchError::NoHead) => {
70 term::error(err);
71 term::hint("perhaps you need to create a branch?");
72 anyhow::bail!("aborting `rad init`")
73 }
74 Err(err) => anyhow::bail!(err),
75 Ok(branch) => branch,
76 };
77
78 term::headline(format!(
79 "Initializing{}radicle 👾 repository in {}..",
80 match visibility {
81 Some(ref visibility) => term::format::spaced(term::format::visibility(visibility)),
82 None => term::format::default(" ").into(),
83 },
84 term::format::dim(path.display())
85 ));
86
87 let name: ProjectName = match args.name {
88 Some(name) => name,
89 None => {
90 let default = path
91 .file_name()
92 .and_then(|f| f.to_str())
93 .and_then(|f| ProjectName::try_from(f).ok());
94 let name = term::input(
97 "Name",
98 default,
99 Some("The name of your repository, eg. 'acme'"),
100 )?;
101
102 name.ok_or_else(|| anyhow::anyhow!("A project name is required."))?
103 }
104 };
105 let description = match args.description {
106 Some(desc) => desc,
107 None => {
108 term::input("Description", None, Some("You may leave this blank"))?.unwrap_or_default()
109 }
110 };
111 let branch = match args.branch {
112 Some(branch) => branch,
113 None if interactive.yes() => term::input(
114 "Default branch",
115 Some(default_branch),
116 Some("Please specify an existing branch"),
117 )?
118 .unwrap_or_default(),
119 None => default_branch,
120 };
121 let branch = RefString::try_from(branch.clone())
122 .map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?;
123 let visibility = if let Some(v) = visibility {
124 v
125 } else {
126 let selected = term::select(
129 "Visibility",
130 &["public", "private"],
131 "Public repositories are accessible by anyone on the network after initialization",
132 )?;
133 Visibility::from_str(selected)?
134 };
135
136 let signer = term::signer(profile)?;
137 let mut node = radicle::Node::new(profile.socket());
138 let mut spinner = term::spinner("Initializing...");
139 let mut push_cmd = String::from("git push");
140
141 match radicle::rad::init(
142 &repo,
143 name,
144 &description,
145 branch.clone(),
146 visibility,
147 &signer,
148 &profile.storage,
149 ) {
150 Ok((rid, doc, _)) => {
151 let proj = doc.project()?;
152
153 spinner.message(format!(
154 "Repository {} created.",
155 term::format::highlight(proj.name())
156 ));
157 spinner.finish();
158
159 if args.verbose {
160 term::blob(json::to_string_pretty(&proj)?);
161 }
162 if seed {
165 profile.seed(rid, args.scope, &mut node)?;
166
167 if doc.is_public() {
168 profile.add_inventory(rid, &mut node)?;
169 }
170 }
171
172 if args.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() {
173 radicle::git::set_upstream(
175 &repo,
176 &*radicle::rad::REMOTE_NAME,
177 proj.default_branch(),
178 radicle::git::refs::workdir::branch(proj.default_branch()),
179 )?;
180 } else {
181 push_cmd = format!("git push {} {branch}", *radicle::rad::REMOTE_NAME);
182 }
183
184 if args.setup_signing {
185 self::setup_signing(profile.id(), &repo, interactive)?;
187 }
188
189 term::blank();
190 term::info!(
191 "Your Repository ID {} is {}.",
192 term::format::dim("(RID)"),
193 term::format::highlight(rid.urn())
194 );
195 let directory = if path == dunce::canonicalize(env::current_dir()?)? {
196 "this directory".to_owned()
197 } else {
198 term::format::tertiary(path.display()).to_string()
199 };
200 term::info!(
201 "You can show it any time by running {} from {directory}.",
202 term::format::command("rad .")
203 );
204 term::blank();
205
206 if let Err(e) = announce(rid, doc, &mut node, &profile.config) {
208 term::blank();
209 term::warning(format!(
210 "There was an error announcing your repository to the network: {e}"
211 ));
212 term::warning("Try again with `rad sync --announce`, or check your logs with `rad node logs`.");
213 term::blank();
214 }
215 term::info!("To push changes, run {}.", term::format::command(push_cmd));
216 }
217 Err(err) => {
218 spinner.failed();
219 anyhow::bail!(err);
220 }
221 }
222
223 Ok(())
224}
225
226pub fn init_existing(
227 working: git::Repository,
228 rid: RepoId,
229 args: Args,
230 profile: &profile::Profile,
231) -> anyhow::Result<()> {
232 let stored = profile.storage.repository(rid)?;
233 let project = stored.project()?;
234 let url = radicle::git::Url::from(rid);
235 let interactive = args.interactive();
236
237 radicle::git::configure_repository(&working)?;
238 radicle::git::configure_remote(
239 &working,
240 &radicle::rad::REMOTE_NAME,
241 &url,
242 &url.clone().with_namespace(profile.public_key),
243 )?;
244
245 if args.set_upstream {
246 radicle::git::set_upstream(
248 &working,
249 &*radicle::rad::REMOTE_NAME,
250 project.default_branch(),
251 radicle::git::refs::workdir::branch(project.default_branch()),
252 )?;
253 }
254
255 if args.setup_signing {
256 self::setup_signing(profile.id(), &working, interactive)?;
258 }
259
260 term::success!(
261 "Initialized existing repository {} in {}..",
262 term::format::tertiary(rid),
263 term::format::dim(
264 working
265 .workdir()
266 .unwrap_or_else(|| working.path())
267 .display()
268 ),
269 );
270
271 Ok(())
272}
273
274#[derive(Debug)]
275enum SyncResult<T> {
276 NodeStopped,
277 NoPeersConnected,
278 NotSynced,
279 Synced { result: T },
280}
281
282fn sync(
283 rid: RepoId,
284 node: &mut Node,
285 config: &profile::Config,
286) -> Result<SyncResult<Option<ExplorerUrl>>, radicle::node::Error> {
287 if !node.is_running() {
288 return Ok(SyncResult::NodeStopped);
289 }
290 let mut spinner = term::spinner("Updating inventory..");
291 let events = node.subscribe(DEFAULT_SUBSCRIBE_TIMEOUT)?;
294 let sessions = node.sessions()?;
295
296 spinner.message("Announcing..");
297
298 if !sessions.iter().any(|s| s.is_connected()) {
299 return Ok(SyncResult::NoPeersConnected);
300 }
301
302 for seed in config.preferred_seeds.iter() {
304 if !sessions.iter().any(|s| s.nid == seed.id) {
305 commands::node::control::connect(
306 node,
307 seed.id,
308 seed.addr.clone(),
309 radicle::node::DEFAULT_TIMEOUT,
310 )
311 .ok();
312 }
313 }
314 node.announce_inventory()?;
316
317 spinner.message("Syncing..");
318
319 let mut replicas = HashSet::new();
320 let mut upload_pack = term::upload_pack::UploadPack::new();
322
323 for e in events {
324 match e {
325 Ok(Event::RefsSynced {
326 remote, rid: rid_, ..
327 }) if rid == rid_ => {
328 term::success!("Repository successfully synced to {remote}");
329 replicas.insert(remote);
330 if config.preferred_seeds.iter().any(|s| s.id == remote) {
332 break;
333 }
334 }
335 Ok(Event::UploadPack(UploadPack::Write {
336 rid: rid_,
337 remote,
338 progress,
339 })) if rid == rid_ => {
340 log::debug!("Upload progress for {remote}: {progress}");
341 }
342 Ok(Event::UploadPack(UploadPack::PackProgress {
343 rid: rid_,
344 remote,
345 transmitted,
346 })) if rid == rid_ => spinner.message(upload_pack.transmitted(remote, transmitted)),
347 Ok(Event::UploadPack(UploadPack::Done {
348 rid: rid_,
349 remote,
350 status,
351 })) if rid == rid_ => {
352 log::debug!("Upload done for {rid} to {remote} with status: {status}");
353 spinner.message(upload_pack.done(&remote));
354 }
355 Ok(Event::UploadPack(UploadPack::Error {
356 rid: rid_,
357 remote,
358 err,
359 })) if rid == rid_ => {
360 term::warning(format!("Upload error for {rid} to {remote}: {err}"));
361 }
362 Ok(_) => {
363 }
365 Err(radicle::node::Error::TimedOut) => {
366 break;
367 }
368 Err(e) => {
369 spinner.error(&e);
370 return Err(e);
371 }
372 }
373 }
374
375 if !replicas.is_empty() {
376 spinner.message(format!(
377 "Repository successfully synced to {} node(s).",
378 replicas.len()
379 ));
380 spinner.finish();
381
382 for seed in config.preferred_seeds.iter() {
383 if replicas.contains(&seed.id) {
384 return Ok(SyncResult::Synced {
385 result: Some(config.public_explorer.url(seed.addr.host.to_string(), rid)),
386 });
387 }
388 }
389 Ok(SyncResult::Synced { result: None })
390 } else {
391 spinner.message("Repository successfully announced to the network.");
392 spinner.finish();
393
394 Ok(SyncResult::NotSynced)
395 }
396}
397
398pub fn announce(
399 rid: RepoId,
400 doc: Doc,
401 node: &mut Node,
402 config: &profile::Config,
403) -> anyhow::Result<()> {
404 if doc.is_public() {
405 match sync(rid, node, config) {
406 Ok(SyncResult::Synced {
407 result: Some(url), ..
408 }) => {
409 term::blank();
410 term::info!(
411 "Your repository has been synced to the network and is \
412 now discoverable by peers.",
413 );
414 term::info!("View it in your browser at:");
415 term::blank();
416 term::indented(term::format::tertiary(url));
417 term::blank();
418 }
419 Ok(SyncResult::Synced { result: None, .. }) => {
420 term::blank();
421 term::info!(
422 "Your repository has been synced to the network and is \
423 now discoverable by peers.",
424 );
425 if !config.preferred_seeds.is_empty() {
426 term::info!(
427 "Unfortunately, you were unable to replicate your repository to \
428 your preferred seeds."
429 );
430 }
431 }
432 Ok(SyncResult::NotSynced) => {
433 term::blank();
434 term::info!(
435 "Your repository has been announced to the network and is \
436 now discoverable by peers.",
437 );
438 term::info!(
439 "You can check for any nodes that have replicated your repository by running \
440 `rad sync status`."
441 );
442 term::blank();
443 }
444 Ok(SyncResult::NoPeersConnected) => {
445 term::blank();
446 term::info!(
447 "You are not connected to any peers. Your repository will be announced as soon as \
448 your node establishes a connection with the network.");
449 term::info!("Check for peer connections with `rad node status`.");
450 term::blank();
451 }
452 Ok(SyncResult::NodeStopped) => {
453 term::info!(
454 "Your repository will be announced to the network when you start your node."
455 );
456 term::info!(
457 "You can start your node with {}.",
458 term::format::command("rad node start")
459 );
460 }
461 Err(e) => {
462 return Err(e.into());
463 }
464 }
465 } else {
466 term::info!(
467 "You have created a {} repository.",
468 term::format::visibility(doc.visibility())
469 );
470 term::info!(
471 "This repository will only be visible to you, \
472 and to peers you explicitly allow.",
473 );
474 term::blank();
475 term::info!(
476 "To make it public, run {}.",
477 term::format::command("rad publish")
478 );
479 }
480
481 Ok(())
482}
483
484pub fn setup_signing(
486 node_id: &NodeId,
487 repo: &git::Repository,
488 interactive: Interactive,
489) -> anyhow::Result<()> {
490 const SIGNERS: &str = ".gitsigners";
491
492 let path = repo.path();
493 let config = path.join("config");
494
495 let key = ssh::fmt::fingerprint(node_id);
496 let yes = if !git::is_signing_configured(path)? {
497 term::headline(format!(
498 "Configuring radicle signing key {}...",
499 term::format::tertiary(key)
500 ));
501 true
502 } else if interactive.yes() {
503 term::confirm(format!(
504 "Configure radicle signing key {} in {}?",
505 term::format::tertiary(key),
506 term::format::tertiary(config.display()),
507 ))
508 } else {
509 true
510 };
511
512 if !yes {
513 return Ok(());
514 }
515
516 git::configure_signing(path, node_id)?;
517 term::success!(
518 "Signing configured in {}",
519 term::format::tertiary(config.display())
520 );
521
522 if let Some(repo) = repo.workdir() {
523 match git::write_gitsigners(repo, [node_id]) {
524 Ok(file) => {
525 git::ignore(repo, file.as_path())?;
526
527 term::success!("Created {} file", term::format::tertiary(file.display()));
528 }
529 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
530 let ssh_key = ssh::fmt::key(node_id);
531 let gitsigners = term::format::tertiary(SIGNERS);
532 term::success!("Found existing {} file", gitsigners);
533
534 let ssh_keys =
535 git::read_gitsigners(repo).context(format!("error reading {SIGNERS} file"))?;
536
537 if ssh_keys.contains(&ssh_key) {
538 term::success!("Signing key is already in {gitsigners} file");
539 } else if term::confirm(format!("Add signing key to {gitsigners}?")) {
540 git::add_gitsigners(repo, [node_id])?;
541 }
542 }
543 Err(err) => {
544 return Err(err.into());
545 }
546 }
547 } else {
548 term::notice!("Not writing {SIGNERS} file.")
549 }
550
551 Ok(())
552}
553
554#[derive(Debug, thiserror::Error)]
555enum DefaultBranchError {
556 #[error("could not determine default branch in repository")]
557 NoHead,
558 #[error("in detached HEAD state")]
559 Head,
560 #[error("could not determine default branch in repository: {0}")]
561 Git(raw::Error),
562}
563
564fn find_default_branch(repo: &raw::Repository) -> Result<String, DefaultBranchError> {
565 match find_init_default_branch(repo).ok().flatten() {
566 Some(refname) => Ok(refname),
567 None => Ok(find_repository_head(repo)?),
568 }
569}
570
571fn find_init_default_branch(repo: &raw::Repository) -> Result<Option<String>, raw::Error> {
572 let config = repo.config().and_then(|mut c| c.snapshot())?;
573 let default_branch = config.get_str("init.defaultbranch")?;
574 let branch = repo.find_branch(default_branch, raw::BranchType::Local)?;
575 Ok(branch.into_reference().shorthand().map(ToOwned::to_owned))
576}
577
578fn find_repository_head(repo: &raw::Repository) -> Result<String, DefaultBranchError> {
579 match repo.head() {
580 Err(e) if e.code() == raw::ErrorCode::UnbornBranch => Err(DefaultBranchError::NoHead),
581 Err(e) => Err(DefaultBranchError::Git(e)),
582 Ok(head) => head
583 .shorthand()
584 .filter(|refname| *refname != "HEAD")
585 .ok_or(DefaultBranchError::Head)
586 .map(|refname| refname.to_owned()),
587 }
588}