Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
heartwood crates radicle src rad.rs
use std::io;
use std::path::Path;
use std::str::FromStr;
use std::sync::LazyLock;

use thiserror::Error;

use crate::cob::ObjectId;
use crate::git;
use crate::git::BranchName;
use crate::identity::doc;
use crate::identity::doc::{DocError, RepoId, Visibility};
use crate::identity::project::{Project, ProjectName};
use crate::node::device::Device;
use crate::storage::RepositoryError;
use crate::storage::git::Repository;
use crate::storage::git::transport;
use crate::storage::refs::SignedRefs;
use crate::storage::{ReadRepository as _, RemoteId, SignRepository as _};
use crate::storage::{WriteRepository, WriteStorage};
use crate::{identity, storage};

/// Name of the Radicle storage remote.
pub static REMOTE_NAME: LazyLock<git::fmt::RefString> = LazyLock::new(|| git::fmt::refname!("rad"));
/// Name of the Radicle storage remote.
pub static REMOTE_COMPONENT: LazyLock<git::fmt::Component> =
    LazyLock::new(|| git::fmt::component!("rad"));
/// Refname used for pushing patches.
pub static PATCHES_REFNAME: LazyLock<git::fmt::RefString> =
    LazyLock::new(|| git::fmt::refname!("refs/patches"));

