#![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::cob::ObjectId; use crate::crypto::{Signer, Verified}; use crate::git; use crate::identity::doc::{DocError, Id, Visibility}; use crate::identity::project::Project; use crate::identity::{doc, IdentityError}; use crate::storage::git::transport; use crate::storage::git::Repository; use crate::storage::refs::SignedRefs; use crate::storage::{BranchName, ReadRepository as _, RemoteId, SignRepository as _}; use crate::storage::{WriteRepository, WriteStorage}; use crate::{identity, storage}; /// Name of the radicle storage remote. pub static REMOTE_NAME: Lazy = Lazy::new(|| git::refname!("rad")); /// Name of the radicle storage remote. pub static REMOTE_COMPONENT: Lazy = Lazy::new(|| git::fmt::name::component!("rad")); /// Refname used for pushing patches. pub static PATCHES_REFNAME: Lazy = Lazy::new(|| git::refname!("refs/patches")); #[derive(Error, Debug)] pub enum InitError { #[error("doc: {0}")] Doc(#[from] DocError), #[error("project: {0}")] Identity(#[from] IdentityError), #[error("project payload: {0}")] ProjectPayload(String), #[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( repo: &git2::Repository, name: &str, description: &str, default_branch: BranchName, visibility: Visibility, signer: &G, storage: S, ) -> Result<(Id, identity::Doc, SignedRefs), InitError> { // TODO: Better error when project id already exists in storage, but remote doesn't. let pk = signer.public_key(); let delegate = identity::Did::from(*pk); let proj = Project::new( name.to_owned(), description.to_owned(), default_branch.clone(), ) .map_err(|errs| { InitError::ProjectPayload( errs.into_iter() .map(|err| err.to_string()) .collect::>() .join(", "), ) })?; let doc = identity::Doc::initial(proj, delegate, visibility).verified()?; let (project, _) = Repository::init(&doc, pk, storage, signer)?; let url = git::Url::from(project.id); git::configure_repository(repo)?; git::configure_remote(repo, &REMOTE_NAME, &url, &url.clone().with_namespace(*pk))?; git::push( repo, &REMOTE_NAME, [( &git::fmt::lit::refs_heads(&default_branch).into(), &git::fmt::lit::refs_heads(&default_branch).into(), )], )?; let signed = project.sign_refs(signer)?; let _head = project.set_head()?; let _head = project.set_identity_head()?; Ok((project.id, doc, signed)) } #[derive(Error, Debug)] pub enum ForkError { #[error("ref string: {0}")] RefString(#[from] git::fmt::Error), #[error("git: {0}")] Git(#[from] git2::Error), #[error("git: {0}")] GitExt(#[from] git::Error), #[error("storage: {0}")] Storage(#[from] storage::Error), #[error("payload: {0}")] Payload(#[from] doc::PayloadError), #[error("project `{0}` was not found in storage")] NotFound(Id), #[error("project identity error: {0}")] InvalidIdentity(#[from] IdentityError), #[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( proj: Id, remote: &RemoteId, signer: &G, storage: S, ) -> Result<(), ForkError> { // TODO: Copy tags over? // Creates or copies the following references: // // refs/namespaces//refs/heads/master // refs/namespaces//refs/rad/id // refs/namespaces//refs/rad/sigrefs // refs/namespaces//refs/tags/* let me = signer.public_key(); let doc = storage .get(remote, proj)? .ok_or(ForkError::NotFound(proj))?; let project = doc.project()?; let repository = storage.repository_mut(proj)?; let raw = repository.raw(); let remote_head = raw.refname_to_id(&git::refs::storage::branch_of( remote, project.default_branch(), ))?; raw.reference( &git::refs::storage::branch_of(me, project.default_branch()), remote_head, false, &format!("creating default branch for {me}"), )?; let remote_id = raw.refname_to_id(&git::refs::storage::id(remote))?; raw.reference( &git::refs::storage::id(me), remote_id, false, &format!("creating identity branch for {me}"), )?; repository.sign_refs(signer)?; Ok(()) } pub fn fork( rid: Id, signer: &G, storage: &S, ) -> Result<(), ForkError> { let me = signer.public_key(); let repository = storage.repository_mut(rid)?; let (canonical_id, _) = repository.identity_doc()?; let (canonical_branch, canonical_head) = repository.head()?; let raw = repository.raw(); raw.reference( &canonical_branch.with_namespace(me.into()), *canonical_head, true, &format!("creating default branch for {me}"), )?; raw.reference( &git::refs::storage::id(me), canonical_id.into(), true, &format!("creating identity branch for {me}"), )?; repository.sign_refs(signer)?; Ok(()) } #[derive(Error, Debug)] pub enum CheckoutError { #[error("failed to fetch to working copy")] Fetch(#[source] git2::Error), #[error("git: {0}")] Git(#[from] git2::Error), #[error("storage: {0}")] Storage(#[from] storage::Error), #[error("payload: {0}")] Payload(#[from] doc::PayloadError), #[error("project `{0}` was not found in storage")] NotFound(Id), #[error("project error: {0}")] Identity(#[from] IdentityError), } /// Checkout a project from storage as a working copy. /// This effectively does a `git-clone` from storage. pub fn checkout, S: storage::ReadStorage>( proj: Id, remote: &RemoteId, path: P, storage: &S, ) -> Result { // TODO: Decide on whether we can use `clone_local` // TODO: Look into sharing object databases. let doc = storage .get(remote, proj)? .ok_or(CheckoutError::NotFound(proj))?; let project = doc.project()?; let mut opts = git2::RepositoryInitOptions::new(); opts.no_reinit(true).description(project.description()); let repo = git2::Repository::init_opts(path.as_ref(), &opts)?; let url = git::Url::from(proj); // Configure and fetch all refs from remote. git::configure_remote( &repo, &REMOTE_NAME, &url, &url.clone().with_namespace(*remote), )?; git::fetch(&repo, &REMOTE_NAME).map_err(CheckoutError::Fetch)?; { // 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 branch = repo .branch(project.default_branch(), &remote_head_commit, true)? .into_reference(); let branch_ref = branch .name() .expect("checkout: default branch name is valid UTF-8"); repo.set_head(branch_ref)?; repo.checkout_head(None)?; // Setup remote tracking for default branch. git::set_upstream(&repo, &*REMOTE_NAME, project.default_branch(), branch_ref)?; } Ok(repo) } #[derive(Error, Debug)] pub enum RemoteError { #[error("git: {0}")] Git(#[from] git2::Error), #[error("invalid remote url: {0}")] Url(#[from] transport::local::UrlError), #[error("invalid utf-8 string")] InvalidUtf8, #[error("remote `{0}` not found")] NotFound(String), #[error("expected remote for {expected} but found {found}")] RidMismatch { found: Id, expected: Id }, } /// 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).map_err(|e| { if e.code() == git2::ErrorCode::NotFound { RemoteError::NotFound(REMOTE_NAME.to_string()) } else { RemoteError::from(e) } })?; let url = remote.url().ok_or(RemoteError::InvalidUtf8)?; let url = git::Url::from_str(url)?; Ok((remote, url.repo)) } /// Delete the radicle ("rad") remote of a repository. pub fn remove_remote(repo: &git2::Repository) -> Result<(), RemoteError> { repo.remote_delete(&REMOTE_NAME).map_err(|e| { if e.code() == git2::ErrorCode::NotFound { RemoteError::NotFound(REMOTE_NAME.to_string()) } else { RemoteError::from(e) } })?; Ok(()) } /// Get the Id of project in current working directory pub fn cwd() -> Result<(git2::Repository, Id), RemoteError> { let repo = git2::Repository::open(Path::new("."))?; let (_, id) = remote(&repo)?; Ok((repo, id)) } /// Get the repository of project in specified directory pub fn repo(path: impl AsRef) -> Result<(git2::Repository, Id), RemoteError> { let repo = git2::Repository::open(path)?; let (_, id) = remote(&repo)?; Ok((repo, id)) } /// Setup patch upstream branch such that `git push` updates the patch. pub fn setup_patch_upstream<'a>( patch: &ObjectId, patch_head: git::Oid, working: &'a git::raw::Repository, force: bool, ) -> Result>, git::ext::Error> { let head = working.head()?; // Don't do anything in case we're not on the patch branch. if head.peel_to_commit()?.id() != *patch_head { return Ok(None); } let Ok(r) = head.resolve() else { return Ok(None); }; let branch = git::raw::Branch::wrap(r); let name: Option = branch.name()?.and_then(|b| b.try_into().ok()); let remote_branch = git::refs::workdir::patch_upstream(patch); let remote_branch = working.reference( &remote_branch, *patch_head, true, "Create remote tracking branch for patch", )?; assert!(remote_branch.is_remote()); if let Some(name) = name { if force || branch.upstream().is_err() { git::set_upstream( working, &*REMOTE_NAME, name.as_str(), git::refs::workdir::patch(patch), )?; } } Ok(Some(git::raw::Branch::wrap(remote_branch))) } #[cfg(test)] mod tests { use std::collections::HashMap; use radicle_crypto::test::signer::MockSigner; use crate::git::{name::component, qualified}; use crate::identity::Did; use crate::storage::git::transport; use crate::storage::git::Storage; use crate::storage::ReadStorage; use crate::test::fixtures; use super::*; #[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(); transport::local::register(storage.clone()); let (repo, _) = fixtures::repository(tempdir.path().join("working")); let (proj, _, refs) = init( &repo, "acme", "Acme's repo", git::refname!("master"), Visibility::default(), &signer, &storage, ) .unwrap(); let doc = storage.get(&public_key, proj).unwrap().unwrap(); let project = doc.project().unwrap(); let remotes: HashMap<_, _> = storage .repository(proj) .unwrap() .remotes() .unwrap() .collect::>() .unwrap(); let project_repo = storage.repository(proj).unwrap(); let (_, head) = project_repo.head().unwrap(); // Test canonical refs. assert_eq!(refs.head(component!("master")).unwrap(), head); assert_eq!(project_repo.raw().refname_to_id("HEAD").unwrap(), *head); assert_eq!( project_repo .raw() .refname_to_id("refs/heads/master") .unwrap(), *head ); 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!(doc.delegates.first(), &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(); transport::local::register(storage.clone()); // Alice creates a project. let (original, _) = fixtures::repository(tempdir.path().join("original")); let (id, _, alice_refs) = init( &original, "acme", "Acme's repo", git::refname!("master"), Visibility::default(), &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(&qualified!("refs/heads/master")) .unwrap(), alice_refs.get(&qualified!("refs/heads/master")).unwrap() ); } #[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(); transport::local::register(storage.clone()); let (original, _) = fixtures::repository(tempdir.path().join("original")); let (id, _, _) = init( &original, "acme", "Acme's repo", git::refname!("master"), Visibility::default(), &signer, &storage, ) .unwrap(); git::set_upstream(&original, "rad", "master", "refs/heads/master").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() .map(|r| r.bytes().to_vec()) .collect::>(), original .find_remote(&REMOTE_NAME) .unwrap() .refspecs() .map(|r| r.bytes().to_vec()) .collect::>(), ); } }