//! Blocking local git operations against GRASP servers. //! //! All functions may block; call them inside `cx.background_spawn`. use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use anyhow::{Context, Result, bail}; use git2::Repository; use signed_core::RepoAddr; /// On-disk cache of cloned repositories, keyed by owner pubkey / repo id. #[derive(Debug, Clone)] pub struct GitCache { root: PathBuf, } impl GitCache { pub fn new(root: PathBuf) -> Self { Self { root } } /// Local path of the clone for a repository. pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root .join(addr.owner.to_hex()) .join(sanitize_path_component(&addr.id)) } /// Open an existing clone. pub fn open(&self, addr: &RepoAddr) -> Result> { let path = self.repo_path(addr); match Repository::open(&path) { Ok(repo) => Ok(Some(repo)), Err(e) if e.code() == git2::ErrorCode::NotFound => Ok(None), Err(e) => Err(e.into()), } } /// Open the local clone if it exists (fetching first), otherwise clone /// from the first working URL in `clone_urls` (the announcement's `clone` tag). pub fn ensure_clone(&self, addr: &RepoAddr, clone_urls: &[String]) -> Result { let path = self.repo_path(addr); if let Some(repo) = self.open(addr)? { fetch_all(&repo).ok(); return Ok(repo); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } let mut last_err: Option = None; for url in clone_urls { match Repository::clone(url, &path) { Ok(repo) => return Ok(repo), Err(e) => last_err = Some(e), } } match last_err { Some(e) => Err(e).context("failed to clone from any mirror"), None => bail!("no clone URLs provided"), } } } /// Fetch all heads from `origin`. pub fn fetch_all(repo: &Repository) -> Result<()> { let mut remote = repo.find_remote("origin")?; remote.fetch( &["+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"], None, None, )?; Ok(()) } /// Apply a `git format-patch` patch (or series) with `git am`. /// /// Uses the git CLI because it handles the mbox format natively; can be /// replaced with a pure-Rust implementation later without changing callers. pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> { let mut child = Command::new("git") .arg("am") .current_dir(repo_path) .stdin(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("failed to spawn `git am`")?; child .stdin .as_mut() .expect("stdin piped") .write_all(patch.as_bytes())?; let output = child.wait_with_output()?; if !output.status.success() { bail!("git am failed: {}", String::from_utf8_lossy(&output.stderr)); } Ok(()) } fn sanitize_path_component(id: &str) -> String { id.chars() .map(|c| { if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '_' } }) .collect() }