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<_>>(),
);
}
}