pub mod canonical; pub mod raw; use std::io; use std::path::Path; use std::process::Command; use std::str::FromStr; pub use radicle_oid::{str::ParseOidError, Oid}; pub extern crate radicle_git_ref_format as fmt; use crate::collections::RandomMap; use crate::crypto::PublicKey; use crate::node::Alias; use crate::rad; use crate::storage::refs::Refs; use crate::storage::RemoteId; pub use crate::storage::git::transport::local::Url; use raw::ErrorExt as _; pub type BranchName = crate::git::fmt::RefString; /// Default port of the `git` transport protocol. pub const PROTOCOL_PORT: u16 = 9418; /// Minimum required git version. pub const VERSION_REQUIRED: Version = Version { major: 2, minor: 31, patch: 0, }; /// A parsed git version. #[derive(PartialEq, Eq, Debug, PartialOrd, Ord)] pub struct Version { pub major: u8, pub minor: u8, pub patch: u8, } impl std::fmt::Display for Version { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) } } /// Verbosity level for Git commands. #[derive(Default, Clone, Copy)] pub struct Verbosity(i8); impl Verbosity { /// Transform into a command line flag, helpful for passing to invocations /// of `git`. /// /// See pub fn into_flag(&self) -> Option { const FLAG_PREFIX: &str = "-"; const FLAG_QUIET: &str = "q"; const FLAG_VERBOSE: &str = "v"; let repetitions = self.0.unsigned_abs() as usize; if repetitions == 0 { return None; } let flag = if self.0 > 0 { FLAG_VERBOSE } else { FLAG_QUIET }; Some(FLAG_PREFIX.to_string() + &flag.repeat(repetitions)) } /// Clamps verbosity to a range, as some commands only accept a specific /// number of repetitions. fn clamp(self, min: i8, max: i8) -> Self { Self(self.0.clamp(min, max)) } /// Clamps verbosity to at most `-v` or `-q`, as some commands do not accept /// repetitions. pub fn clamp_one(self) -> Self { self.clamp(-1, 1) } } impl From for Verbosity { fn from(v: i8) -> Self { Self(v) } } #[derive(thiserror::Error, Debug)] pub enum VersionError { #[error("malformed git version string")] Malformed, #[error("malformed git version string: {0}")] ParseInt(#[from] std::num::ParseIntError), #[error("malformed git version string: {0}")] Utf8(#[from] std::string::FromUtf8Error), #[error("error retrieving git version: {0}")] Io(#[from] io::Error), #[error("error retrieving git version: {0}")] Other(String), } impl std::str::FromStr for Version { type Err = VersionError; fn from_str(input: &str) -> Result { let rest = input .strip_prefix("git version ") .ok_or(VersionError::Malformed)?; let rest = rest.split(' ').next().ok_or(VersionError::Malformed)?; let rest = rest.trim_end(); let mut parts = rest.split('.'); let major = parts.next().ok_or(VersionError::Malformed)?.parse()?; let minor = parts.next().ok_or(VersionError::Malformed)?.parse()?; let patch = match parts.next() { None => 0, Some(patch) => patch.parse()?, }; Ok(Self { major, minor, patch, }) } } /// Get the system's git version. pub fn version() -> Result { let output = Command::new("git").arg("version").output()?; if output.status.success() { let output = String::from_utf8(output.stdout)?; let version = output.parse()?; return Ok(version); } Err(VersionError::Other( String::from_utf8_lossy(&output.stderr).to_string(), )) } #[derive(thiserror::Error, Debug)] pub enum RefError { #[error("ref name is not valid UTF-8")] InvalidName, #[error("unexpected unqualified ref: {0}")] Unqualified(fmt::RefString), #[error("invalid ref format: {0}")] Format(#[from] fmt::Error), #[error("reference has no target")] NoTarget, #[error("expected ref to begin with 'refs/namespaces' but found '{0}'")] MissingNamespace(fmt::RefString), #[error("ref name contains invalid namespace identifier '{name}'")] InvalidNamespace { name: fmt::RefString, #[source] err: Box, }, #[error(transparent)] Other(#[from] raw::Error), } #[derive(thiserror::Error, Debug)] pub enum ListRefsError { #[error("git error: {0}")] Git(#[from] raw::Error), #[error("invalid ref: {0}")] InvalidRef(#[from] RefError), } pub mod refs { use std::sync::LazyLock; use radicle_cob as cob; use super::fmt::*; use super::*; /// Try to get a qualified reference from a generic reference. pub fn qualified_from<'a>(r: &'a raw::Reference) -> Result<(Qualified<'a>, Oid), RefError> { let name = r.name().ok_or(RefError::InvalidName)?; let refstr = RefStr::try_from_str(name)?; let target = r.resolve()?.target().ok_or(RefError::NoTarget)?; let qualified = Qualified::from_refstr(refstr) .ok_or_else(|| RefError::Unqualified(refstr.to_owned()))?; Ok((qualified, target.into())) } /// Create a qualified branch reference. /// /// `refs/heads/` /// pub fn branch<'a>(branch: &RefStr) -> Qualified<'a> { Qualified::from(lit::refs_heads(branch)) } /// A patch reference. /// /// `refs/heads/patches/` /// pub fn patch<'a>(object_id: &cob::ObjectId) -> Qualified<'a> { Qualified::from_components( component!("heads"), component!("patches"), Some(object_id.into()), ) } pub mod storage { use super::*; /// Where the repo's identity document is stored. /// /// `refs/rad/id` /// pub static IDENTITY_BRANCH: LazyLock = LazyLock::new(|| Qualified::from_components(component!("rad"), component!("id"), None)); /// Where the repo's identity root document is stored. /// /// `refs/rad/root` /// pub static IDENTITY_ROOT: LazyLock = LazyLock::new(|| { Qualified::from_components(component!("rad"), component!("root"), None) }); /// Where the project's signed references are stored. /// /// `refs/rad/sigrefs` /// pub static SIGREFS_BRANCH: LazyLock = LazyLock::new(|| { Qualified::from_components(component!("rad"), component!("sigrefs"), None) }); /// The set of special references used in the Heartwood protocol. #[derive(Clone, Copy, Debug)] pub enum Special { /// `rad/id` Id, /// `rad/sigrefs` SignedRefs, } impl From for Qualified<'_> { fn from(s: Special) -> Self { match s { Special::Id => (*IDENTITY_BRANCH).clone(), Special::SignedRefs => (*SIGREFS_BRANCH).clone(), } } } impl Special { pub fn namespaced<'a>(&self, remote: &PublicKey) -> Namespaced<'a> { Qualified::from(*self).with_namespace(Component::from(remote)) } pub fn from_qualified(refname: &Qualified) -> Option { if refname == &*IDENTITY_BRANCH { Some(Special::Id) } else if refname == &*SIGREFS_BRANCH { Some(Special::SignedRefs) } else { None } } } /// Create the [`Namespaced`] `branch` under the `remote` namespace, i.e. /// /// `refs/namespaces//refs/heads/` /// pub fn branch_of<'a>(remote: &RemoteId, branch: &RefStr) -> Namespaced<'a> { Qualified::from(lit::refs_heads(branch)).with_namespace(remote.into()) } /// Get the branch where the project's identity document is stored. /// /// `refs/namespaces//refs/rad/id` /// pub fn id(remote: &RemoteId) -> Namespaced<'_> { IDENTITY_BRANCH.with_namespace(remote.into()) } /// Get the root of the branch where the project's identity document is stored. /// /// `refs/namespaces//refs/rad/root` /// pub fn id_root(remote: &RemoteId) -> Namespaced<'_> { IDENTITY_ROOT.with_namespace(remote.into()) } /// Get the branch where the `remote`'s signed references are /// stored. /// /// `refs/namespaces//refs/rad/sigrefs` /// pub fn sigrefs(remote: &RemoteId) -> Namespaced<'_> { SIGREFS_BRANCH.with_namespace(remote.into()) } /// The collaborative object reference, identified by `typename` and `object_id`, under the given `remote`. /// /// `refs/namespaces//refs/cobs//` /// pub fn cob<'a>( remote: &RemoteId, typename: &cob::TypeName, object_id: &cob::ObjectId, ) -> Namespaced<'a> { Qualified::from_components( component!("cobs"), Component::from(typename), Some(object_id.into()), ) .with_namespace(remote.into()) } /// All collaborative objects, identified by `typename` and `object_id`, for all remotes. /// /// `refs/namespaces/*/refs/cobs//` /// pub fn cobs(typename: &cob::TypeName, object_id: &cob::ObjectId) -> refspec::PatternString { pattern!("refs/namespaces/*") .join(refname!("refs/cobs")) .join(Component::from(typename)) .join(Component::from(object_id)) } /// Draft references. /// /// These references are not replicated or signed. pub mod draft { use super::*; /// Review draft reference. Points to the non-COB part of a patch review. /// /// `refs/namespaces//refs/drafts/reviews/` /// /// When building a patch review, we store the intermediate state in this ref. pub fn review<'a>(remote: &RemoteId, patch: &cob::ObjectId) -> Namespaced<'a> { Qualified::from_components( component!("drafts"), component!("reviews"), Some(Component::from(patch)), ) .with_namespace(remote.into()) } /// A draft collaborative object. This can also be a draft operation on an existing /// object. /// /// `refs/namespaces//refs/drafts/cobs//` /// pub fn cob<'a>( remote: &RemoteId, typename: &cob::TypeName, object_id: &cob::ObjectId, ) -> Namespaced<'a> { Qualified::from_components( component!("drafts"), component!("cobs"), [Component::from(typename), object_id.into()], ) .with_namespace(remote.into()) } /// All draft collaborative object, identified by `typename` and `object_id`, for all remotes. /// /// `refs/namespaces/*/refs/drafts/cobs//` /// pub fn cobs( typename: &cob::TypeName, object_id: &cob::ObjectId, ) -> refspec::PatternString { pattern!("refs/namespaces/*") .join(refname!("refs/drafts/cobs")) .join(Component::from(typename)) .join(Component::from(object_id)) } } /// Staging/temporary references. pub mod staging { use super::*; /// Where patch heads are pushed initially, before patch creation. /// This is a short-lived reference, which is deleted after the patch has been opened. /// The `` is the commit proposed in the patch. /// /// `refs/namespaces//refs/tmp/heads/` /// pub fn patch<'a>(remote: &RemoteId, oid: impl Into) -> Namespaced<'a> { // SAFETY: OIDs are valid reference names and valid path component. #[allow(clippy::unwrap_used)] let oid = RefString::try_from(oid.into().to_string()).unwrap(); #[allow(clippy::unwrap_used)] let oid = Component::from_refstr(oid).unwrap(); Qualified::from_components(component!("tmp"), component!("heads"), Some(oid)) .with_namespace(remote.into()) } } } pub mod workdir { use super::*; /// Create a [`RefString`] that corresponds to `refs/heads/`. pub fn branch(branch: &RefStr) -> RefString { refname!("refs/heads").join(branch) } /// Create a [`RefString`] that corresponds to `refs/notes/`. pub fn note(name: &RefStr) -> RefString { refname!("refs/notes").join(name) } /// Create a [`RefString`] that corresponds to `refs/remotes//`. pub fn remote_branch(remote: &RefStr, branch: &RefStr) -> RefString { refname!("refs/remotes").and(remote).and(branch) } /// Create a [`RefString`] that corresponds to `refs/tags/`. pub fn tag(name: &RefStr) -> RefString { refname!("refs/tags").join(name) } /// A patch head. /// /// `refs/remotes/rad/patches/` /// pub fn patch_upstream<'a>(patch_id: &cob::ObjectId) -> Qualified<'a> { Qualified::from_components( component!("remotes"), crate::rad::REMOTE_COMPONENT.clone(), [component!("patches"), patch_id.into()], ) } } } /// List remote refs of a project, given the remote URL. pub fn remote_refs(url: &Url) -> Result, ListRefsError> { let url = url.to_string(); let mut remotes = RandomMap::default(); let mut remote = raw::Remote::create_detached(url)?; remote.connect(raw::Direction::Fetch)?; let refs = remote.list()?; for r in refs { // Skip the `HEAD` reference, as it is untrusted. if r.name() == "HEAD" { continue; } // Nb. skip refs that don't have a public key namespace. if let (Some(id), refname) = parse_ref::(r.name())? { let entry = remotes.entry(id).or_insert_with(Refs::default); entry.insert(refname.into(), r.oid().into()); } } Ok(remotes) } /// Parse a [`fmt::Qualified`] reference string while expecting the reference /// to start with `refs/namespaces`. If the namespace is not present, then an /// error will be returned. /// /// The namespace returned is the path component that is after `refs/namespaces`, /// e.g. in the reference below, the segment is /// `z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C`: /// /// ```text, no_run /// refs/namespaces/z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C/refs/heads/main /// ``` /// /// The `T` can be specified when calling the function. For example, if you /// wanted to parse the namespace as a `PublicKey`, then you would the function /// like so, `parse_ref_namespaced::(s)`. pub fn parse_ref_namespaced(s: &str) -> Result<(T, fmt::Qualified<'_>), RefError> where T: FromStr, T::Err: std::error::Error + Send + Sync + 'static, { match parse_ref::(s) { Ok((None, refname)) => Err(RefError::MissingNamespace(refname.to_ref_string())), Ok((Some(t), r)) => Ok((t, r)), Err(err) => Err(err), } } /// Parse a [`fmt::Qualified`] reference string. It will optionally return /// the namespace, if present. /// /// The qualified form could be of the form: `refs/heads/main`, /// `refs/tags/v1.0`, etc. /// /// The namespace returned is the path component that is after `refs/namespaces`, /// e.g. in the reference below, the segment is /// `z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C`: /// /// ```text, no_run /// refs/namespaces/z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C/refs/heads/main /// ``` /// /// The `T` can be specified when calling the function. For example, if you /// wanted to parse the namespace as a `PublicKey`, then you would the function /// like so, `parse_ref::(s)`. pub fn parse_ref(s: &str) -> Result<(Option, fmt::Qualified<'_>), RefError> where T: FromStr, T::Err: std::error::Error + Send + Sync + 'static, { let input = fmt::RefStr::try_from_str(s)?; match input.to_namespaced() { None => { let refname = fmt::Qualified::from_refstr(input) .ok_or_else(|| RefError::Unqualified(input.to_owned()))?; Ok((None, refname)) } Some(ns) => { let id = ns .namespace() .as_str() .parse() .map_err(|err| RefError::InvalidNamespace { name: input.to_owned(), err: Box::new(err), })?; let rest = ns.strip_namespace(); Ok((Some(id), rest)) } } } /// Create an initial empty commit. pub fn initial_commit<'a>( repo: &'a raw::Repository, sig: &raw::Signature, ) -> Result, raw::Error> { let tree_id = repo.index()?.write_tree()?; let tree = repo.find_tree(tree_id)?; let oid = repo.commit(None, sig, sig, "Initial commit", &tree, &[])?; let commit = repo.find_commit(oid)?; Ok(commit) } /// Create a commit and update the given ref to it. pub fn commit<'a>( repo: &'a raw::Repository, parent: &'a raw::Commit, target: &fmt::RefStr, message: &str, sig: &raw::Signature, tree: &raw::Tree, ) -> Result, raw::Error> { let oid = repo.commit(Some(target.as_str()), sig, sig, message, tree, &[parent])?; let commit = repo.find_commit(oid)?; Ok(commit) } /// Create an empty commit on top of the parent. pub fn empty_commit<'a>( repo: &'a raw::Repository, parent: &'a raw::Commit, target: &fmt::RefStr, message: &str, sig: &raw::Signature, ) -> Result, raw::Error> { let tree = parent.tree()?; let oid = repo.commit(Some(target.as_str()), sig, sig, message, &tree, &[parent])?; let commit = repo.find_commit(oid)?; Ok(commit) } /// Get the repository head. pub fn head(repo: &raw::Repository) -> Result, raw::Error> { let head = repo.head()?.peel_to_commit()?; Ok(head) } /// Write a tree with the given blob at the given path. pub fn write_tree<'r>( path: &Path, bytes: &[u8], repo: &'r raw::Repository, ) -> Result, raw::Error> { let blob_id = repo.blob(bytes)?; let mut builder = repo.treebuilder(None)?; builder.insert(path, blob_id, 0o100_644)?; let tree_id = builder.write()?; let tree = repo.find_tree(tree_id)?; Ok(tree) } /// Configure a radicle repository. /// /// * Sets `push.default = upstream`. pub fn configure_repository(repo: &raw::Repository) -> Result<(), raw::Error> { let mut cfg = repo.config()?; cfg.set_str("push.default", "upstream")?; Ok(()) } /// Configure a repository's radicle remote. /// /// The entry for this remote will be: /// ```text /// [remote.] /// url = /// pushurl = /// fetch = +refs/heads/*:refs/remotes//* /// fetch = +refs/tags/*:refs/remotes//tags/* /// tagOpt = --no-tags /// pruneTags = false /// ``` /// /// Because of the `+refs/tags/*:…` refspec, set: /// 1. `pruneTags = false` to ensure that `git` does not delete tags because /// the remote does not have them. Tags for a Radicle repository are /// synthesised by canonical refs and thus, the `rad` remote will handle /// fetching them. /// 2. `tagOpt = --no-tags` to ensure that tags are not fetched and stored /// under `refs/tags`, again, because these are fetched by the `rad` remote. pub fn configure_remote<'r>( repo: &'r raw::Repository, name: &str, fetch: &Url, push: &Url, ) -> Result, raw::Error> { let fetchspec = format!("+refs/heads/*:refs/remotes/{name}/*"); let remote = repo.remote_with_fetch(name, fetch.to_string().as_str(), &fetchspec)?; // We want to be able fetch tags from a peer's namespace and this is // necessary to do so, since Git assumes that tags should always be fetched // from the top-level `refs/tags` namespace let tags = format!("+refs/tags/*:refs/remotes/{name}/tags/*"); repo.remote_add_fetch(name, &tags)?; if name != (*rad::REMOTE_NAME).as_str() { let mut config = repo.config()?; config.set_bool(&format!("remote.{name}.pruneTags"), false)?; config.set_str(&format!("remote.{name}.tagOpt"), "--no-tags")?; } if push != fetch { repo.remote_set_pushurl(name, Some(push.to_string().as_str()))?; } Ok(remote) } /// Fetch from the given `remote`. pub fn fetch(repo: &raw::Repository, remote: &str) -> Result<(), raw::Error> { repo.find_remote(remote)?.fetch::<&str>( &[], Some( raw::FetchOptions::new() .update_fetchhead(false) .prune(raw::FetchPrune::On) .download_tags(raw::AutotagOption::None), ), None, ) } /// Push `refspecs` to the given `remote` using the provided `namespace`. pub fn push<'a>( repo: &raw::Repository, remote: &str, refspecs: impl IntoIterator, &'a fmt::Qualified<'a>)>, ) -> Result<(), raw::Error> { let refspecs = refspecs .into_iter() .map(|(src, dst)| format!("{src}:{dst}")); repo.find_remote(remote)? .push(refspecs.collect::>().as_slice(), None)?; Ok(()) } /// Set the upstream of the given branch to the given remote. /// /// This writes to the `config` directly. The entry will look like the /// following: /// /// ```text /// [branch "main"] /// remote = rad /// merge = refs/heads/main /// ``` pub fn set_upstream( repo: &raw::Repository, remote: impl AsRef, branch: impl AsRef, merge: impl AsRef, ) -> Result<(), raw::Error> { let remote = remote.as_ref(); let branch = branch.as_ref(); let merge = merge.as_ref(); let mut config = repo.config()?; let branch_remote = format!("branch.{branch}.remote"); let branch_merge = format!("branch.{branch}.merge"); config.remove_multivar(&branch_remote, ".*").or_else(|e| { if e.is_not_found() { Ok(()) } else { Err(e) } })?; config.remove_multivar(&branch_merge, ".*").or_else(|e| { if e.is_not_found() { Ok(()) } else { Err(e) } })?; config.set_multivar(&branch_remote, ".*", remote)?; config.set_multivar(&branch_merge, ".*", merge)?; Ok(()) } pub fn init_default_branch(repo: &raw::Repository) -> Result, raw::Error> { let config = repo.config().and_then(|mut c| c.snapshot())?; let default_branch = config.get_str("init.defaultbranch")?; let branch = repo.find_branch(default_branch, raw::BranchType::Local)?; Ok(branch.into_reference().shorthand().map(ToOwned::to_owned)) } pub fn head_refname(repo: &raw::Repository) -> Result, raw::Error> { let head = repo.head()?; match head.shorthand() { Some("HEAD") => Ok(None), Some(refname) => Ok(Some(refname.to_owned())), None => Ok(None), } } /// Execute a `git` command by spawning a child process and collect its output. /// If `working` is [`Some`], the command is run as if `git` was started in /// `working` instead of the current working directory, by prepending /// `-C ` to the command line. pub fn run( working: Option<&std::path::Path>, args: impl IntoIterator, ) -> io::Result where S: AsRef, { let mut cmd = Command::new("git"); if let Some(working) = working { cmd.arg("-C").arg(dunce::canonicalize(working)?); } cmd.args(args).output() } /// Functions that call to the `git` CLI instead of `git2`. pub mod process { use std::io; use std::path::Path; use crate::storage::ReadRepository; use super::{run, Oid, Verbosity}; /// Perform a local fetch, from storage using `git fetch-pack`. /// /// `oids` are the set of [`Oid`]s that are being fetched from the /// `storage`. pub fn fetch_pack( working: Option<&Path>, storage: &R, oids: impl IntoIterator, verbosity: Verbosity, ) -> io::Result where R: ReadRepository, { let mut args = vec!["fetch-pack".to_string()]; args.extend(verbosity.clamp_one().into_flag()); args.push(dunce::canonicalize(storage.path())?.display().to_string()); args.extend(oids.into_iter().map(|oid| oid.to_string())); run(working, args) } } /// Git URLs. pub mod url { use std::path::PathBuf; use crate::prelude::RepoId; /// A Git URL using the `file://` scheme. pub struct File { pub path: PathBuf, } impl File { /// Create a new file URL pointing to the given path. pub fn new(path: impl Into) -> Self { Self { path: path.into() } } /// Return a URL with the given RID set. pub fn rid(mut self, rid: RepoId) -> Self { self.path.push(rid.canonical()); self } } impl std::fmt::Display for File { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "file://{}", self.path.display()) } } } /// Git environment variables. pub mod env { /// Set of environment vars to reset git's configuration to default. pub const GIT_DEFAULT_CONFIG: [(&str, &str); 2] = [ ("GIT_CONFIG_GLOBAL", "/dev/null"), ("GIT_CONFIG_NOSYSTEM", "1"), ]; } /// The user information used for signing commits and configuring the /// `name` and `email` fields in the Git config. #[derive(Debug, Clone)] pub struct UserInfo { /// Alias of the local peer. pub alias: Alias, /// [`PublicKey`] of the local peer. pub key: PublicKey, } impl UserInfo { /// The name of the user, i.e. the `alias`. pub fn name(&self) -> Alias { self.alias.clone() } /// The "email" of the user, which is in the form /// `@`. pub fn email(&self) -> String { format!("{}@{}", self.alias, self.key) } } #[cfg(test)] mod test { use super::*; use std::str::FromStr; #[test] fn test_version_ord() { assert!( Version { major: 2, minor: 34, patch: 1 } > Version { major: 2, minor: 34, patch: 0 } ); assert!( Version { major: 2, minor: 24, patch: 12 } < Version { major: 2, minor: 34, patch: 0 } ); } #[test] fn test_version_from_str() { assert_eq!( Version::from_str("git version 2.34.1\n").ok(), Some(Version { major: 2, minor: 34, patch: 1 }) ); assert_eq!( Version::from_str("git version 2.34.1 (macOS)").ok(), Some(Version { major: 2, minor: 34, patch: 1 }) ); assert_eq!( Version::from_str("git version 2.34").ok(), Some(Version { major: 2, minor: 34, patch: 0 }) ); assert!(Version::from_str("2.34").is_err()); } }