//! Git-related functions and types. 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::Context as _; use anyhow::anyhow; use thiserror::Error; use radicle::crypto::ssh; use radicle::git; use radicle::git::{VERSION_REQUIRED, Version}; use radicle::prelude::{NodeId, RepoId}; use radicle::storage::git::transport; pub use radicle::git::Oid; pub use radicle::git::raw::{ AnnotatedCommit, Commit, Direction, ErrorCode, ErrorExt as _, MergeAnalysis, MergeOptions, Reference, Repository, Signature, build::CheckoutBuilder, }; 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"; /// Git revision parameter. Supports extended SHA-1 syntax. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Rev(String); impl Rev { /// Return the revision as a string. #[must_use] pub fn as_str(&self) -> &str { &self.0 } /// Resolve the revision to an [`From`]. pub fn resolve(&self, repo: &Repository) -> Result where T: From, { 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 { self.0.fmt(f) } } impl From 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, inner: git::raw::Remote<'a>, } impl<'a> TryFrom> for Remote<'a> { type Error = RemoteError; fn try_from(value: git::raw::Remote<'a>) -> Result { 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 = git::raw::Remote<'a>; fn deref(&self) -> &Self::Target { &self.inner } } impl DerefMut for Remote<'_> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } } /// Get the git repository in the current directory. pub fn repository() -> Result { match Repository::open(".") { Ok(repo) => Ok(repo), Err(err) => Err(err).context("the current working directory is not a git repository"), } } /// Execute a git command by spawning a child process. /// Returns [`Result::Ok`] if the command *exited successfully*. pub fn git>( repo: &std::path::Path, args: impl IntoIterator, ) -> anyhow::Result { let output = radicle::git::run(Some(repo), args)?; if !output.status.success() { anyhow::bail!( "`git` exited with status {}, stderr and stdout follow:\n{}\n{}\n", output.status, String::from_utf8_lossy(&output.stderr), String::from_utf8_lossy(&output.stdout), ) } Ok(output) } /// Configure SSH signing in the given git repo, for the given peer. 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(()) } /// Write a `.gitsigners` file in the given repository. /// Fails if the file already exists. pub fn write_gitsigners<'a>( repo: &Path, signers: impl IntoIterator, ) -> Result { 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()) } /// Add signers to the repository's `.gitsigners` file. pub fn add_gitsigners<'a>( path: &Path, signers: impl IntoIterator, ) -> 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(()) } /// Read a `.gitsigners` file. Returns SSH keys. pub fn read_gitsigners(path: &Path) -> Result, 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) } /// Add a path to the repository's git ignore file. Creates the /// ignore file if it does not exist. 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()) } /// Check whether SSH or GPG signing is configured in the given repository. pub fn is_signing_configured(repo: &Path) -> Result { Ok(git(repo, ["config", CONFIG_SIGNING_KEY]).is_ok()) } /// Return the list of Radicle remotes for the given repository. pub fn rad_remotes(repo: &Repository) -> anyhow::Result>> { let remotes: Vec<_> = repo .remotes()? .iter() .filter_map(|name| { let remote = repo.find_remote(name?).ok()?; Remote::try_from(remote).ok() }) .collect(); Ok(remotes) } /// Check if the git remote is configured for the `Repository`. pub fn is_remote(repo: &Repository, alias: &str) -> anyhow::Result { match repo.find_remote(alias) { Ok(_) => Ok(true), Err(err) if err.is_not_found() => Ok(false), Err(err) => Err(err.into()), } } /// Get the repository's "rad" remote. pub fn rad_remote(repo: &Repository) -> anyhow::Result<(git::raw::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<()> { // N.b. ensure that we are removing the remote for the correct RID 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"), } } /// Set up an upstream tracking branch for the given remote and branch. /// Creates the tracking branch if it does not exist. /// /// > scooby/master...rad/scooby/heads/master /// pub fn set_tracking(repo: &Repository, remote: &NodeId, branch: &str) -> anyhow::Result { // The tracking branch name, eg. 'scooby/master' let branch_name = format!("{remote}/{branch}"); // The remote branch being tracked, eg. 'rad/scooby/heads/master' let remote_branch_name = format!("rad/{remote}/heads/{branch}"); // The target reference this branch should be set to. 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) } /// Get the name of the remote of the given branch, if any. pub fn branch_remote(repo: &Repository, branch: &str) -> anyhow::Result { let cfg = repo.config()?; let remote = cfg.get_string(&format!("branch.{branch}.remote"))?; Ok(remote) } /// Check that the system's git version is supported. Returns an error otherwise. pub fn check_version() -> Result { 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 add_tag( repo: &Repository, message: &str, patch_tag_name: &str, ) -> anyhow::Result { let head = repo.head()?; let commit = head.peel(git::raw::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)) } /// From a commit hash, return the signer's fingerprint, if any. pub fn commit_ssh_fingerprint(path: &Path, sha1: &str) -> Result, io::Error> { use std::io::BufRead; use std::io::BufReader; let output = Command::new("git") .current_dir(path) // We need to place the command execution in the git dir .args(["show", sha1, "--pretty=%GF", "--raw"]) .output()?; if !output.status.success() { return Err(io::Error::other(String::from_utf8_lossy(&output.stderr))); } let string = BufReader::new(output.stdout.as_slice()) .lines() .next() .transpose()?; // We only return a fingerprint if it's not an empty string if let Some(s) = string && !s.is_empty() { return Ok(Some(s)); } Ok(None) }