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 }