Skip to main content

radicle_cli/commands/
init.rs

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            // TODO(finto): this is interactive without checking `interactive` –
94            // this should check if interactive and use the default if not
95            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        // TODO(finto): this is interactive without checking `interactive` –
126        // this should check if interactive and use the `private` if not
127        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            // It's important to seed our own repositories to make sure that our node signals
162            // interest for them. This ensures that messages relating to them are relayed to us.
163            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                // Setup, e.g. `master` -> `rad/master`
173                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                // Set up Radicle signing key.
185                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            // Announce inventory to network.
206            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        // Setup, e.g. `master` -> `rad/master`
248        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        // Set up Radicle signing key.
258        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    // N.b. indefinitely subscribe to events and set a lower timeout on events
293    // below.
294    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    // Connect to preferred seeds in case we aren't connected.
304    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    // Announce our new inventory to connected nodes.
316    node.announce_inventory()?;
317
318    spinner.message("Syncing..");
319
320    let mut replicas = HashSet::new();
321    // Start upload pack as None and set it if we encounter an event
322    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 we manage to replicate to one of our preferred seeds, we can stop waiting.
332                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                // Some other irrelevant event received.
365            }
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
486/// Set up Radicle key as commit signing key in repository.
487pub 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}