radicle_cli/commands/
init.rs

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            // It's important to seed our own repositories to make sure that our node signals
315            // interest for them. This ensures that messages relating to them are relayed to us.
316            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                // Setup eg. `master` -> `rad/master`
326                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                // Setup radicle signing key.
338                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            // Announce inventory to network.
359            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        // Setup eg. `master` -> `rad/master`
398        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        // Setup radicle signing key.
408        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    // N.b. indefinitely subscribe to events and set a lower timeout on events
443    // below.
444    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    // Connect to preferred seeds in case we aren't connected.
454    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    // Announce our new inventory to connected nodes.
466    node.announce_inventory()?;
467
468    spinner.message("Syncing..");
469
470    let mut replicas = HashSet::new();
471    // Start upload pack as None and set it if we encounter an event
472    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 we manage to replicate to one of our preferred seeds, we can stop waiting.
482                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                // Some other irrelevant event received.
515            }
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
635/// Setup radicle key as commit signing key in repository.
636pub 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}