#[derive(Error, Debug)]
pub enum InitError {
    #[error("doc: {0}")]
    Doc(#[from] DocError),
    #[error("repository: {0}")]
    Repository(#[from] RepositoryError),
    #[error("project payload: {0}")]
    ProjectPayload(String),
    #[error("git: {0}")]
    Git(#[from] git::raw::Error),
    #[error("i/o: {0}")]
    Io(#[from] io::Error),
    #[error("storage: {0}")]
    Storage(#[from] storage::Error),
}

/// Initialize a new Radicle project from a git repository.
pub fn init<G, S>(
    repo: &git::raw::Repository,
    name: ProjectName,
    description: &str,
    default_branch: BranchName,
    visibility: Visibility,
    signer: &Device<G>,
    storage: S,
) -> Result<(RepoId, identity::Doc, SignedRefs), InitError>
where
    G: crypto::signature::Signer<crypto::Signature>,
    S: WriteStorage,
{
    // TODO: Better error when project id already exists in storage, but remote doesn't.
    let delegate: identity::Did = signer.public_key().into();
    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::<Vec<_>>()
                .join(", "),
        )
    })?;
    let doc = identity::Doc::initial(proj, delegate, visibility);

    let (project, identity) = Repository::init(&doc, &storage, signer)?;
    let url = git::Url::from(project.id);

    match init_configure(repo, &project, &default_branch, &url, identity, signer) {
        Ok(signed) => Ok((project.id, doc, signed)),
        Err(err) => {
            if let Err(e) = project.remove() {
                log::warn!(target: "radicle", "Failed to remove project during `rad::init` cleanup: {e}");
            }
            if repo.find_remote(&REMOTE_NAME).is_ok() {
                if let Err(e) = repo.remote_delete(&REMOTE_NAME) {
                    log::warn!(target: "radicle", "Failed to remove remote during `rad::init` cleanup: {e}");
                }
            }
            Err(err)
        }
    }
}

fn init_configure<G>(
    repo: &git::raw::Repository,
    stored: &Repository,
    default_branch: &BranchName,
    url: &git::Url,
    identity: git::Oid,
    signer: &Device<G>,
) -> Result<SignedRefs, InitError>
where
    G: crypto::signature::Signer<crypto::Signature>,
{
    let pk = signer.public_key();

    git::configure_repository(repo)?;
    git::configure_remote(repo, &REMOTE_NAME, url, &url.clone().with_namespace(*pk))?;
    let branch = git::fmt::Qualified::from(git::fmt::lit::refs_heads(default_branch));

    {
        // Push branch to storage.
        let stored = dunce::canonicalize(stored.path())?.display().to_string();

        // Pushes to default branch to the namespace of the `signer`.
        let pushspec = git::fmt::refspec::Refspec {
            src: branch.clone(),
            dst: branch.with_namespace(git::fmt::Component::from(pk)),
            force: false,
        }
        .to_string();

        git::run(Some(repo.path()), ["push".to_string(), stored, pushspec])?;
    }

    // N.b. we need to create the remote branch for the default branch
    let rad_remote = git::fmt::Qualified::from(git::fmt::lit::refs_remotes(&*REMOTE_COMPONENT))
        .join(default_branch);
    let oid = repo.refname_to_id(branch.as_str())?;
    repo.reference(
        rad_remote.as_str(),
        oid,
        false,
        &format!(
            "radicle: remote branch {}/{}",
            *REMOTE_COMPONENT, default_branch
        ),
    )?;
    stored.set_remote_identity_root_to(pk, identity)?;
    stored.set_identity_head_to(identity)?;
    stored.set_head_to_default_branch()?;
    stored.set_default_branch_to_canonical_head()?;

    let signed = stored.sign_refs(signer)?;

    Ok(signed)
}

#[derive(Error, Debug)]
pub enum ForkError {
    #[error("git: {0}")]
    Git(#[from] git::raw::Error),
    #[error("storage: {0}")]
    Storage(#[from] storage::Error),
    #[error("payload: {0}")]
    Payload(#[from] doc::PayloadError),
    #[error("repository `{0}` was not found in storage")]
    NotFound(RepoId),
    #[error("repository: {0}")]
    Repository(#[from] RepositoryError),
}

/// Create a local tree for an existing project, from an existing remote.
#[cfg(any(test, feature = "test"))]
pub fn fork_remote<G, S>(
    proj: RepoId,
    remote: &RemoteId,
    signer: &Device<G>,
    storage: S,
) -> Result<(), ForkError>
where
    G: crypto::signature::Signer<crypto::Signature>,
    S: storage::WriteStorage,
{
    // TODO: Copy tags over?

    // Creates or copies the following references:
    //
    // refs/namespaces/<pk>/refs/heads/master
    // refs/namespaces/<pk>/refs/rad/sigrefs
    // refs/namespaces/<pk>/refs/tags/*

    let me = signer.public_key();
    let doc = storage.get(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}"),
    )?;
    repository.sign_refs(signer)?;

    Ok(())
}

pub fn fork<G, S>(rid: RepoId, signer: &Device<G>, storage: &S) -> Result<(), ForkError>
where
    G: crypto::signature::Signer<crypto::Signature>,
    S: storage::WriteStorage,
{
    let me = signer.public_key();
    let repository = storage.repository_mut(rid)?;
    let (canonical_branch, canonical_head) = repository.head()?;
    let raw = repository.raw();

    raw.reference(
        &canonical_branch.with_namespace(me.into()),
        canonical_head.into(),
        true,
        &format!("creating default branch for {me}"),
    )?;
    repository.sign_refs(signer)?;

    Ok(())
}

#[derive(Error, Debug)]
#[non_exhaustive]
pub enum CheckoutError {
    #[error("failed to fetch to working copy: {0}")]
    FetchIo(#[source] std::io::Error),
    #[error(
        "internal fetch failed with exit status {status}, stderr and stdout follow:\n{stderr}\n{stdout}"
    )]
    FetchGit {
        status: std::process::ExitStatus,
        stderr: String,
        stdout: String,
    },
    #[error("git: {0}")]
    Git(#[from] git::raw::Error),
    #[error("payload: {0}")]
    Payload(#[from] doc::PayloadError),
    #[error("repository `{0}` was not found in storage")]
    NotFound(RepoId),
    #[error("repository: {0}")]
    Repository(#[from] RepositoryError),
}

/// 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: RepoId,
    remote: &RemoteId,
    path: P,
    storage: &S,
    bare: bool,
) -> Result<git::raw::Repository, CheckoutError> {
    // TODO: Decide on whether we can use `clone_local`
    // TODO: Look into sharing object databases.
    let doc = storage.get(proj)?.ok_or(CheckoutError::NotFound(proj))?;
    let project = doc.project()?;

    let mut opts = git::raw::RepositoryInitOptions::new();
    opts.no_reinit(true)
        .external_template(false)
        .description(project.description())
        .bare(bare);

    let repo = git::raw::Repository::init_opts(path.as_ref(), &opts)?;
    let url = git::Url::from(proj);

    // Configure repository for Radicle.
    git::configure_repository(&repo)?;
    // Configure and fetch all refs from remote.
    git::configure_remote(
        &repo,
        &REMOTE_NAME,
        &url,
        &url.clone().with_namespace(*remote),
    )?;

    {
        // Fetch remote head to working copy.

        let fetchspec = git::fmt::refspec::Refspec {
            src: git::fmt::pattern!("refs/heads/*"),
            dst: git::fmt::Qualified::from(git::fmt::lit::refs_remotes(&*REMOTE_NAME))
                .to_pattern(git::fmt::refspec::STAR)
                .into_patternstring(),
            force: false,
        }
        .to_string();

        let stored = dunce::canonicalize(storage.repository(proj)?.path())
            .map_err(CheckoutError::FetchIo)?
            .display()
            .to_string();

        let output = git::run(Some(repo.path()), ["fetch", &stored, &fetchspec])
            .map_err(CheckoutError::FetchIo)?;

        if !output.status.success() {
            return Err(CheckoutError::FetchGit {
                status: output.status,
                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
            });
        }
    }

    {
        // 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)?;
        if !bare {
            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] git::raw::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: RepoId, expected: RepoId },
}

/// Get the Radicle ("rad") remote of a repository, and return the associated project id.
pub fn remote(repo: &git::raw::Repository) -> Result<(git::raw::Remote<'_>, RepoId), RemoteError> {
    let remote = repo.find_remote(&REMOTE_NAME).map_err(|e| {
        if e.code() == git::raw::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: &git::raw::Repository) -> Result<(), RemoteError> {
    repo.remote_delete(&REMOTE_NAME).map_err(|e| {
        if e.code() == git::raw::ErrorCode::NotFound {
            RemoteError::NotFound(REMOTE_NAME.to_string())
        } else {
            RemoteError::from(e)
        }
    })?;
    Ok(())
}

#[derive(Error, Debug)]
pub enum CwdError {
    #[error(transparent)]
    Remote(#[from] RemoteError),

    #[error("Detection failed (git: '{git}', jj: '{jj}')")]
    Detection {
        git: git::raw::Error,
        jj: JujutsuGitRootError,
    },
}

/// Get the RID of the repository in current working directory
///
/// It will attempt to search parent directories if `path` did not find
/// a git repository.
///
/// # Safety
///
/// This function should only perform read operations since we do not
/// want to modify the wrong repository in the case that it found a
/// Git repository that is not a Radicle repository.
pub fn cwd() -> Result<(git::raw::Repository, RepoId), CwdError> {
    let repo =
        repo().or_else(|git| repo_jj_git_root().map_err(|jj| CwdError::Detection { git, jj }))?;

    let (_, id) = remote(&repo)?;
    Ok((repo, id))
}

/// Get the repository of project in specified directory
pub fn at(path: impl AsRef<Path>) -> Result<(git::raw::Repository, RepoId), RemoteError> {
    let repo = git::raw::Repository::open(path)?;
    let (_, id) = remote(&repo)?;

    Ok((repo, id))
}

/// Get the current Git repository.
pub fn repo() -> Result<git::raw::Repository, git::raw::Error> {
    let mut flags = git::raw::RepositoryOpenFlags::empty();
    // Allow to search upwards.
    flags.set(git::raw::RepositoryOpenFlags::NO_SEARCH, false);
    // Allow to use `GIT_DIR` env.
    flags.set(git::raw::RepositoryOpenFlags::FROM_ENV, true);

    let ceilings: &[&str] = &[];
    let repo = git::raw::Repository::open_ext(Path::new("."), flags, ceilings)?;

    Ok(repo)
}

#[derive(Error, Debug)]
pub enum JujutsuGitRootError {
    #[error("git: {0}")]
    Git(#[from] git::raw::Error),

    #[error("i/o: {0}")]
    Io(#[from] io::Error),

    #[error("exited with status {status}")]
    CommandFailure { status: std::process::ExitStatus },
}

/// Get the Git repo underlying the current Jujutsu repository.
pub fn repo_jj_git_root() -> Result<git::raw::Repository, JujutsuGitRootError> {
    let output = std::process::Command::new("jj")
        .args(["git", "root"])
        .output()?;

    if !output.status.success() {
        return Err(JujutsuGitRootError::CommandFailure {
            status: output.status,
        });
    }

    let path = std::path::PathBuf::from(String::from_utf8_lossy(&output.stdout).to_string().trim());
    Ok(git::raw::Repository::open(path)?)
}

/// Setup patch upstream branch such that `git push` updates the patch.
pub fn setup_patch_upstream<'a>(
    patch: &ObjectId,
    patch_head: crate::git::Oid,
    working: &'a crate::git::raw::Repository,
    remote: &git::fmt::RefString,
    force: bool,
) -> Result<Option<crate::git::raw::Branch<'a>>, crate::git::raw::Error> {
    let head = working.head()?;

    // Don't do anything in case we're not on the patch branch.
    if patch_head != head.peel_to_commit()?.id() {
        return Ok(None);
    }
    let Ok(r) = head.resolve() else {
        return Ok(None);
    };

    // Can't set an upstream for something that's not a branch
    if !r.is_branch() {
        return Ok(None);
    }

    let branch = crate::git::raw::Branch::wrap(r);

    // Only set the upstream if it's missing or `force` is `true`
    if branch.upstream().is_ok() && !force {
        return Ok(None);
    }

    let name: Option<git::fmt::RefString> = 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.into(),
        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.as_str(), git::refs::patch(patch))?;
        }
    }
    Ok(Some(crate::git::raw::Branch::wrap(remote_branch)))
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::collections::HashMap;

    use pretty_assertions::assert_eq;

    use crate::identity::Did;
    use crate::storage::git::Storage;
    use crate::storage::git::transport;
    use crate::storage::{ReadStorage, RemoteRepository as _};
    use crate::test::fixtures;
    use git::fmt::{component, qualified};

    use super::*;

    #[test]
    fn test_init() {
        let tempdir = tempfile::tempdir().unwrap();
        let signer = Device::mock();
        let public_key = *signer.public_key();
        let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();

        transport::local::register(storage.clone());

        let (repo, _) = fixtures::repository(tempdir.path().join("working"));
        let (proj, _, refs) = init(
            &repo,
            "acme".try_into().unwrap(),
            "Acme's repo",
            git::fmt::refname!("master"),
            Visibility::default(),
            &signer,
            &storage,
        )
        .unwrap();

        let doc = storage.get(proj).unwrap().unwrap();
        let project = doc.project().unwrap();
        let remotes: HashMap<_, _> = storage
            .repository(proj)
            .unwrap()
            .remotes()
            .unwrap()
            .collect::<Result<_, _>>()
            .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!(head, project_repo.raw().refname_to_id("HEAD").unwrap());
        assert_eq!(
            head,
            project_repo
                .raw()
                .refname_to_id("refs/heads/master")
                .unwrap(),
        );

        assert_eq!(remotes[&public_key].refs.refs(), refs.refs());
        assert_eq!(project.name(), "acme");
        assert_eq!(project.description(), "Acme's repo");
        assert_eq!(project.default_branch(), &git::fmt::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 = Device::mock_rng(&mut rng);
        let bob = Device::mock_rng(&mut rng);
        let bob_id = bob.public_key();
        let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).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".try_into().unwrap(),
            "Acme's repo",
            git::fmt::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, false).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 = Device::mock();
        let remote_id = signer.public_key();
        let storage = Storage::open(tempdir.path().join("storage"), fixtures::user()).unwrap();

        transport::local::register(storage.clone());

        let (original, _) = fixtures::repository(tempdir.path().join("original"));
        let (id, _, _) = init(
            &original,
            "acme".try_into().unwrap(),
            "Acme's repo",
            git::fmt::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, false).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::<Vec<_>>(),
            original
                .find_remote(&REMOTE_NAME)
                .unwrap()
                .refspecs()
                .map(|r| r.bytes().to_vec())
                .collect::<Vec<_>>(),
        );
    }
}