radicle-heartwood-lfs/radicle/src/rad.rs

471 lines
14 KiB
Rust

#![allow(clippy::let_unit_value)]
use std::io;
use std::path::Path;
use std::str::FromStr;
use once_cell::sync::Lazy;
use thiserror::Error;
use crate::crypto::{Signer, Verified};
use crate::git;
use crate::identity::project::DocError;
use crate::identity::Id;
use crate::node;
use crate::storage::git::ProjectError;
use crate::storage::refs::SignedRefs;
use crate::storage::{BranchName, ReadRepository as _, RemoteId, WriteRepository as _};
use crate::{identity, storage};
pub static REMOTE_NAME: Lazy<git::RefString> = Lazy::new(|| git::refname!("rad"));
#[derive(Error, Debug)]
pub enum InitError {
#[error("doc: {0}")]
Doc(#[from] identity::project::DocError),
#[error("doc: {0}")]
DocVerification(#[from] identity::project::VerificationError),
#[error("git: {0}")]
Git(#[from] git2::Error),
#[error("i/o: {0}")]
Io(#[from] io::Error),
#[error("storage: {0}")]
Storage(#[from] storage::Error),
#[error("cannot initialize project inside a bare repository")]
BareRepo,
#[error("cannot initialize project from detached head state")]
DetachedHead,
#[error("HEAD reference is not valid UTF-8")]
InvalidHead,
}
/// Initialize a new radicle project from a git repository.
pub fn init<G: Signer, S: storage::WriteStorage>(
repo: &git2::Repository,
name: &str,
description: &str,
default_branch: BranchName,
signer: G,
storage: &S,
) -> Result<(Id, SignedRefs<Verified>), InitError> {
let pk = signer.public_key();
let delegate = identity::Delegate {
// TODO: Use actual user name.
name: String::from("anonymous"),
id: identity::Did::from(*pk),
};
let doc = identity::Doc::initial(
name.to_owned(),
description.to_owned(),
default_branch.clone(),
delegate,
)
.verified()?;
let (id, _, project) = doc.create(pk, "Initialize Radicle\n", storage)?;
let url = storage.url(&id);
git::set_upstream(
repo,
&REMOTE_NAME,
&default_branch,
&git::refs::storage::branch(pk, &default_branch),
)?;
// TODO: Note that you'll likely want to use `RemoteCallbacks` and set
// `push_update_reference` to test whether all the references were pushed
// successfully.
git::configure_remote(repo, &REMOTE_NAME, pk, &url)?.push::<&str>(
&[&format!(
"{}:{}",
&git::refs::workdir::branch(&default_branch),
&git::refs::storage::branch(pk, &default_branch),
)],
None,
)?;
let signed = storage.sign_refs(&project, signer)?;
Ok((id, signed))
}
#[derive(Error, Debug)]
pub enum ForkError {
#[error("ref string: {0}")]
RefString(#[from] git::fmt::Error),
#[error("git: {0}")]
GitRaw(#[from] git2::Error),
#[error("git: {0}")]
Git(#[from] git::Error),
#[error("storage: {0}")]
Storage(#[from] storage::Error),
#[error("project `{0}` was not found in storage")]
NotFound(Id),
#[error("project identity error: {0}")]
InvalidIdentity(#[from] storage::git::ProjectError),
#[error("project identity document error: {0}")]
Doc(#[from] DocError),
#[error("git: invalid reference")]
InvalidReference,
}
/// Create a local tree for an existing project, from an existing remote.
pub fn fork_remote<G: Signer, S: storage::WriteStorage>(
proj: Id,
remote: &RemoteId,
signer: G,
storage: S,
) -> Result<(), ForkError> {
// TODO: Copy tags over?
// Creates or copies the following references:
//
// refs/remotes/<pk>/heads/master
// refs/remotes/<pk>/heads/radicle/id
// refs/remotes/<pk>/tags/*
// refs/remotes/<pk>/rad/signature
let me = signer.public_key();
let project = storage
.get(remote, proj)?
.ok_or(ForkError::NotFound(proj))?;
let repository = storage.repository(proj)?;
let raw = repository.raw();
let remote_head = raw
.find_reference(&git::refs::storage::branch(remote, &project.default_branch))?
.target()
.ok_or(ForkError::InvalidReference)?;
raw.reference(
&git::refs::storage::branch(me, &project.default_branch),
remote_head,
false,
&format!("creating default branch for {me}"),
)?;
let remote_id = raw
.find_reference(&git::refs::storage::id(remote))?
.target()
.ok_or(ForkError::InvalidReference)?;
raw.reference(
&git::refs::storage::id(me),
remote_id,
false,
&format!("creating identity branch for {me}"),
)?;
storage.sign_refs(&repository, &signer)?;
Ok(())
}
pub fn fork<G: Signer, S: storage::WriteStorage>(
proj: Id,
signer: &G,
storage: &S,
) -> Result<(), ForkError> {
let me = signer.public_key();
let repository = storage.repository(proj)?;
let (canonical_id, project) = repository.project_identity()?;
let raw = repository.raw();
// TODO: Test the fork functions in isolation.
// TODO: Move to function on `Repository`.
let canonical_head = {
let mut heads = Vec::new();
for delegate in project.delegates.iter() {
let name = format!("heads/{}", &project.default_branch);
let refname = git::RefString::try_from(name.as_str())?;
let r = repository.reference_oid(&delegate.id, &refname)?.into();
heads.push(r);
}
match heads.as_slice() {
[head] => Ok(*head),
// FIXME: This branch is not tested.
heads => raw.merge_base_many(heads),
}
}?;
raw.reference(
&git::refs::storage::branch(me, &project.default_branch),
canonical_head,
false,
&format!("creating default branch for {me}"),
)?;
raw.reference(
&git::refs::storage::id(me),
canonical_id.into(),
false,
&format!("creating identity branch for {me}"),
)?;
storage.sign_refs(&repository, &signer)?;
Ok(())
}
#[derive(Error, Debug)]
pub enum CloneError {
#[error("node: {0}")]
Node(#[from] node::Error),
#[error("fork: {0}")]
Fork(#[from] ForkError),
#[error("checkout: {0}")]
Checkout(#[from] CheckoutError),
#[error("identity document error: {0}")]
Doc(#[from] DocError),
}
pub fn clone<P: AsRef<Path>, G: Signer, S: storage::WriteStorage, H: node::Handle>(
proj: Id,
path: P,
signer: &G,
storage: &S,
handle: &H,
) -> Result<git2::Repository, CloneError> {
let _ = handle.track(&proj)?;
let _ = handle.fetch(&proj)?;
let _ = fork(proj, signer, storage)?;
let working = checkout(proj, signer.public_key(), path, storage)?;
Ok(working)
}
#[derive(Error, Debug)]
pub enum CloneUrlError {
#[error("storage: {0}")]
Storage(#[from] storage::Error),
#[error("fetch: {0}")]
Fetch(#[from] storage::FetchError),
#[error("fork: {0}")]
Fork(#[from] ForkError),
#[error("checkout: {0}")]
Checkout(#[from] CheckoutError),
}
pub fn clone_url<P: AsRef<Path>, G: Signer, S: storage::WriteStorage>(
proj: Id,
url: &git::Url,
path: P,
signer: &G,
storage: &S,
) -> Result<git2::Repository, CloneUrlError> {
let mut project = storage.repository(proj)?;
let _updates = project.fetch(url)?;
let _ = fork(proj, signer, storage)?;
let working = checkout(proj, signer.public_key(), path, storage)?;
Ok(working)
}
#[derive(Error, Debug)]
pub enum CheckoutError {
#[error("git: {0}")]
Git(#[from] git2::Error),
#[error("storage: {0}")]
Storage(#[from] storage::Error),
#[error("project `{0}` was not found in storage")]
NotFound(Id),
#[error("project error: {0}")]
Project(#[from] ProjectError),
}
/// Checkout a project from storage as a working copy.
/// This effectively does a `git-clone` from storage.
pub fn checkout<P: AsRef<Path>, S: storage::ReadStorage>(
proj: Id,
remote: &RemoteId,
path: P,
storage: &S,
) -> Result<git2::Repository, CheckoutError> {
// TODO: Decide on whether we can use `clone_local`
// TODO: Look into sharing object databases.
let project = storage
.get(remote, proj)?
.ok_or(CheckoutError::NotFound(proj))?;
let mut opts = git2::RepositoryInitOptions::new();
opts.no_reinit(true).description(&project.description);
let repo = git2::Repository::init_opts(path.as_ref().join(&project.name), &opts)?;
let url = storage.url(&proj);
// Configure and fetch all refs from remote.
git::configure_remote(&repo, &REMOTE_NAME, remote, &url)?.fetch::<&str>(&[], None, None)?;
{
// Setup default branch.
let remote_head_ref =
git::refs::workdir::remote_branch(&REMOTE_NAME, &project.default_branch);
let remote_head_commit = repo.find_reference(&remote_head_ref)?.peel_to_commit()?;
let _ = repo.branch(&project.default_branch, &remote_head_commit, true)?;
// Setup remote tracking for default branch.
git::set_upstream(
&repo,
&REMOTE_NAME,
&project.default_branch,
&git::refs::storage::branch(remote, &project.default_branch),
)?;
}
Ok(repo)
}
#[derive(Error, Debug)]
pub enum RemoteError {
#[error("git: {0}")]
Git(#[from] git2::Error),
#[error("invalid remote url: {0}")]
Url(#[from] git::url::parse::Error),
#[error("remote url doesn't have an id: `{0}`")]
MissingId(git::Url),
#[error("identifier error: {0}")]
InvalidId(#[from] identity::IdError),
}
/// Get the radicle ("rad") remote of a repository, and return the associated project id.
pub fn remote(repo: &git2::Repository) -> Result<(git2::Remote<'_>, Id), RemoteError> {
let remote = repo.find_remote(&REMOTE_NAME)?;
let url = remote.url_bytes();
let url = git::Url::from_bytes(url)?;
let path = url.path.to_string();
let id = path.split('/').last().ok_or(RemoteError::MissingId(url))?;
let id = Id::from_str(id)?;
Ok((remote, id))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::git::fmt::refname;
use crate::identity::{Delegate, Did};
use crate::storage::git::Storage;
use crate::storage::{ReadStorage, WriteStorage};
use crate::test::{fixtures, signer::MockSigner};
#[test]
fn test_init() {
let tempdir = tempfile::tempdir().unwrap();
let signer = MockSigner::default();
let public_key = *signer.public_key();
let storage = Storage::open(tempdir.path().join("storage")).unwrap();
let (repo, _) = fixtures::repository(tempdir.path().join("working"));
let (proj, refs) = init(
&repo,
"acme",
"Acme's repo",
git::refname!("master"),
&signer,
&storage,
)
.unwrap();
let project = storage.get(&public_key, proj).unwrap().unwrap();
let remotes: HashMap<_, _> = storage
.repository(proj)
.unwrap()
.remotes()
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(remotes[&public_key].refs, refs);
assert_eq!(project.name, "acme");
assert_eq!(project.description, "Acme's repo");
assert_eq!(project.default_branch, git::refname!("master"));
assert_eq!(
project.delegates.first(),
&Delegate {
name: String::from("anonymous"),
id: Did::from(public_key),
}
);
}
#[test]
fn test_fork() {
let mut rng = fastrand::Rng::new();
let tempdir = tempfile::tempdir().unwrap();
let alice = MockSigner::new(&mut rng);
let bob = MockSigner::new(&mut rng);
let bob_id = bob.public_key();
let storage = Storage::open(tempdir.path().join("storage")).unwrap();
let (original, _) = fixtures::repository(tempdir.path().join("original"));
// Alice creates a project.
let (id, alice_refs) = init(
&original,
"acme",
"Acme's repo",
git::refname!("master"),
&alice,
&storage,
)
.unwrap();
// Bob forks it and creates a checkout.
fork(id, &bob, &storage).unwrap();
checkout(id, bob_id, tempdir.path().join("copy"), &storage).unwrap();
let bob_remote = storage.repository(id).unwrap().remote(bob_id).unwrap();
assert_eq!(
bob_remote.refs.get(&refname!("master")),
alice_refs.get(&refname!("master"))
);
}
#[test]
fn test_checkout() {
let tempdir = tempfile::tempdir().unwrap();
let signer = MockSigner::default();
let remote_id = signer.public_key();
let storage = Storage::open(tempdir.path().join("storage")).unwrap();
let (original, _) = fixtures::repository(tempdir.path().join("original"));
let (id, _) = init(
&original,
"acme",
"Acme's repo",
git::refname!("master"),
&signer,
&storage,
)
.unwrap();
let copy = checkout(id, remote_id, tempdir.path().join("copy"), &storage).unwrap();
assert_eq!(
copy.head().unwrap().target(),
original.head().unwrap().target()
);
assert_eq!(
copy.branch_upstream_name("refs/heads/master")
.unwrap()
.to_vec(),
original
.branch_upstream_name("refs/heads/master")
.unwrap()
.to_vec()
);
assert_eq!(
copy.find_remote(&REMOTE_NAME)
.unwrap()
.refspecs()
.into_iter()
.map(|r| r.bytes().to_vec())
.collect::<Vec<_>>(),
original
.find_remote(&REMOTE_NAME)
.unwrap()
.refspecs()
.into_iter()
.map(|r| r.bytes().to_vec())
.collect::<Vec<_>>(),
);
}
}