1#![allow(clippy::or_fun_call)]
2#![allow(clippy::collapsible_else_if)]
3use std::collections::HashSet;
4use std::convert::TryFrom;
5use std::env;
6use std::ffi::OsString;
7use std::path::PathBuf;
8use std::str::FromStr;
9
10use anyhow::{anyhow, bail, Context as _};
11use serde_json as json;
12
13use radicle::crypto::ssh;
14use radicle::explorer::ExplorerUrl;
15use radicle::git::raw;
16use radicle::git::RefString;
17use radicle::identity::project::ProjectName;
18use radicle::identity::{Doc, RepoId, Visibility};
19use radicle::node::events::UploadPack;
20use radicle::node::policy::Scope;
21use radicle::node::{Event, Handle, NodeId, DEFAULT_SUBSCRIBE_TIMEOUT};
22use radicle::storage::ReadStorage as _;
23use radicle::{profile, Node};
24
25use crate::commands;
26use crate::git;
27use crate::terminal as term;
28use crate::terminal::args::{Args, Error, Help};
29use crate::terminal::Interactive;
30
31pub const HELP: Help = Help {
32 name: "init",
33 description: "Initialize a Radicle repository",
34 version: env!("RADICLE_VERSION"),
35 usage: r#"
36Usage
37
38 rad init [<path>] [<option>...]
39
40Options
41
42 --name <string> Name of the repository
43 --description <string> Description of the repository
44 --default-branch <name> The default branch of the repository
45 --scope <scope> Repository follow scope: `followed` or `all` (default: all)
46 --private Set repository visibility to *private*
47 --public Set repository visibility to *public*
48 --existing <rid> Setup repository as an existing Radicle repository
49 -u, --set-upstream Setup the upstream of the default branch
50 --setup-signing Setup the radicle key as a signing key for this repository
51 --no-confirm Don't ask for confirmation during setup
52 --no-seed Don't seed this repository after initializing it
53 -v, --verbose Verbose mode
54 --help Print help
55"#,
56};
57
58#[derive(Default)]
59pub struct Options {
60 pub path: Option<PathBuf>,
61 pub name: Option<ProjectName>,
62 pub description: Option<String>,
63 pub branch: Option<String>,
64 pub interactive: Interactive,
65 pub visibility: Option<Visibility>,
66 pub existing: Option<RepoId>,
67 pub setup_signing: bool,
68 pub scope: Scope,
69 pub set_upstream: bool,
70 pub verbose: bool,
71 pub seed: bool,
72}
73
74impl Args for Options {
75 fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
76 use lexopt::prelude::*;
77
78 let mut parser = lexopt::Parser::from_args(args);
79 let mut path: Option<PathBuf> = None;
80
81 let mut name = None;
82 let mut description = None;
83 let mut branch = None;
84 let mut interactive = Interactive::Yes;
85 let mut set_upstream = false;
86 let mut setup_signing = false;
87 let mut scope = Scope::All;
88 let mut existing = None;
89 let mut seed = true;
90 let mut verbose = false;
91 let mut visibility = None;
92
93 while let Some(arg) = parser.next()? {
94 match arg {
95 Long("name") if name.is_none() => {
96 let value = parser.value()?;
97 let value = term::args::string(&value);
98 let value = ProjectName::try_from(value)?;
99
100 name = Some(value);
101 }
102 Long("description") if description.is_none() => {
103 let value = parser
104 .value()?
105 .to_str()
106 .ok_or(anyhow::anyhow!(
107 "invalid repository description specified with `--description`"
108 ))?
109 .to_owned();
110
111 description = Some(value);
112 }
113 Long("default-branch") if branch.is_none() => {
114 let value = parser
115 .value()?
116 .to_str()
117 .ok_or(anyhow::anyhow!(
118 "invalid branch specified with `--default-branch`"
119 ))?
120 .to_owned();
121
122 branch = Some(value);
123 }
124 Long("scope") => {
125 let value = parser.value()?;
126
127 scope = term::args::parse_value("scope", value)?;
128 }
129 Long("set-upstream") | Short('u') => {
130 set_upstream = true;
131 }
132 Long("setup-signing") => {
133 setup_signing = true;
134 }
135 Long("no-confirm") => {
136 interactive = Interactive::No;
137 }
138 Long("no-seed") => {
139 seed = false;
140 }
141 Long("private") => {
142 visibility = Some(Visibility::private([]));
143 }
144 Long("public") => {
145 visibility = Some(Visibility::Public);
146 }
147 Long("existing") if existing.is_none() => {
148 let val = parser.value()?;
149 let rid = term::args::rid(&val)?;
150
151 existing = Some(rid);
152 }
153 Long("verbose") | Short('v') => {
154 verbose = true;
155 }
156 Long("help") | Short('h') => {
157 return Err(Error::Help.into());
158 }
159 Value(val) if path.is_none() => {
160 path = Some(val.into());
161 }
162 _ => anyhow::bail!(arg.unexpected()),
163 }
164 }
165
166 Ok((
167 Options {
168 path,
169 name,
170 description,
171 branch,
172 scope,
173 existing,
174 interactive,
175 set_upstream,
176 setup_signing,
177 seed,
178 visibility,
179 verbose,
180 },
181 vec![],
182 ))
183 }
184}
185
186pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
187 let profile = ctx.profile()?;
188 let cwd = env::current_dir()?;
189 let path = options.path.as_deref().unwrap_or(cwd.as_path());
190 let repo = match git::Repository::open(path) {
191 Ok(r) => r,
192 Err(e) if radicle::git::ext::is_not_found_err(&e) => {
193 anyhow::bail!("a Git repository was not found at the given path")
194 }
195 Err(e) => return Err(e.into()),
196 };
197 if let Ok((remote, _)) = git::rad_remote(&repo) {
198 if let Some(remote) = remote.url() {
199 bail!("repository is already initialized with remote {remote}");
200 }
201 }
202
203 if let Some(rid) = options.existing {
204 init_existing(repo, rid, options, &profile)
205 } else {
206 init(repo, options, &profile)
207 }
208}
209
210pub fn init(
211 repo: git::Repository,
212 options: Options,
213 profile: &profile::Profile,
214) -> anyhow::Result<()> {
215 let path = dunce::canonicalize(repo.workdir().unwrap_or_else(|| repo.path()))?;
216 let interactive = options.interactive;
217
218 let default_branch = match find_default_branch(&repo) {
219 Err(err @ DefaultBranchError::Head) => {
220 term::error(err);
221 term::hint("try `git checkout <default branch>` or set `git config set --local init.defaultBranch <default branch>`");
222 anyhow::bail!("aborting `rad init`")
223 }
224 Err(err @ DefaultBranchError::NoHead) => {
225 term::error(err);
226 term::hint("perhaps you need to create a branch?");
227 anyhow::bail!("aborting `rad init`")
228 }
229 Err(err) => anyhow::bail!(err),
230 Ok(branch) => branch,
231 };
232
233 term::headline(format!(
234 "Initializing{}radicle 👾 repository in {}..",
235 if let Some(visibility) = &options.visibility {
236 term::format::spaced(term::format::visibility(visibility))
237 } else {
238 term::format::default(" ").into()
239 },
240 term::format::dim(path.display())
241 ));
242
243 let name: ProjectName = match options.name {
244 Some(name) => name,
245 None => {
246 let default = path
247 .file_name()
248 .and_then(|f| f.to_str())
249 .and_then(|f| ProjectName::try_from(f).ok());
250 let name = term::input(
251 "Name",
252 default,
253 Some("The name of your repository, eg. 'acme'"),
254 )?;
255
256 name.ok_or_else(|| anyhow::anyhow!("A project name is required."))?
257 }
258 };
259 let description = match options.description {
260 Some(desc) => desc,
261 None => {
262 term::input("Description", None, Some("You may leave this blank"))?.unwrap_or_default()
263 }
264 };
265 let branch = match options.branch {
266 Some(branch) => branch,
267 None if interactive.yes() => term::input(
268 "Default branch",
269 Some(default_branch),
270 Some("Please specify an existing branch"),
271 )?
272 .unwrap_or_default(),
273 None => default_branch,
274 };
275 let branch = RefString::try_from(branch.clone())
276 .map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?;
277 let visibility = if let Some(v) = options.visibility {
278 v
279 } else {
280 let selected = term::select(
281 "Visibility",
282 &["public", "private"],
283 "Public repositories are accessible by anyone on the network after initialization",
284 )?;
285 Visibility::from_str(selected)?
286 };
287
288 let signer = term::signer(profile)?;
289 let mut node = radicle::Node::new(profile.socket());
290 let mut spinner = term::spinner("Initializing...");
291 let mut push_cmd = String::from("git push");
292
293 match radicle::rad::init(
294 &repo,
295 name,
296 &description,
297 branch.clone(),
298 visibility,
299 &signer,
300 &profile.storage,
301 ) {
302 Ok((rid, doc, _)) => {
303 let proj = doc.project()?;
304
305 spinner.message(format!(
306 "Repository {} created.",
307 term::format::highlight(proj.name())
308 ));
309 spinner.finish();
310
311 if options.verbose {
312 term::blob(json::to_string_pretty(&proj)?);
313 }
314 if options.seed {
317 profile.seed(rid, options.scope, &mut node)?;
318
319 if doc.is_public() {
320 profile.add_inventory(rid, &mut node)?;
321 }
322 }
323
324 if options.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() {
325 radicle::git::set_upstream(
327 &repo,
328 &*radicle::rad::REMOTE_NAME,
329 proj.default_branch(),
330 radicle::git::refs::workdir::branch(proj.default_branch()),
331 )?;
332 } else {
333 push_cmd = format!("git push {} {branch}", *radicle::rad::REMOTE_NAME);
334 }
335
336 if options.setup_signing {
337 self::setup_signing(profile.id(), &repo, interactive)?;
339 }
340
341 term::blank();
342 term::info!(
343 "Your Repository ID {} is {}.",
344 term::format::dim("(RID)"),
345 term::format::highlight(rid.urn())
346 );
347 let directory = if path == env::current_dir()? {
348 "this directory".to_owned()
349 } else {
350 term::format::tertiary(path.display()).to_string()
351 };
352 term::info!(
353 "You can show it any time by running {} from {directory}.",
354 term::format::command("rad .")
355 );
356 term::blank();
357
358 if let Err(e) = announce(rid, doc, &mut node, &profile.config) {
360 term::blank();
361 term::warning(format!(
362 "There was an error announcing your repository to the network: {e}"
363 ));
364 term::warning("Try again with `rad sync --announce`, or check your logs with `rad node logs`.");
365 term::blank();
366 }
367 term::info!("To push changes, run {}.", term::format::command(push_cmd));
368 }
369 Err(err) => {
370 spinner.failed();
371 anyhow::bail!(err);
372 }
373 }
374
375 Ok(())
376}
377
378pub fn init_existing(
379 working: git::Repository,
380 rid: RepoId,
381 options: Options,
382 profile: &profile::Profile,
383) -> anyhow::Result<()> {
384 let stored = profile.storage.repository(rid)?;
385 let project = stored.project()?;
386 let url = radicle::git::Url::from(rid);
387
388 radicle::git::configure_repository(&working)?;
389 radicle::git::configure_remote(
390 &working,
391 &radicle::rad::REMOTE_NAME,
392 &url,
393 &url.clone().with_namespace(profile.public_key),
394 )?;
395
396 if options.set_upstream {
397 radicle::git::set_upstream(
399 &working,
400 &*radicle::rad::REMOTE_NAME,
401 project.default_branch(),
402 radicle::git::refs::workdir::branch(project.default_branch()),
403 )?;
404 }
405
406 if options.setup_signing {
407 self::setup_signing(profile.id(), &working, options.interactive)?;
409 }
410
411 term::success!(
412 "Initialized existing repository {} in {}..",
413 term::format::tertiary(rid),
414 term::format::dim(
415 working
416 .workdir()
417 .unwrap_or_else(|| working.path())
418 .display()
419 ),
420 );
421
422 Ok(())
423}
424
425#[derive(Debug)]
426enum SyncResult<T> {
427 NodeStopped,
428 NoPeersConnected,
429 NotSynced,
430 Synced { result: T },
431}
432
433fn sync(
434 rid: RepoId,
435 node: &mut Node,
436 config: &profile::Config,
437) -> Result<SyncResult<Option<ExplorerUrl>>, radicle::node::Error> {
438 if !node.is_running() {
439 return Ok(SyncResult::NodeStopped);
440 }
441 let mut spinner = term::spinner("Updating inventory..");
442 let events = node.subscribe(DEFAULT_SUBSCRIBE_TIMEOUT)?;
445 let sessions = node.sessions()?;
446
447 spinner.message("Announcing..");
448
449 if !sessions.iter().any(|s| s.is_connected()) {
450 return Ok(SyncResult::NoPeersConnected);
451 }
452
453 for seed in config.preferred_seeds.iter() {
455 if !sessions.iter().any(|s| s.nid == seed.id) {
456 commands::node::control::connect(
457 node,
458 seed.id,
459 seed.addr.clone(),
460 radicle::node::DEFAULT_TIMEOUT,
461 )
462 .ok();
463 }
464 }
465 node.announce_inventory()?;
467
468 spinner.message("Syncing..");
469
470 let mut replicas = HashSet::new();
471 let mut upload_pack = term::upload_pack::UploadPack::new();
473
474 for e in events {
475 match e {
476 Ok(Event::RefsSynced {
477 remote, rid: rid_, ..
478 }) if rid == rid_ => {
479 term::success!("Repository successfully synced to {remote}");
480 replicas.insert(remote);
481 if config.preferred_seeds.iter().any(|s| s.id == remote) {
483 break;
484 }
485 }
486 Ok(Event::UploadPack(UploadPack::Write {
487 rid: rid_,
488 remote,
489 progress,
490 })) if rid == rid_ => {
491 log::debug!("Upload progress for {remote}: {progress}");
492 }
493 Ok(Event::UploadPack(UploadPack::PackProgress {
494 rid: rid_,
495 remote,
496 transmitted,
497 })) if rid == rid_ => spinner.message(upload_pack.transmitted(remote, transmitted)),
498 Ok(Event::UploadPack(UploadPack::Done {
499 rid: rid_,
500 remote,
501 status,
502 })) if rid == rid_ => {
503 log::debug!("Upload done for {rid} to {remote} with status: {status}");
504 spinner.message(upload_pack.done(&remote));
505 }
506 Ok(Event::UploadPack(UploadPack::Error {
507 rid: rid_,
508 remote,
509 err,
510 })) if rid == rid_ => {
511 term::warning(format!("Upload error for {rid} to {remote}: {err}"));
512 }
513 Ok(_) => {
514 }
516 Err(radicle::node::Error::TimedOut) => {
517 break;
518 }
519 Err(e) => {
520 spinner.error(&e);
521 return Err(e);
522 }
523 }
524 }
525
526 if !replicas.is_empty() {
527 spinner.message(format!(
528 "Repository successfully synced to {} node(s).",
529 replicas.len()
530 ));
531 spinner.finish();
532
533 for seed in config.preferred_seeds.iter() {
534 if replicas.contains(&seed.id) {
535 return Ok(SyncResult::Synced {
536 result: Some(config.public_explorer.url(seed.addr.host.to_string(), rid)),
537 });
538 }
539 }
540 Ok(SyncResult::Synced { result: None })
541 } else {
542 spinner.message("Repository successfully announced to the network.");
543 spinner.finish();
544
545 Ok(SyncResult::NotSynced)
546 }
547}
548
549pub fn announce(
550 rid: RepoId,
551 doc: Doc,
552 node: &mut Node,
553 config: &profile::Config,
554) -> anyhow::Result<()> {
555 if doc.is_public() {
556 match sync(rid, node, config) {
557 Ok(SyncResult::Synced {
558 result: Some(url), ..
559 }) => {
560 term::blank();
561 term::info!(
562 "Your repository has been synced to the network and is \
563 now discoverable by peers.",
564 );
565 term::info!("View it in your browser at:");
566 term::blank();
567 term::indented(term::format::tertiary(url));
568 term::blank();
569 }
570 Ok(SyncResult::Synced { result: None, .. }) => {
571 term::blank();
572 term::info!(
573 "Your repository has been synced to the network and is \
574 now discoverable by peers.",
575 );
576 if !config.preferred_seeds.is_empty() {
577 term::info!(
578 "Unfortunately, you were unable to replicate your repository to \
579 your preferred seeds."
580 );
581 }
582 }
583 Ok(SyncResult::NotSynced) => {
584 term::blank();
585 term::info!(
586 "Your repository has been announced to the network and is \
587 now discoverable by peers.",
588 );
589 term::info!(
590 "You can check for any nodes that have replicated your repository by running \
591 `rad sync status`."
592 );
593 term::blank();
594 }
595 Ok(SyncResult::NoPeersConnected) => {
596 term::blank();
597 term::info!(
598 "You are not connected to any peers. Your repository will be announced as soon as \
599 your node establishes a connection with the network.");
600 term::info!("Check for peer connections with `rad node status`.");
601 term::blank();
602 }
603 Ok(SyncResult::NodeStopped) => {
604 term::info!(
605 "Your repository will be announced to the network when you start your node."
606 );
607 term::info!(
608 "You can start your node with {}.",
609 term::format::command("rad node start")
610 );
611 }
612 Err(e) => {
613 return Err(e.into());
614 }
615 }
616 } else {
617 term::info!(
618 "You have created a {} repository.",
619 term::format::visibility(doc.visibility())
620 );
621 term::info!(
622 "This repository will only be visible to you, \
623 and to peers you explicitly allow.",
624 );
625 term::blank();
626 term::info!(
627 "To make it public, run {}.",
628 term::format::command("rad publish")
629 );
630 }
631
632 Ok(())
633}
634
635pub fn setup_signing(
637 node_id: &NodeId,
638 repo: &git::Repository,
639 interactive: Interactive,
640) -> anyhow::Result<()> {
641 const SIGNERS: &str = ".gitsigners";
642
643 let path = repo.path();
644 let config = path.join("config");
645
646 let key = ssh::fmt::fingerprint(node_id);
647 let yes = if !git::is_signing_configured(path)? {
648 term::headline(format!(
649 "Configuring radicle signing key {}...",
650 term::format::tertiary(key)
651 ));
652 true
653 } else if interactive.yes() {
654 term::confirm(format!(
655 "Configure radicle signing key {} in {}?",
656 term::format::tertiary(key),
657 term::format::tertiary(config.display()),
658 ))
659 } else {
660 true
661 };
662
663 if !yes {
664 return Ok(());
665 }
666
667 git::configure_signing(path, node_id)?;
668 term::success!(
669 "Signing configured in {}",
670 term::format::tertiary(config.display())
671 );
672
673 if let Some(repo) = repo.workdir() {
674 match git::write_gitsigners(repo, [node_id]) {
675 Ok(file) => {
676 git::ignore(repo, file.as_path())?;
677
678 term::success!("Created {} file", term::format::tertiary(file.display()));
679 }
680 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
681 let ssh_key = ssh::fmt::key(node_id);
682 let gitsigners = term::format::tertiary(SIGNERS);
683 term::success!("Found existing {} file", gitsigners);
684
685 let ssh_keys =
686 git::read_gitsigners(repo).context(format!("error reading {SIGNERS} file"))?;
687
688 if ssh_keys.contains(&ssh_key) {
689 term::success!("Signing key is already in {gitsigners} file");
690 } else if term::confirm(format!("Add signing key to {gitsigners}?")) {
691 git::add_gitsigners(repo, [node_id])?;
692 }
693 }
694 Err(err) => {
695 return Err(err.into());
696 }
697 }
698 } else {
699 term::notice!("Not writing {SIGNERS} file.")
700 }
701
702 Ok(())
703}
704
705#[derive(Debug, thiserror::Error)]
706enum DefaultBranchError {
707 #[error("could not determine default branch in repository")]
708 NoHead,
709 #[error("in detached HEAD state")]
710 Head,
711 #[error("could not determine default branch in repository: {0}")]
712 Git(raw::Error),
713}
714
715fn find_default_branch(repo: &raw::Repository) -> Result<String, DefaultBranchError> {
716 match find_init_default_branch(repo).ok().flatten() {
717 Some(refname) => Ok(refname),
718 None => Ok(find_repository_head(repo)?),
719 }
720}
721
722fn find_init_default_branch(repo: &raw::Repository) -> Result<Option<String>, raw::Error> {
723 let config = repo.config().and_then(|mut c| c.snapshot())?;
724 let default_branch = config.get_str("init.defaultbranch")?;
725 let branch = repo.find_branch(default_branch, raw::BranchType::Local)?;
726 Ok(branch.into_reference().shorthand().map(ToOwned::to_owned))
727}
728
729fn find_repository_head(repo: &raw::Repository) -> Result<String, DefaultBranchError> {
730 match repo.head() {
731 Err(e) if e.code() == raw::ErrorCode::UnbornBranch => Err(DefaultBranchError::NoHead),
732 Err(e) => Err(DefaultBranchError::Git(e)),
733 Ok(head) => head
734 .shorthand()
735 .filter(|refname| *refname != "HEAD")
736 .ok_or(DefaultBranchError::Head)
737 .map(|refname| refname.to_owned()),
738 }
739}