git.rs
1 //! Git-related functions and types. 2 3 pub mod ddiff; 4 pub mod pretty_diff; 5 pub mod unified_diff; 6 7 use std::collections::HashSet; 8 use std::fmt::Display; 9 use std::fs::{File, OpenOptions}; 10 use std::io; 11 use std::io::Write; 12 use std::ops::{Deref, DerefMut}; 13 use std::path::{Path, PathBuf}; 14 use std::process::Command; 15 use std::str::FromStr; 16 17 use anyhow::anyhow; 18 use anyhow::Context as _; 19 use thiserror::Error; 20 21 use radicle::crypto::ssh; 22 use radicle::git; 23 use radicle::git::raw as git2; 24 use radicle::git::{Version, VERSION_REQUIRED}; 25 use radicle::prelude::{Id, NodeId}; 26 use radicle::storage::git::transport; 27 28 pub use radicle::git::raw::{ 29 build::CheckoutBuilder, AnnotatedCommit, Commit, Direction, ErrorCode, MergeAnalysis, 30 MergeOptions, Oid, Reference, Repository, Signature, 31 }; 32 33 pub const CONFIG_COMMIT_GPG_SIGN: &str = "commit.gpgsign"; 34 pub const CONFIG_SIGNING_KEY: &str = "user.signingkey"; 35 pub const CONFIG_GPG_FORMAT: &str = "gpg.format"; 36 pub const CONFIG_GPG_SSH_PROGRAM: &str = "gpg.ssh.program"; 37 pub const CONFIG_GPG_SSH_ALLOWED_SIGNERS: &str = "gpg.ssh.allowedSignersFile"; 38 39 /// Git revision parameter. Supports extended SHA-1 syntax. 40 #[derive(Debug, Clone, PartialEq, Eq)] 41 pub struct Rev(String); 42 43 impl Rev { 44 /// Return the revision as a string. 45 pub fn as_str(&self) -> &str { 46 &self.0 47 } 48 49 /// Resolve the revision to an [`From<git2::Oid>`]. 50 pub fn resolve<T>(&self, repo: &git2::Repository) -> Result<T, git2::Error> 51 where 52 T: From<git2::Oid>, 53 { 54 let object = repo.revparse_single(self.as_str())?; 55 Ok(object.id().into()) 56 } 57 } 58 59 impl Display for Rev { 60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 61 write!(f, "{}", self.0) 62 } 63 } 64 65 impl From<String> for Rev { 66 fn from(value: String) -> Self { 67 Rev(value) 68 } 69 } 70 71 #[derive(Error, Debug)] 72 pub enum RemoteError { 73 #[error("url malformed: {0}")] 74 ParseUrl(#[from] transport::local::UrlError), 75 #[error("remote `url` not found")] 76 MissingUrl, 77 #[error("remote `name` not found")] 78 MissingName, 79 } 80 81 #[derive(Clone)] 82 pub struct Remote<'a> { 83 pub name: String, 84 pub url: radicle::git::Url, 85 pub pushurl: Option<radicle::git::Url>, 86 87 inner: git2::Remote<'a>, 88 } 89 90 impl<'a> TryFrom<git2::Remote<'a>> for Remote<'a> { 91 type Error = RemoteError; 92 93 fn try_from(value: git2::Remote<'a>) -> Result<Self, Self::Error> { 94 let url = value.url().map_or(Err(RemoteError::MissingUrl), |url| { 95 Ok(radicle::git::Url::from_str(url)?) 96 })?; 97 let pushurl = value 98 .pushurl() 99 .map(radicle::git::Url::from_str) 100 .transpose()?; 101 let name = value.name().ok_or(RemoteError::MissingName)?; 102 103 Ok(Self { 104 name: name.to_owned(), 105 url, 106 pushurl, 107 inner: value, 108 }) 109 } 110 } 111 112 impl<'a> Deref for Remote<'a> { 113 type Target = git2::Remote<'a>; 114 115 fn deref(&self) -> &Self::Target { 116 &self.inner 117 } 118 } 119 120 impl<'a> DerefMut for Remote<'a> { 121 fn deref_mut(&mut self) -> &mut Self::Target { 122 &mut self.inner 123 } 124 } 125 126 /// Get the git repository in the current directory. 127 pub fn repository() -> Result<Repository, anyhow::Error> { 128 match Repository::open(".") { 129 Ok(repo) => Ok(repo), 130 Err(err) => Err(err).context("the current working directory is not a git repository"), 131 } 132 } 133 134 /// Execute a git command by spawning a child process. 135 pub fn git<S: AsRef<std::ffi::OsStr>>( 136 repo: &std::path::Path, 137 args: impl IntoIterator<Item = S>, 138 ) -> Result<String, io::Error> { 139 radicle::git::run::<_, _, &str, &str>(repo, args, []) 140 } 141 142 /// Configure SSH signing in the given git repo, for the given peer. 143 pub fn configure_signing(repo: &Path, node_id: &NodeId) -> Result<(), anyhow::Error> { 144 let key = ssh::fmt::key(node_id); 145 146 git(repo, ["config", "--local", CONFIG_SIGNING_KEY, &key])?; 147 git(repo, ["config", "--local", CONFIG_GPG_FORMAT, "ssh"])?; 148 git(repo, ["config", "--local", CONFIG_COMMIT_GPG_SIGN, "true"])?; 149 git( 150 repo, 151 ["config", "--local", CONFIG_GPG_SSH_PROGRAM, "ssh-keygen"], 152 )?; 153 git( 154 repo, 155 [ 156 "config", 157 "--local", 158 CONFIG_GPG_SSH_ALLOWED_SIGNERS, 159 ".gitsigners", 160 ], 161 )?; 162 163 Ok(()) 164 } 165 166 /// Write a `.gitsigners` file in the given repository. 167 /// Fails if the file already exists. 168 pub fn write_gitsigners<'a>( 169 repo: &Path, 170 signers: impl IntoIterator<Item = &'a NodeId>, 171 ) -> Result<PathBuf, io::Error> { 172 let path = Path::new(".gitsigners"); 173 let mut file = OpenOptions::new() 174 .write(true) 175 .create_new(true) 176 .open(repo.join(path))?; 177 178 for node_id in signers.into_iter() { 179 write_gitsigner(&mut file, node_id)?; 180 } 181 Ok(path.to_path_buf()) 182 } 183 184 /// Add signers to the repository's `.gitsigners` file. 185 pub fn add_gitsigners<'a>( 186 path: &Path, 187 signers: impl IntoIterator<Item = &'a NodeId>, 188 ) -> Result<(), io::Error> { 189 let mut file = OpenOptions::new() 190 .append(true) 191 .open(path.join(".gitsigners"))?; 192 193 for node_id in signers.into_iter() { 194 write_gitsigner(&mut file, node_id)?; 195 } 196 Ok(()) 197 } 198 199 /// Read a `.gitsigners` file. Returns SSH keys. 200 pub fn read_gitsigners(path: &Path) -> Result<HashSet<String>, io::Error> { 201 use std::io::BufRead; 202 203 let mut keys = HashSet::new(); 204 let file = File::open(path.join(".gitsigners"))?; 205 206 for line in io::BufReader::new(file).lines() { 207 let line = line?; 208 if let Some((label, key)) = line.split_once(' ') { 209 if let Ok(peer) = NodeId::from_str(label) { 210 let expected = ssh::fmt::key(&peer); 211 if key != expected { 212 return Err(io::Error::new( 213 io::ErrorKind::InvalidData, 214 "key does not match peer id", 215 )); 216 } 217 } 218 keys.insert(key.to_owned()); 219 } 220 } 221 Ok(keys) 222 } 223 224 /// Add a path to the repository's git ignore file. Creates the 225 /// ignore file if it does not exist. 226 pub fn ignore(repo: &Path, item: &Path) -> Result<(), io::Error> { 227 let mut ignore = OpenOptions::new() 228 .append(true) 229 .create(true) 230 .open(repo.join(".gitignore"))?; 231 232 writeln!(ignore, "{}", item.display()) 233 } 234 235 /// Check whether SSH or GPG signing is configured in the given repository. 236 pub fn is_signing_configured(repo: &Path) -> Result<bool, anyhow::Error> { 237 Ok(git(repo, ["config", CONFIG_SIGNING_KEY]).is_ok()) 238 } 239 240 /// Return the list of radicle remotes for the given repository. 241 pub fn rad_remotes(repo: &git2::Repository) -> anyhow::Result<Vec<Remote>> { 242 let remotes: Vec<_> = repo 243 .remotes()? 244 .iter() 245 .filter_map(|name| { 246 let remote = repo.find_remote(name?).ok()?; 247 Remote::try_from(remote).ok() 248 }) 249 .collect(); 250 Ok(remotes) 251 } 252 253 /// Check if the git remote is configured for the `Repository`. 254 pub fn is_remote(repo: &git2::Repository, alias: &str) -> anyhow::Result<bool> { 255 match repo.find_remote(alias) { 256 Ok(_) => Ok(true), 257 Err(err) if err.code() == git2::ErrorCode::NotFound => Ok(false), 258 Err(err) => Err(err.into()), 259 } 260 } 261 262 /// Get the repository's "rad" remote. 263 pub fn rad_remote(repo: &Repository) -> anyhow::Result<(git2::Remote, Id)> { 264 match radicle::rad::remote(repo) { 265 Ok((remote, id)) => Ok((remote, id)), 266 Err(radicle::rad::RemoteError::NotFound(_)) => Err(anyhow!( 267 "could not find radicle remote in git config; did you forget to run `rad init`?" 268 )), 269 Err(err) => Err(err).context("could not read git remote configuration"), 270 } 271 } 272 273 pub fn remove_remote(repo: &Repository, rid: &Id) -> anyhow::Result<()> { 274 // N.b. ensure that we are removing the remote for the correct RID 275 match radicle::rad::remote(repo) { 276 Ok((_, rid_)) => { 277 if rid_ != *rid { 278 return Err(radicle::rad::RemoteError::RidMismatch { 279 found: rid_, 280 expected: *rid, 281 } 282 .into()); 283 } 284 } 285 Err(radicle::rad::RemoteError::NotFound(_)) => return Ok(()), 286 Err(err) => return Err(err).context("could not read git remote configuration"), 287 }; 288 289 match radicle::rad::remove_remote(repo) { 290 Ok(()) => Ok(()), 291 Err(err) => Err(err).context("could not read git remote configuration"), 292 } 293 } 294 295 /// Setup an upstream tracking branch for the given remote and branch. 296 /// Creates the tracking branch if it does not exist. 297 /// 298 /// > scooby/master...rad/scooby/heads/master 299 /// 300 pub fn set_tracking(repo: &Repository, remote: &NodeId, branch: &str) -> anyhow::Result<String> { 301 // The tracking branch name, eg. 'scooby/master' 302 let branch_name = format!("{remote}/{branch}"); 303 // The remote branch being tracked, eg. 'rad/scooby/heads/master' 304 let remote_branch_name = format!("rad/{remote}/heads/{branch}"); 305 // The target reference this branch should be set to. 306 let target = format!("refs/remotes/{remote_branch_name}"); 307 let reference = repo.find_reference(&target)?; 308 let commit = reference.peel_to_commit()?; 309 310 repo.branch(&branch_name, &commit, true)? 311 .set_upstream(Some(&remote_branch_name))?; 312 313 Ok(branch_name) 314 } 315 316 /// Get the name of the remote of the given branch, if any. 317 pub fn branch_remote(repo: &Repository, branch: &str) -> anyhow::Result<String> { 318 let cfg = repo.config()?; 319 let remote = cfg.get_string(&format!("branch.{branch}.remote"))?; 320 321 Ok(remote) 322 } 323 324 /// Check that the system's git version is supported. Returns an error otherwise. 325 pub fn check_version() -> Result<Version, anyhow::Error> { 326 let git_version = git::version()?; 327 328 if git_version < VERSION_REQUIRED { 329 anyhow::bail!("a minimum git version of {} is required", VERSION_REQUIRED); 330 } 331 Ok(git_version) 332 } 333 334 /// Parse a remote refspec into a peer id and ref. 335 pub fn parse_remote(refspec: &str) -> Option<(NodeId, &str)> { 336 refspec 337 .strip_prefix("refs/remotes/") 338 .and_then(|s| s.split_once('/')) 339 .and_then(|(peer, r)| NodeId::from_str(peer).ok().map(|p| (p, r))) 340 } 341 342 pub fn view_diff( 343 repo: &git2::Repository, 344 left: &git2::Oid, 345 right: &git2::Oid, 346 ) -> anyhow::Result<()> { 347 // TODO(erikli): Replace with repo.diff() 348 let workdir = repo 349 .workdir() 350 .ok_or_else(|| anyhow!("Could not get workdir current repository."))?; 351 352 let left = format!("{:.7}", left.to_string()); 353 let right = format!("{:.7}", right.to_string()); 354 355 let mut git = Command::new("git") 356 .current_dir(workdir) 357 .args(["diff", &left, &right]) 358 .spawn()?; 359 git.wait()?; 360 361 Ok(()) 362 } 363 364 pub fn add_tag( 365 repo: &git2::Repository, 366 message: &str, 367 patch_tag_name: &str, 368 ) -> anyhow::Result<git2::Oid> { 369 let head = repo.head()?; 370 let commit = head.peel(git2::ObjectType::Commit).unwrap(); 371 let oid = repo.tag(patch_tag_name, &commit, &repo.signature()?, message, false)?; 372 373 Ok(oid) 374 } 375 376 fn write_gitsigner(mut w: impl io::Write, signer: &NodeId) -> io::Result<()> { 377 writeln!(w, "{} {}", signer, ssh::fmt::key(signer)) 378 } 379 380 /// From a commit hash, return the signer's fingerprint, if any. 381 pub fn commit_ssh_fingerprint(path: &Path, sha1: &str) -> Result<Option<String>, io::Error> { 382 use std::io::BufRead; 383 use std::io::BufReader; 384 385 let output = Command::new("git") 386 .current_dir(path) // We need to place the command execution in the git dir 387 .args(["show", sha1, "--pretty=%GF", "--raw"]) 388 .output()?; 389 390 if !output.status.success() { 391 return Err(io::Error::new( 392 io::ErrorKind::Other, 393 String::from_utf8_lossy(&output.stderr), 394 )); 395 } 396 397 let string = BufReader::new(output.stdout.as_slice()) 398 .lines() 399 .next() 400 .transpose()?; 401 402 // We only return a fingerprint if it's not an empty string 403 if let Some(s) = string { 404 if !s.is_empty() { 405 return Ok(Some(s)); 406 } 407 } 408 409 Ok(None) 410 }