use std::io; use std::path::Path; use std::process::Command; use std::str::FromStr; use git_ref_format as format; use once_cell::sync::Lazy; use crate::collections::HashMap; use crate::crypto::PublicKey; 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_ref_format as fmt; pub use git_ref_format::{lit, name, refname, Component, Namespaced, Qualified, RefStr, RefString}; pub use git_url as url; pub use git_url::Url; pub use radicle_git_ext as ext; pub use storage::BranchName; /// Default port of the `git` transport protocol. pub const PROTOCOL_PORT: u16 = 9418; #[derive(thiserror::Error, Debug)] pub enum RefError { #[error("invalid ref name '{0}'")] InvalidName(format::RefString), #[error("invalid ref format: {0}")] Format(#[from] format::Error), #[error("expected ref to begin with 'refs/namespaces' but found '{0}'")] MissingNamespace(format::RefString), #[error("ref name contains invalid namespace identifier '{name}'")] Id { name: format::RefString, #[source] err: Box, }, } #[derive(thiserror::Error, Debug)] pub enum ListRefsError { #[error("git error: {0}")] Git(#[from] git2::Error), #[error("invalid ref: {0}")] InvalidRef(#[from] RefError), } impl<'a> From<&RemoteId> for Component<'a> { fn from(id: &RemoteId) -> Self { let refstr = RefString::try_from(id.to_string()).expect("encoded public keys are valid ref strings"); Component::from_refstring(refstr).expect("encoded public keys are valid refname components") } } pub mod refs { use super::*; /// Where project information is kept. pub static IDENTITY_BRANCH: Lazy = Lazy::new(|| refname!("radicle/id")); pub mod storage { use super::*; /// Create the [`Namespaced`] `branch` under the `remote` namespace, i.e. /// `refs/namespaces//refs/heads/` pub fn branch<'a>(remote: &RemoteId, branch: &RefStr) -> Namespaced<'a> { Qualified::from(git_ref_format::lit::refs_heads(branch)).with_namespace(remote.into()) } /// Get the branch used to track project information. /// /// `refs/namespaces//refs/heads/radicle/id` pub fn id(remote: &RemoteId) -> Namespaced { branch(remote, &IDENTITY_BRANCH) } } 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) } } } /// 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 = HashMap::default(); let mut remote = git2::Remote::create_detached(url)?; remote.connect(git2::Direction::Fetch)?; let refs = remote.list()?; for r in refs { let (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. pub fn parse_ref(s: &str) -> Result<(T, 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 => Err(RefError::MissingNamespace(input.to_owned())), Some(ns) => { let id = ns .namespace() .as_str() .parse() .map_err(|err| RefError::Id { name: input.to_owned(), err: Box::new(err), })?; let rest = ns.strip_namespace(); Ok((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, user: &str, ) -> Result, git2::Error> { let sig = git2::Signature::now(user, "anonymous@radicle.xyz")?; let tree_id = repo.index()?.write_tree()?; let tree = repo.find_tree(tree_id)?; 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 repository's radicle remote. /// /// The entry for this remote will be: /// ```text /// [remote.] /// url = /// fetch +refs/heads/*:refs/remotes//* /// ``` pub fn configure_remote<'r>( repo: &'r git2::Repository, name: &str, url: &Url, ) -> Result, git2::Error> { let fetch = format!("+refs/heads/*:refs/remotes/{name}/*"); let remote = repo.remote_with_fetch(name, url.to_string().as_str(), &fetch)?; Ok(remote) } /// Fetch from the given `remote` using the provided `namespace`. /// /// This uses [`Command`] under the hood and is the equivalent to: /// /// `GIT_NAMESPACE= git fetch ` pub fn fetch(repo: &git2::Repository, remote: &str, namespace: &RemoteId) -> io::Result { run( &repo.path(), ["fetch", remote], [("GIT_NAMESPACE", Component::from(namespace).as_str())], ) } /// Push `refspecs` to the given `remote` using the provided `namespace`. /// /// This uses [`Command`] under the hood and is the equivalent to: /// /// `GIT_NAMESPACE= git push []` pub fn push( repo: &git2::Repository, remote: &str, namespace: &RemoteId, refspecs: impl IntoIterator, ) -> io::Result where Ref: AsRef, { let mut args = vec!["push".to_owned(), remote.to_owned()]; let refspecs = refspecs .into_iter() .map(|(src, dst)| format!("{}:{}", src.as_ref().as_str(), dst.as_ref().as_str())); args.extend(refspecs); run( &repo.path(), args, [("GIT_NAMESPACE", Component::from(namespace).as_str())], ) } /// 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: &str, branch: &str, merge: &str, ) -> Result<(), git2::Error> { let mut config = repo.config()?; let branch_remote = format!("branch.{}.remote", branch); let branch_merge = format!("branch.{}.merge", branch); 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), )) } /// Parsing and formatting of commit objects. /// This module exists to work with commits that have multiple signature headers. pub mod commit { use std::str::FromStr; /// A parsed commit object. /// Contains the full commit header and body. /// /// Can be created with the [`FromStr`] instance, and formatted with the [`ToString`] /// instance. #[derive(Debug)] pub struct CommitObject { headers: Vec<(String, String)>, message: String, } impl CommitObject { /// Get the commit message. pub fn message(&self) -> &str { self.message.as_str() } /// Iterate over the headers, in order. pub fn headers(&self) -> impl Iterator { self.headers.iter().map(|(k, v)| (k.as_str(), v.as_str())) } /// Iterate over matching header values. pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator + '_ { self.headers .iter() .filter(move |(k, _)| k == name) .map(|(_, v)| v.as_str()) } /// Push a header to the end of the headers section. pub fn push_header(&mut self, name: &str, value: &str) { self.headers .push((name.to_owned(), value.trim().to_owned())); } } #[derive(thiserror::Error, Debug)] pub enum ParseError { #[error("invalid git commit object format")] InvalidFormat, } impl TryFrom for CommitObject { type Error = ParseError; fn try_from(value: git2::Buf) -> Result { value.as_str().ok_or(ParseError::InvalidFormat)?.parse() } } impl FromStr for CommitObject { type Err = ParseError; fn from_str(buffer: &str) -> Result { let mut headers = Vec::new(); let (header, message) = buffer.split_once("\n\n").ok_or(ParseError::InvalidFormat)?; for line in header.lines() { if let Some(rest) = line.strip_prefix(' ') { let value: &mut String = headers .last_mut() .map(|(_, v)| v) .ok_or(ParseError::InvalidFormat)?; value.push('\n'); value.push_str(rest); } else if let Some((name, value)) = line.split_once(' ') { headers.push((name.to_owned(), value.to_owned())); } else { return Err(ParseError::InvalidFormat); } } Ok(Self { headers, message: message.to_owned(), }) } } impl ToString for CommitObject { fn to_string(&self) -> String { let mut buf = String::new(); for (name, value) in &self.headers { buf.push_str(name); buf.push(' '); buf.push_str(value.replace('\n', "\n ").as_str()); buf.push('\n'); } buf.push('\n'); buf.push_str(self.message.as_str()); buf } } #[cfg(test)] mod test { use super::*; const UNSIGNED: &str = "\ tree c66cc435f83ed0fba90ed4500e9b4b96e9bd001b parent af06ad645133f580a87895353508053c5de60716 author Alexis Sellier 1664467633 +0200 committer Alexis Sellier 1664786099 +0200 Add SSH functionality with new `radicle-ssh` We borrow code from `thrussh`, refactored to be runtime-less. "; const SIGNATURE: &str = "\ -----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvjrQogRxxLjzzWns8+mKJAGzEX 4fm2ALoN7pyvD2ttQAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 AAAAQIQvhIewOgGfnXLgR5Qe1ZEr2vjekYXTdOfNWICi6ZiosgfZnIqV0enCPC4arVqQg+ GPp0HqxaB911OnSAr6bwU= -----END SSH SIGNATURE----- "; const SIGNED: &str = "\ tree c66cc435f83ed0fba90ed4500e9b4b96e9bd001b parent af06ad645133f580a87895353508053c5de60716 author Alexis Sellier 1664467633 +0200 committer Alexis Sellier 1664786099 +0200 other e6fe3c97619deb8ab4198620f9a7eb79d98363dd gpgsig -----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvjrQogRxxLjzzWns8+mKJAGzEX 4fm2ALoN7pyvD2ttQAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 AAAAQIQvhIewOgGfnXLgR5Qe1ZEr2vjekYXTdOfNWICi6ZiosgfZnIqV0enCPC4arVqQg+ GPp0HqxaB911OnSAr6bwU= -----END SSH SIGNATURE----- gpgsig -----BEGIN SSH SIGNATURE----- U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgvjrQogRxxLjzzWns8+mKJAGzEX 4fm2ALoN7pyvD2ttQAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5 AAAAQIQvhIewOgGfnXLgR5Qe1ZEr2vjekYXTdOfNWICi6ZiosgfZnIqV0enCPC4arVqQg+ GPp0HqxaB911OnSAr6bwU= -----END SSH SIGNATURE----- Add SSH functionality with new `radicle-ssh` We borrow code from `thrussh`, refactored to be runtime-less. "; #[test] fn test_push_header() { let mut commit = CommitObject::from_str(UNSIGNED).unwrap(); commit.push_header("other", "e6fe3c97619deb8ab4198620f9a7eb79d98363dd"); commit.push_header("gpgsig", SIGNATURE); commit.push_header("gpgsig", SIGNATURE); assert_eq!(commit.to_string(), SIGNED); } #[test] fn test_get_header() { let commit = CommitObject::from_str(SIGNED).unwrap(); assert_eq!( commit.values("gpgsig").collect::>(), vec![SIGNATURE.trim(), SIGNATURE.trim()] ); assert_eq!( commit.values("parent").collect::>(), vec![String::from("af06ad645133f580a87895353508053c5de60716")], ); assert!(commit.values("unknown").next().is_none()); } #[test] fn test_conversion() { assert_eq!(CommitObject::from_str(SIGNED).unwrap().to_string(), SIGNED); assert_eq!( CommitObject::from_str(UNSIGNED).unwrap().to_string(), UNSIGNED ); } } }