Skip to main content

radicle_cli/commands/
init.rs

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            // TODO(finto): this is interactive without checking `interactive` –
95            // this should check if interactive and use the default if not
96            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        // TODO(finto): this is interactive without checking `interactive` –
127        // this should check if interactive and use the `private` if not
128        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            // It's important to seed our own repositories to make sure that our node signals
163            // interest for them. This ensures that messages relating to them are relayed to us.
164            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                // Setup eg. `master` -> `rad/master`
174                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                // Setup radicle signing key.
186                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            // Announce inventory to network.
207            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        // Setup eg. `master` -> `rad/master`
247        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        // Setup radicle signing key.
257        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    // N.b. indefinitely subscribe to events and set a lower timeout on events
292    // below.
293    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    // Connect to preferred seeds in case we aren't connected.
303    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    // Announce our new inventory to connected nodes.
315    node.announce_inventory()?;
316
317    spinner.message("Syncing..");
318
319    let mut replicas = HashSet::new();
320    // Start upload pack as None and set it if we encounter an event
321    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 we manage to replicate to one of our preferred seeds, we can stop waiting.
331                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                // Some other irrelevant event received.
364            }
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
484/// Setup radicle key as commit signing key in repository.
485pub 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}