pub mod ddiff;
pub mod pretty_diff;
pub mod unified_diff;
use std::collections::HashSet;
use std::fmt::Display;
use std::fs::{File, OpenOptions};
use std::io;
use std::io::Write;
use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::str::FromStr;
use anyhow::anyhow;
use anyhow::Context as _;
use thiserror::Error;
use radicle::crypto::ssh;
use radicle::git;
use radicle::git::raw as git2;
use radicle::git::{Version, VERSION_REQUIRED};
use radicle::prelude::{NodeId, RepoId};
use radicle::storage::git::transport;
pub use radicle::git::raw::{
build::CheckoutBuilder, AnnotatedCommit, Commit, Direction, ErrorCode, MergeAnalysis,
MergeOptions, Oid, Reference, Repository, Signature,
};
pub const CONFIG_COMMIT_GPG_SIGN: &str = "commit.gpgsign";
pub const CONFIG_SIGNING_KEY: &str = "user.signingkey";
pub const CONFIG_GPG_FORMAT: &str = "gpg.format";
pub const CONFIG_GPG_SSH_PROGRAM: &str = "gpg.ssh.program";
pub const CONFIG_GPG_SSH_ALLOWED_SIGNERS: &str = "gpg.ssh.allowedSignersFile";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rev(String);
impl Rev {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn resolve<T>(&self, repo: &git2::Repository) -> Result<T, git2::Error>
where
T: From<git2::Oid>,
{
let object = repo.revparse_single(self.as_str())?;
Ok(object.id().into())
}
}
impl Display for Rev {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<String> for Rev {
fn from(value: String) -> Self {
Rev(value)
}
}
#[derive(Error, Debug)]
pub enum RemoteError {
#[error("url malformed: {0}")]
ParseUrl(#[from] transport::local::UrlError),
#[error("remote `url` not found")]
MissingUrl,
#[error("remote `name` not found")]
MissingName,
}
#[derive(Clone)]
pub struct Remote<'a> {
pub name: String,
pub url: radicle::git::Url,
pub pushurl: Option<radicle::git::Url>,
inner: git2::Remote<'a>,
}
impl<'a> TryFrom<git2::Remote<'a>> for Remote<'a> {
type Error = RemoteError;
fn try_from(value: git2::Remote<'a>) -> Result<Self, Self::Error> {
let url = value.url().map_or(Err(RemoteError::MissingUrl), |url| {
Ok(radicle::git::Url::from_str(url)?)
})?;
let pushurl = value
.pushurl()
.map(radicle::git::Url::from_str)
.transpose()?;
let name = value.name().ok_or(RemoteError::MissingName)?;
Ok(Self {
name: name.to_owned(),
url,
pushurl,
inner: value,
})
}
}
impl<'a> Deref for Remote<'a> {
type Target = git2::Remote<'a>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<'a> DerefMut for Remote<'a> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
pub fn repository() -> Result<Repository, anyhow::Error> {
match Repository::open(".") {
Ok(repo) => Ok(repo),
Err(err) => Err(err).context("the current working directory is not a git repository"),
}
}
pub fn git<S: AsRef<std::ffi::OsStr>>(
repo: &std::path::Path,
args: impl IntoIterator<Item = S>,
) -> Result<String, io::Error> {
radicle::git::run::<_, _, &str, &str>(repo, args, [])
}
pub fn configure_signing(repo: &Path, node_id: &NodeId) -> Result<(), anyhow::Error> {
let key = ssh::fmt::key(node_id);
git(repo, ["config", "--local", CONFIG_SIGNING_KEY, &key])?;
git(repo, ["config", "--local", CONFIG_GPG_FORMAT, "ssh"])?;
git(repo, ["config", "--local", CONFIG_COMMIT_GPG_SIGN, "true"])?;
git(
repo,
["config", "--local", CONFIG_GPG_SSH_PROGRAM, "ssh-keygen"],
)?;
git(
repo,
[
"config",
"--local",
CONFIG_GPG_SSH_ALLOWED_SIGNERS,
".gitsigners",
],
)?;
Ok(())
}
pub fn write_gitsigners<'a>(
repo: &Path,
signers: impl IntoIterator<Item = &'a NodeId>,
) -> Result<PathBuf, io::Error> {
let path = Path::new(".gitsigners");
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(repo.join(path))?;
for node_id in signers.into_iter() {
write_gitsigner(&mut file, node_id)?;
}
Ok(path.to_path_buf())
}
pub fn add_gitsigners<'a>(
path: &Path,
signers: impl IntoIterator<Item = &'a NodeId>,
) -> Result<(), io::Error> {
let mut file = OpenOptions::new()
.append(true)
.open(path.join(".gitsigners"))?;
for node_id in signers.into_iter() {
write_gitsigner(&mut file, node_id)?;
}
Ok(())
}
pub fn read_gitsigners(path: &Path) -> Result<HashSet<String>, io::Error> {
use std::io::BufRead;
let mut keys = HashSet::new();
let file = File::open(path.join(".gitsigners"))?;
for line in io::BufReader::new(file).lines() {
let line = line?;
if let Some((label, key)) = line.split_once(' ') {
if let Ok(peer) = NodeId::from_str(label) {
let expected = ssh::fmt::key(&peer);
if key != expected {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"key does not match peer id",
));
}
}
keys.insert(key.to_owned());
}
}
Ok(keys)
}
pub fn ignore(repo: &Path, item: &Path) -> Result<(), io::Error> {
let mut ignore = OpenOptions::new()
.append(true)
.create(true)
.open(repo.join(".gitignore"))?;
writeln!(ignore, "{}", item.display())
}
pub fn is_signing_configured(repo: &Path) -> Result<bool, anyhow::Error> {
Ok(git(repo, ["config", CONFIG_SIGNING_KEY]).is_ok())
}
pub fn rad_remotes(repo: &git2::Repository) -> anyhow::Result<Vec<Remote>> {
let remotes: Vec<_> = repo
.remotes()?
.iter()
.filter_map(|name| {
let remote = repo.find_remote(name?).ok()?;
Remote::try_from(remote).ok()
})
.collect();
Ok(remotes)
}
pub fn is_remote(repo: &git2::Repository, alias: &str) -> anyhow::Result<bool> {
match repo.find_remote(alias) {
Ok(_) => Ok(true),
Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(false),
Err(err) => Err(err.into()),
}
}
pub fn rad_remote(repo: &Repository) -> anyhow::Result<(git2::Remote, RepoId)> {
match radicle::rad::remote(repo) {
Ok((remote, id)) => Ok((remote, id)),
Err(radicle::rad::RemoteError::NotFound(_)) => Err(anyhow!(
"could not find radicle remote in git config; did you forget to run `rad init`?"
)),
Err(err) => Err(err).context("could not read git remote configuration"),
}
}
pub fn remove_remote(repo: &Repository, rid: &RepoId) -> anyhow::Result<()> {
match radicle::rad::remote(repo) {
Ok((_, rid_)) => {
if rid_ != *rid {
return Err(radicle::rad::RemoteError::RidMismatch {
found: rid_,
expected: *rid,
}
.into());
}
}
Err(radicle::rad::RemoteError::NotFound(_)) => return Ok(()),
Err(err) => return Err(err).context("could not read git remote configuration"),
};
match radicle::rad::remove_remote(repo) {
Ok(()) => Ok(()),
Err(err) => Err(err).context("could not read git remote configuration"),
}
}
pub fn set_tracking(repo: &Repository, remote: &NodeId, branch: &str) -> anyhow::Result<String> {
let branch_name = format!("{remote}/{branch}");
let remote_branch_name = format!("rad/{remote}/heads/{branch}");
let target = format!("refs/remotes/{remote_branch_name}");
let reference = repo.find_reference(&target)?;
let commit = reference.peel_to_commit()?;
repo.branch(&branch_name, &commit, true)?
.set_upstream(Some(&remote_branch_name))?;
Ok(branch_name)
}
pub fn branch_remote(repo: &Repository, branch: &str) -> anyhow::Result<String> {
let cfg = repo.config()?;
let remote = cfg.get_string(&format!("branch.{branch}.remote"))?;
Ok(remote)
}
pub fn check_version() -> Result<Version, anyhow::Error> {
let git_version = git::version()?;
if git_version < VERSION_REQUIRED {
anyhow::bail!("a minimum git version of {} is required", VERSION_REQUIRED);
}
Ok(git_version)
}
pub fn parse_remote(refspec: &str) -> Option<(NodeId, &str)> {
refspec
.strip_prefix("refs/remotes/")
.and_then(|s| s.split_once('/'))
.and_then(|(peer, r)| NodeId::from_str(peer).ok().map(|p| (p, r)))
}
pub fn view_diff(
repo: &git2::Repository,
left: &git2::Oid,
right: &git2::Oid,
) -> anyhow::Result<()> {
let workdir = repo
.workdir()
.ok_or_else(|| anyhow!("Could not get workdir current repository."))?;
let left = format!("{:.7}", left.to_string());
let right = format!("{:.7}", right.to_string());
let mut git = Command::new("git")
.current_dir(workdir)
.args(["diff", &left, &right])
.spawn()?;
git.wait()?;
Ok(())
}
pub fn add_tag(
repo: &git2::Repository,
message: &str,
patch_tag_name: &str,
) -> anyhow::Result<git2::Oid> {
let head = repo.head()?;
let commit = head.peel(git2::ObjectType::Commit).unwrap();
let oid = repo.tag(patch_tag_name, &commit, &repo.signature()?, message, false)?;
Ok(oid)
}
fn write_gitsigner(mut w: impl io::Write, signer: &NodeId) -> io::Result<()> {
writeln!(w, "{} {}", signer, ssh::fmt::key(signer))
}
pub fn commit_ssh_fingerprint(path: &Path, sha1: &str) -> Result<Option<String>, io::Error> {
use std::io::BufRead;
use std::io::BufReader;
let output = Command::new("git")
.current_dir(path) .args(["show", sha1, "--pretty=%GF", "--raw"])
.output()?;
if !output.status.success() {
return Err(io::Error::new(
io::ErrorKind::Other,
String::from_utf8_lossy(&output.stderr),
));
}
let string = BufReader::new(output.stdout.as_slice())
.lines()
.next()
.transpose()?;
if let Some(s) = string {
if !s.is_empty() {
return Ok(Some(s));
}
}
Ok(None)
}