radicle-heartwood-lfs/radicle/src/git.rs

522 lines
16 KiB
Rust

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<dyn std::error::Error + Send + Sync + 'static>,
},
}
#[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<RefString> = Lazy::new(|| refname!("radicle/id"));
pub mod storage {
use super::*;
/// Create the [`Namespaced`] `branch` under the `remote` namespace, i.e.
/// `refs/namespaces/<remote>/refs/heads/<branch>`
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/<remote>/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/<branch>`.
pub fn branch(branch: &RefStr) -> RefString {
refname!("refs/heads").join(branch)
}
/// Create a [`RefString`] that corresponds to `refs/notes/<name>`.
pub fn note(name: &RefStr) -> RefString {
refname!("refs/notes").join(name)
}
/// Create a [`RefString`] that corresponds to `refs/remotes/<remote>/<branch>`.
pub fn remote_branch(remote: &RefStr, branch: &RefStr) -> RefString {
refname!("refs/remotes").and(remote).and(branch)
}
/// Create a [`RefString`] that corresponds to `refs/tags/<branch>`.
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<HashMap<RemoteId, Refs>, 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::<PublicKey>(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<T>(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::Commit<'a>, 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::Commit<'a>, 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<git2::Commit, git2::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 git2::Repository,
) -> Result<git2::Tree<'r>, 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.<name>]
/// url = <url>
/// fetch +refs/heads/*:refs/remotes/<name>/*
/// ```
pub fn configure_remote<'r>(
repo: &'r git2::Repository,
name: &str,
url: &Url,
) -> Result<git2::Remote<'r>, 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=<namespace> git fetch <remote>`
pub fn fetch(repo: &git2::Repository, remote: &str, namespace: &RemoteId) -> io::Result<String> {
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=<namespace> git push <remote> [<refspecs>]`
pub fn push<Ref>(
repo: &git2::Repository,
remote: &str,
namespace: &RemoteId,
refspecs: impl IntoIterator<Item = (Ref, Ref)>,
) -> io::Result<String>
where
Ref: AsRef<RefStr>,
{
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<P, S, K, V>(
repo: &P,
args: impl IntoIterator<Item = S>,
envs: impl IntoIterator<Item = (K, V)>,
) -> Result<String, io::Error>
where
P: AsRef<Path>,
S: AsRef<std::ffi::OsStr>,
K: AsRef<std::ffi::OsStr>,
V: AsRef<std::ffi::OsStr>,
{
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<Item = (&str, &str)> {
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<Item = &'a str> + '_ {
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<git2::Buf> for CommitObject {
type Error = ParseError;
fn try_from(value: git2::Buf) -> Result<Self, Self::Error> {
value.as_str().ok_or(ParseError::InvalidFormat)?.parse()
}
}
impl FromStr for CommitObject {
type Err = ParseError;
fn from_str(buffer: &str) -> Result<Self, Self::Err> {
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 <alexis@radicle.xyz> 1664467633 +0200
committer Alexis Sellier <alexis@radicle.xyz> 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 <alexis@radicle.xyz> 1664467633 +0200
committer Alexis Sellier <alexis@radicle.xyz> 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<_>>(),
vec![SIGNATURE.trim(), SIGNATURE.trim()]
);
assert_eq!(
commit.values("parent").collect::<Vec<_>>(),
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
);
}
}
}