use std::io; use std::path::Path; use std::process::Command; use std::str::FromStr; use git_ext::ref_format as format; use once_cell::sync::Lazy; use crate::collections::RandomMap; use crate::crypto::PublicKey; use crate::node::Alias; use crate::storage; use crate::storage::refs::Refs; use crate::storage::RemoteId; pub use ext::is_not_found_err; pub use ext::Error; pub use ext::NotFound; pub use ext::Oid; pub use git2 as raw; pub use git_ext::ref_format as fmt; pub use git_ext::ref_format::{ component, lit, name, qualified, refname, refspec, refspec::{PatternStr, PatternString, Refspec}, Component, Namespaced, Qualified, RefStr, RefString, }; pub use radicle_git_ext as ext; pub use storage::git::transport::local::Url; pub use storage::BranchName; /// 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) } } #[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(RefString), #[error("invalid ref format: {0}")] Format(#[from] format::Error), #[error("reference has no target")] NoTarget, #[error("expected ref to begin with 'refs/namespaces' but found '{0}'")] MissingNamespace(format::RefString), #[error("ref name contains invalid namespace identifier '{name}'")] InvalidNamespace { name: format::RefString, #[source] err: Box, }, #[error(transparent)] Other(#[from] git2::Error), } #[derive(thiserror::Error, Debug)] pub enum ListRefsError { #[error("git error: {0}")] Git(#[from] git2::Error), #[error("invalid ref: {0}")] InvalidRef(#[from] RefError), } pub mod refs { use super::*; use radicle_cob as cob; /// Try to get a qualified reference from a generic reference. pub fn qualified_from<'a>(r: &'a git2::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)) } pub mod storage { use format::{ lit, name::component, refspec::{self, PatternString}, }; use super::*; /// Where the project's identity document is stored. /// /// `refs/rad/id` /// pub static IDENTITY_BRANCH: Lazy = Lazy::new(|| { Qualified::from_components(name::component!("rad"), name::component!("id"), None) }); /// Where the project's signed references are stored. /// /// `refs/rad/sigrefs` /// pub static SIGREFS_BRANCH: Lazy = Lazy::new(|| { Qualified::from_components(name::component!("rad"), name::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 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) -> PatternString { refspec::pattern!("refs/namespaces/*") .join(refname!("refs/cobs")) .join(Component::from(typename)) .join(Component::from(object_id)) } /// 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()), ) } /// 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()) } /// Draft collaborative objects of a type. /// /// `refs/namespaces//refs/drafts/cobs//*` /// pub fn cobs(remote: &RemoteId, typename: &cob::TypeName) -> PatternString { Qualified::from_components( component!("drafts"), component!("cobs"), Some(Component::from(typename)), ) .with_namespace(remote.into()) .to_pattern(refspec::pattern!("*")) } } /// 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::*; use format::name::component; /// 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/heads/patches/` /// pub fn patch<'a>(patch_id: &cob::ObjectId) -> Qualified<'a> { Qualified::from_components( component!("heads"), component!("patches"), Some(patch_id.into()), ) } /// 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 = git2::Remote::create_detached(url)?; remote.connect(git2::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 ref string. Returns an error if it isn't namespaced. pub fn parse_ref_namespaced(s: &str) -> Result<(T, format::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 ref string. Optionally returns a namespace. pub fn parse_ref(s: &str) -> Result<(Option, format::Qualified), RefError> where T: FromStr, T::Err: std::error::Error + Send + Sync + 'static, { let input = format::RefStr::try_from_str(s)?; match input.to_namespaced() { None => { let refname = 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 git2::Repository, sig: &git2::Signature, ) -> Result, git2::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 git2::Repository, parent: &'a git2::Commit, target: &RefStr, message: &str, sig: &git2::Signature, tree: &git2::Tree, ) -> Result, git2::Error> { 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: &git2::Repository) -> Result { 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 git2::Repository, ) -> Result, 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: &git2::Repository) -> Result<(), git2::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//* /// ``` pub fn configure_remote<'r>( repo: &'r git2::Repository, name: &str, fetch: &Url, push: &Url, ) -> Result, git2::Error> { let fetchspec = format!("+refs/heads/*:refs/remotes/{name}/*"); let remote = repo.remote_with_fetch(name, fetch.to_string().as_str(), &fetchspec)?; 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: &git2::Repository, remote: &str) -> Result<(), git2::Error> { repo.find_remote(remote)?.fetch::<&str>( &[], Some( git2::FetchOptions::new() .update_fetchhead(false) .prune(git2::FetchPrune::On) .download_tags(git2::AutotagOption::None), ), None, ) } /// Push `refspecs` to the given `remote` using the provided `namespace`. pub fn push<'a>( repo: &git2::Repository, remote: &str, refspecs: impl IntoIterator, &'a Qualified<'a>)>, ) -> Result<(), git2::Error> { let refspecs = refspecs .into_iter() .map(|(src, dst)| format!("{}:{}", src.as_str(), dst.as_str())); 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: &git2::Repository, remote: impl AsRef, branch: impl AsRef, merge: impl AsRef, ) -> Result<(), git2::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 ext::is_not_found_err(&e) { Ok(()) } else { Err(e) } })?; config.remove_multivar(&branch_merge, ".*").or_else(|e| { if ext::is_not_found_err(&e) { Ok(()) } else { Err(e) } })?; config.set_multivar(&branch_remote, ".*", remote)?; config.set_multivar(&branch_merge, ".*", merge)?; Ok(()) } /// Execute a git command by spawning a child process. pub fn run( repo: P, args: impl IntoIterator, envs: impl IntoIterator, ) -> Result where P: AsRef, S: AsRef, K: AsRef, V: AsRef, { let output = Command::new("git") .current_dir(repo) .envs(envs) .args(args) .output()?; if output.status.success() { let out = if output.stdout.is_empty() { &output.stderr } else { &output.stdout }; return Ok(String::from_utf8_lossy(out).into()); } Err(io::Error::new( io::ErrorKind::Other, String::from_utf8_lossy(&output.stderr), )) } /// Git URLs. pub mod url { use std::path::PathBuf; /// 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() } } } impl ToString for File { fn to_string(&self) -> String { format!("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()); } }