/ radicle-cli / src / git.rs
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  }