/ radicle / src / git.rs
git.rs
  1  use std::io;
  2  use std::path::Path;
  3  use std::process::Command;
  4  use std::str::FromStr;
  5  
  6  use git_ext::ref_format as format;
  7  use once_cell::sync::Lazy;
  8  
  9  use crate::collections::RandomMap;
 10  use crate::crypto::PublicKey;
 11  use crate::storage;
 12  use crate::storage::refs::Refs;
 13  use crate::storage::RemoteId;
 14  
 15  pub use ext::is_not_found_err;
 16  pub use ext::Error;
 17  pub use ext::NotFound;
 18  pub use ext::Oid;
 19  pub use git2 as raw;
 20  pub use git_ext::ref_format as fmt;
 21  pub use git_ext::ref_format::{
 22      component, lit, name, qualified, refname, refspec,
 23      refspec::{PatternStr, PatternString, Refspec},
 24      Component, Namespaced, Qualified, RefStr, RefString,
 25  };
 26  pub use radicle_git_ext as ext;
 27  pub use storage::git::transport::local::Url;
 28  pub use storage::BranchName;
 29  
 30  /// Default port of the `git` transport protocol.
 31  pub const PROTOCOL_PORT: u16 = 9418;
 32  /// Minimum required git version.
 33  pub const VERSION_REQUIRED: Version = Version {
 34      major: 2,
 35      minor: 31,
 36      patch: 0,
 37  };
 38  
 39  /// A parsed git version.
 40  #[derive(PartialEq, Eq, Debug, PartialOrd, Ord)]
 41  pub struct Version {
 42      pub major: u8,
 43      pub minor: u8,
 44      pub patch: u8,
 45  }
 46  
 47  impl std::fmt::Display for Version {
 48      fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 49          write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
 50      }
 51  }
 52  
 53  #[derive(thiserror::Error, Debug)]
 54  pub enum VersionError {
 55      #[error("malformed git version string")]
 56      Malformed,
 57      #[error("malformed git version string: {0}")]
 58      ParseInt(#[from] std::num::ParseIntError),
 59      #[error("malformed git version string: {0}")]
 60      Utf8(#[from] std::string::FromUtf8Error),
 61      #[error("error retrieving git version: {0}")]
 62      Io(#[from] io::Error),
 63      #[error("error retrieving git version: {0}")]
 64      Other(String),
 65  }
 66  
 67  impl std::str::FromStr for Version {
 68      type Err = VersionError;
 69  
 70      fn from_str(input: &str) -> Result<Self, Self::Err> {
 71          let rest = input
 72              .strip_prefix("git version ")
 73              .ok_or(VersionError::Malformed)?;
 74          let rest = rest.split(' ').next().ok_or(VersionError::Malformed)?;
 75          let rest = rest.trim_end();
 76  
 77          let mut parts = rest.split('.');
 78          let major = parts.next().ok_or(VersionError::Malformed)?.parse()?;
 79          let minor = parts.next().ok_or(VersionError::Malformed)?.parse()?;
 80  
 81          let patch = match parts.next() {
 82              None => 0,
 83              Some(patch) => patch.parse()?,
 84          };
 85  
 86          Ok(Self {
 87              major,
 88              minor,
 89              patch,
 90          })
 91      }
 92  }
 93  
 94  /// Get the system's git version.
 95  pub fn version() -> Result<Version, VersionError> {
 96      let output = Command::new("git").arg("version").output()?;
 97  
 98      if output.status.success() {
 99          let output = String::from_utf8(output.stdout)?;
100          let version = output.parse()?;
101  
102          return Ok(version);
103      }
104      Err(VersionError::Other(
105          String::from_utf8_lossy(&output.stderr).to_string(),
106      ))
107  }
108  
109  #[derive(thiserror::Error, Debug)]
110  pub enum RefError {
111      #[error("ref name is not valid UTF-8")]
112      InvalidName,
113      #[error("unexpected unqualified ref: {0}")]
114      Unqualified(RefString),
115      #[error("invalid ref format: {0}")]
116      Format(#[from] format::Error),
117      #[error("reference has no target")]
118      NoTarget,
119      #[error("expected ref to begin with 'refs/namespaces' but found '{0}'")]
120      MissingNamespace(format::RefString),
121      #[error("ref name contains invalid namespace identifier '{name}'")]
122      InvalidNamespace {
123          name: format::RefString,
124          #[source]
125          err: Box<dyn std::error::Error + Send + Sync + 'static>,
126      },
127      #[error(transparent)]
128      Other(#[from] git2::Error),
129  }
130  
131  #[derive(thiserror::Error, Debug)]
132  pub enum ListRefsError {
133      #[error("git error: {0}")]
134      Git(#[from] git2::Error),
135      #[error("invalid ref: {0}")]
136      InvalidRef(#[from] RefError),
137  }
138  
139  pub mod refs {
140      use super::*;
141      use radicle_cob as cob;
142  
143      /// Try to get a qualified reference from a generic reference.
144      pub fn qualified_from<'a>(r: &'a git2::Reference) -> Result<(Qualified<'a>, Oid), RefError> {
145          let name = r.name().ok_or(RefError::InvalidName)?;
146          let refstr = RefStr::try_from_str(name)?;
147          let target = r.resolve()?.target().ok_or(RefError::NoTarget)?;
148          let qualified = Qualified::from_refstr(refstr)
149              .ok_or_else(|| RefError::Unqualified(refstr.to_owned()))?;
150  
151          Ok((qualified, target.into()))
152      }
153  
154      /// Create a qualified branch reference.
155      ///
156      /// `refs/heads/<branch>`
157      ///
158      pub fn branch<'a>(branch: &RefStr) -> Qualified<'a> {
159          Qualified::from(lit::refs_heads(branch))
160      }
161  
162      pub mod storage {
163          use format::{
164              lit,
165              name::component,
166              refspec::{self, PatternString},
167          };
168  
169          use super::*;
170  
171          /// Where the project's identity document is stored.
172          ///
173          /// `refs/rad/id`
174          ///
175          pub static IDENTITY_BRANCH: Lazy<Qualified> = Lazy::new(|| {
176              Qualified::from_components(name::component!("rad"), name::component!("id"), None)
177          });
178  
179          /// Where the project's signed references are stored.
180          ///
181          /// `refs/rad/sigrefs`
182          ///
183          pub static SIGREFS_BRANCH: Lazy<Qualified> = Lazy::new(|| {
184              Qualified::from_components(name::component!("rad"), name::component!("sigrefs"), None)
185          });
186  
187          /// Create the [`Namespaced`] `branch` under the `remote` namespace, i.e.
188          ///
189          /// `refs/namespaces/<remote>/refs/heads/<branch>`
190          ///
191          pub fn branch_of<'a>(remote: &RemoteId, branch: &RefStr) -> Namespaced<'a> {
192              Qualified::from(lit::refs_heads(branch)).with_namespace(remote.into())
193          }
194  
195          /// Get the branch where the project's identity document is stored.
196          ///
197          /// `refs/namespaces/<remote>/refs/rad/id`
198          ///
199          pub fn id(remote: &RemoteId) -> Namespaced {
200              IDENTITY_BRANCH.with_namespace(remote.into())
201          }
202  
203          /// The collaborative object reference, identified by `typename` and `object_id`, under the given `remote`.
204          ///
205          /// `refs/namespaces/<remote>/refs/cobs/<typename>/<object_id>`
206          ///
207          pub fn cob<'a>(
208              remote: &RemoteId,
209              typename: &cob::TypeName,
210              object_id: &cob::ObjectId,
211          ) -> Namespaced<'a> {
212              Qualified::from_components(
213                  component!("cobs"),
214                  Component::from(typename),
215                  Some(object_id.into()),
216              )
217              .with_namespace(remote.into())
218          }
219  
220          /// All collaborative objects, identified by `typename` and `object_id`, for all remotes.
221          ///
222          /// `refs/namespaces/*/refs/cobs/<typename>/<object_id>`
223          ///
224          pub fn cobs(typename: &cob::TypeName, object_id: &cob::ObjectId) -> PatternString {
225              refspec::pattern!("refs/namespaces/*")
226                  .join(refname!("refs/cobs"))
227                  .join(Component::from(typename))
228                  .join(Component::from(object_id))
229          }
230  
231          /// A patch reference.
232          ///
233          /// `refs/heads/patches/<object_id>`
234          ///
235          pub fn patch<'a>(object_id: &cob::ObjectId) -> Qualified<'a> {
236              Qualified::from_components(
237                  component!("heads"),
238                  component!("patches"),
239                  Some(object_id.into()),
240              )
241          }
242  
243          /// Draft references.
244          ///
245          /// These references are not replicated or signed.
246          pub mod draft {
247              use super::*;
248  
249              /// Review draft reference. Points to the non-COB part of a patch review.
250              ///
251              /// `refs/namespaces/<remote>/refs/drafts/reviews/<patch-id>`
252              ///
253              /// When building a patch review, we store the intermediate state in this ref.
254              pub fn review<'a>(remote: &RemoteId, patch: &cob::ObjectId) -> Namespaced<'a> {
255                  Qualified::from_components(
256                      component!("drafts"),
257                      component!("reviews"),
258                      Some(Component::from(patch)),
259                  )
260                  .with_namespace(remote.into())
261              }
262  
263              /// A draft collaborative object. This can also be a draft operation on an existing
264              /// object.
265              ///
266              /// `refs/namespaces/<remote>/refs/drafts/cobs/<typename>/<object_id>`
267              ///
268              pub fn cob<'a>(
269                  remote: &RemoteId,
270                  typename: &cob::TypeName,
271                  object_id: &cob::ObjectId,
272              ) -> Namespaced<'a> {
273                  Qualified::from_components(
274                      component!("drafts"),
275                      component!("cobs"),
276                      [Component::from(typename), object_id.into()],
277                  )
278                  .with_namespace(remote.into())
279              }
280  
281              /// Draft collaborative objects of a type.
282              ///
283              /// `refs/namespaces/<remote>/refs/drafts/cobs/<typename>/*`
284              ///
285              pub fn cobs(remote: &RemoteId, typename: &cob::TypeName) -> PatternString {
286                  Qualified::from_components(
287                      component!("drafts"),
288                      component!("cobs"),
289                      Some(Component::from(typename)),
290                  )
291                  .with_namespace(remote.into())
292                  .to_pattern(refspec::pattern!("*"))
293              }
294          }
295  
296          /// Staging/temporary references.
297          pub mod staging {
298              use super::*;
299  
300              /// Where patch heads are pushed initially, before patch creation.
301              /// This is a short-lived reference, which is deleted after the patch has been opened.
302              /// The `<oid>` is the commit proposed in the patch.
303              ///
304              /// `refs/namespaces/<remote>/refs/tmp/heads/<oid>`
305              ///
306              pub fn patch<'a>(remote: &RemoteId, oid: impl Into<Oid>) -> Namespaced<'a> {
307                  // SAFETY: OIDs are valid reference names and valid path component.
308                  #[allow(clippy::unwrap_used)]
309                  let oid = RefString::try_from(oid.into().to_string()).unwrap();
310                  #[allow(clippy::unwrap_used)]
311                  let oid = Component::from_refstr(oid).unwrap();
312  
313                  Qualified::from_components(component!("tmp"), component!("heads"), Some(oid))
314                      .with_namespace(remote.into())
315              }
316          }
317      }
318  
319      pub mod workdir {
320          use super::*;
321          use format::name::component;
322  
323          /// Create a [`RefString`] that corresponds to `refs/heads/<branch>`.
324          pub fn branch(branch: &RefStr) -> RefString {
325              refname!("refs/heads").join(branch)
326          }
327  
328          /// Create a [`RefString`] that corresponds to `refs/notes/<name>`.
329          pub fn note(name: &RefStr) -> RefString {
330              refname!("refs/notes").join(name)
331          }
332  
333          /// Create a [`RefString`] that corresponds to `refs/remotes/<remote>/<branch>`.
334          pub fn remote_branch(remote: &RefStr, branch: &RefStr) -> RefString {
335              refname!("refs/remotes").and(remote).and(branch)
336          }
337  
338          /// Create a [`RefString`] that corresponds to `refs/tags/<branch>`.
339          pub fn tag(name: &RefStr) -> RefString {
340              refname!("refs/tags").join(name)
341          }
342  
343          /// A patch head.
344          ///
345          /// `refs/heads/patches/<patch-id>`
346          ///
347          pub fn patch<'a>(patch_id: &cob::ObjectId) -> Qualified<'a> {
348              Qualified::from_components(
349                  component!("heads"),
350                  component!("patches"),
351                  Some(patch_id.into()),
352              )
353          }
354  
355          /// A patch head.
356          ///
357          /// `refs/remotes/rad/patches/<patch-id>`
358          ///
359          pub fn patch_upstream<'a>(patch_id: &cob::ObjectId) -> Qualified<'a> {
360              Qualified::from_components(
361                  component!("remotes"),
362                  crate::rad::REMOTE_COMPONENT.clone(),
363                  [component!("patches"), patch_id.into()],
364              )
365          }
366      }
367  }
368  
369  /// List remote refs of a project, given the remote URL.
370  pub fn remote_refs(url: &Url) -> Result<RandomMap<RemoteId, Refs>, ListRefsError> {
371      let url = url.to_string();
372      let mut remotes = RandomMap::default();
373      let mut remote = git2::Remote::create_detached(url)?;
374  
375      remote.connect(git2::Direction::Fetch)?;
376  
377      let refs = remote.list()?;
378      for r in refs {
379          // Skip the `HEAD` reference, as it is untrusted.
380          if r.name() == "HEAD" {
381              continue;
382          }
383          // Nb. skip refs that don't have a public key namespace.
384          if let (Some(id), refname) = parse_ref::<PublicKey>(r.name())? {
385              let entry = remotes.entry(id).or_insert_with(Refs::default);
386              entry.insert(refname.into(), r.oid().into());
387          }
388      }
389  
390      Ok(remotes)
391  }
392  
393  /// Parse a ref string. Returns an error if it isn't namespaced.
394  pub fn parse_ref_namespaced<T>(s: &str) -> Result<(T, format::Qualified), RefError>
395  where
396      T: FromStr,
397      T::Err: std::error::Error + Send + Sync + 'static,
398  {
399      match parse_ref::<T>(s) {
400          Ok((None, refname)) => Err(RefError::MissingNamespace(refname.to_ref_string())),
401          Ok((Some(t), r)) => Ok((t, r)),
402          Err(err) => Err(err),
403      }
404  }
405  
406  /// Parse a ref string. Optionally returns a namespace.
407  pub fn parse_ref<T>(s: &str) -> Result<(Option<T>, format::Qualified), RefError>
408  where
409      T: FromStr,
410      T::Err: std::error::Error + Send + Sync + 'static,
411  {
412      let input = format::RefStr::try_from_str(s)?;
413      match input.to_namespaced() {
414          None => {
415              let refname = Qualified::from_refstr(input)
416                  .ok_or_else(|| RefError::Unqualified(input.to_owned()))?;
417  
418              Ok((None, refname))
419          }
420          Some(ns) => {
421              let id = ns
422                  .namespace()
423                  .as_str()
424                  .parse()
425                  .map_err(|err| RefError::InvalidNamespace {
426                      name: input.to_owned(),
427                      err: Box::new(err),
428                  })?;
429              let rest = ns.strip_namespace();
430  
431              Ok((Some(id), rest))
432          }
433      }
434  }
435  
436  /// Create an initial empty commit.
437  pub fn initial_commit<'a>(
438      repo: &'a git2::Repository,
439      sig: &git2::Signature,
440  ) -> Result<git2::Commit<'a>, git2::Error> {
441      let tree_id = repo.index()?.write_tree()?;
442      let tree = repo.find_tree(tree_id)?;
443      let oid = repo.commit(None, sig, sig, "Initial commit", &tree, &[])?;
444      let commit = repo.find_commit(oid)?;
445  
446      Ok(commit)
447  }
448  
449  /// Create a commit and update the given ref to it.
450  pub fn commit<'a>(
451      repo: &'a git2::Repository,
452      parent: &'a git2::Commit,
453      target: &RefStr,
454      message: &str,
455      sig: &git2::Signature,
456      tree: &git2::Tree,
457  ) -> Result<git2::Commit<'a>, git2::Error> {
458      let oid = repo.commit(Some(target.as_str()), sig, sig, message, tree, &[parent])?;
459      let commit = repo.find_commit(oid)?;
460  
461      Ok(commit)
462  }
463  
464  /// Get the repository head.
465  pub fn head(repo: &git2::Repository) -> Result<git2::Commit, git2::Error> {
466      let head = repo.head()?.peel_to_commit()?;
467  
468      Ok(head)
469  }
470  
471  /// Write a tree with the given blob at the given path.
472  pub fn write_tree<'r>(
473      path: &Path,
474      bytes: &[u8],
475      repo: &'r git2::Repository,
476  ) -> Result<git2::Tree<'r>, Error> {
477      let blob_id = repo.blob(bytes)?;
478      let mut builder = repo.treebuilder(None)?;
479      builder.insert(path, blob_id, 0o100_644)?;
480  
481      let tree_id = builder.write()?;
482      let tree = repo.find_tree(tree_id)?;
483  
484      Ok(tree)
485  }
486  
487  /// Configure a radicle repository.
488  ///
489  /// * Sets `push.default = upstream`.
490  pub fn configure_repository(repo: &git2::Repository) -> Result<(), git2::Error> {
491      let mut cfg = repo.config()?;
492      cfg.set_str("push.default", "upstream")?;
493  
494      Ok(())
495  }
496  
497  /// Configure a repository's radicle remote.
498  ///
499  /// The entry for this remote will be:
500  /// ```text
501  /// [remote.<name>]
502  ///   url = <fetch>
503  ///   pushurl = <push>
504  ///   fetch +refs/heads/*:refs/remotes/<name>/*
505  /// ```
506  pub fn configure_remote<'r>(
507      repo: &'r git2::Repository,
508      name: &str,
509      fetch: &Url,
510      push: &Url,
511  ) -> Result<git2::Remote<'r>, git2::Error> {
512      let fetchspec = format!("+refs/heads/*:refs/remotes/{name}/*");
513      let remote = repo.remote_with_fetch(name, fetch.to_string().as_str(), &fetchspec)?;
514  
515      if push != fetch {
516          repo.remote_set_pushurl(name, Some(push.to_string().as_str()))?;
517      }
518      Ok(remote)
519  }
520  
521  /// Fetch from the given `remote`.
522  pub fn fetch(repo: &git2::Repository, remote: &str) -> Result<(), git2::Error> {
523      repo.find_remote(remote)?.fetch::<&str>(
524          &[],
525          Some(
526              git2::FetchOptions::new()
527                  .update_fetchhead(false)
528                  .prune(git2::FetchPrune::On)
529                  .download_tags(git2::AutotagOption::None),
530          ),
531          None,
532      )
533  }
534  
535  /// Push `refspecs` to the given `remote` using the provided `namespace`.
536  pub fn push<'a>(
537      repo: &git2::Repository,
538      remote: &str,
539      refspecs: impl IntoIterator<Item = (&'a Qualified<'a>, &'a Qualified<'a>)>,
540  ) -> Result<(), git2::Error> {
541      let refspecs = refspecs
542          .into_iter()
543          .map(|(src, dst)| format!("{}:{}", src.as_str(), dst.as_str()));
544  
545      repo.find_remote(remote)?
546          .push(refspecs.collect::<Vec<_>>().as_slice(), None)?;
547  
548      Ok(())
549  }
550  
551  /// Set the upstream of the given branch to the given remote.
552  ///
553  /// This writes to the `config` directly. The entry will look like the
554  /// following:
555  ///
556  /// ```text
557  /// [branch "main"]
558  ///     remote = rad
559  ///     merge = refs/heads/main
560  /// ```
561  pub fn set_upstream(
562      repo: &git2::Repository,
563      remote: impl AsRef<str>,
564      branch: impl AsRef<str>,
565      merge: impl AsRef<str>,
566  ) -> Result<(), git2::Error> {
567      let remote = remote.as_ref();
568      let branch = branch.as_ref();
569      let merge = merge.as_ref();
570  
571      let mut config = repo.config()?;
572      let branch_remote = format!("branch.{branch}.remote");
573      let branch_merge = format!("branch.{branch}.merge");
574  
575      config.remove_multivar(&branch_remote, ".*").or_else(|e| {
576          if ext::is_not_found_err(&e) {
577              Ok(())
578          } else {
579              Err(e)
580          }
581      })?;
582      config.remove_multivar(&branch_merge, ".*").or_else(|e| {
583          if ext::is_not_found_err(&e) {
584              Ok(())
585          } else {
586              Err(e)
587          }
588      })?;
589      config.set_multivar(&branch_remote, ".*", remote)?;
590      config.set_multivar(&branch_merge, ".*", merge)?;
591  
592      Ok(())
593  }
594  
595  /// Execute a git command by spawning a child process.
596  pub fn run<P, S, K, V>(
597      repo: P,
598      args: impl IntoIterator<Item = S>,
599      envs: impl IntoIterator<Item = (K, V)>,
600  ) -> Result<String, io::Error>
601  where
602      P: AsRef<Path>,
603      S: AsRef<std::ffi::OsStr>,
604      K: AsRef<std::ffi::OsStr>,
605      V: AsRef<std::ffi::OsStr>,
606  {
607      let output = Command::new("git")
608          .current_dir(repo)
609          .envs(envs)
610          .args(args)
611          .output()?;
612  
613      if output.status.success() {
614          let out = if output.stdout.is_empty() {
615              &output.stderr
616          } else {
617              &output.stdout
618          };
619          return Ok(String::from_utf8_lossy(out).into());
620      }
621  
622      Err(io::Error::new(
623          io::ErrorKind::Other,
624          String::from_utf8_lossy(&output.stderr),
625      ))
626  }
627  
628  /// Git URLs.
629  pub mod url {
630      use std::path::PathBuf;
631  
632      /// A Git URL using the `file://` scheme.
633      pub struct File {
634          pub path: PathBuf,
635      }
636  
637      impl File {
638          /// Create a new file URL pointing to the given path.
639          pub fn new(path: impl Into<PathBuf>) -> Self {
640              Self { path: path.into() }
641          }
642      }
643  
644      impl ToString for File {
645          fn to_string(&self) -> String {
646              format!("file://{}", self.path.display())
647          }
648      }
649  }
650  
651  /// Git environment variables.
652  pub mod env {
653      /// Set of environment vars to reset git's configuration to default.
654      pub const GIT_DEFAULT_CONFIG: [(&str, &str); 2] = [
655          ("GIT_CONFIG_GLOBAL", "/dev/null"),
656          ("GIT_CONFIG_NOSYSTEM", "1"),
657      ];
658  }
659  
660  #[cfg(test)]
661  mod test {
662      use super::*;
663      use std::str::FromStr;
664  
665      #[test]
666      fn test_version_ord() {
667          assert!(
668              Version {
669                  major: 2,
670                  minor: 34,
671                  patch: 1
672              } > Version {
673                  major: 2,
674                  minor: 34,
675                  patch: 0
676              }
677          );
678          assert!(
679              Version {
680                  major: 2,
681                  minor: 24,
682                  patch: 12
683              } < Version {
684                  major: 2,
685                  minor: 34,
686                  patch: 0
687              }
688          );
689      }
690  
691      #[test]
692      fn test_version_from_str() {
693          assert_eq!(
694              Version::from_str("git version 2.34.1\n").ok(),
695              Some(Version {
696                  major: 2,
697                  minor: 34,
698                  patch: 1
699              })
700          );
701  
702          assert_eq!(
703              Version::from_str("git version 2.34.1 (macOS)").ok(),
704              Some(Version {
705                  major: 2,
706                  minor: 34,
707                  patch: 1
708              })
709          );
710  
711          assert_eq!(
712              Version::from_str("git version 2.34").ok(),
713              Some(Version {
714                  major: 2,
715                  minor: 34,
716                  patch: 0
717              })
718          );
719  
720          assert!(Version::from_str("2.34").is_err());
721      }
722  }