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