git.rs

  1use anyhow::{Context as _, Result};
  2use collections::HashMap;
  3use futures::lock::{Mutex, OwnedMutexGuard};
  4use std::{
  5    cell::RefCell,
  6    path::{Path, PathBuf},
  7    sync::Arc,
  8};
  9
 10use crate::paths::REPOS_DIR;
 11
 12thread_local! {
 13    static REPO_LOCKS: RefCell<HashMap<PathBuf, Arc<Mutex<()>>>> = RefCell::new(HashMap::default());
 14}
 15
 16#[must_use]
 17pub async fn lock_repo(path: impl AsRef<Path>) -> OwnedMutexGuard<()> {
 18    REPO_LOCKS
 19        .with(|cell| {
 20            cell.borrow_mut()
 21                .entry(path.as_ref().to_path_buf())
 22                .or_default()
 23                .clone()
 24        })
 25        .lock_owned()
 26        .await
 27}
 28
 29pub async fn run_git(repo_path: &Path, args: &[&str]) -> Result<String> {
 30    let output = smol::process::Command::new("git")
 31        .current_dir(repo_path)
 32        .args(args)
 33        .output()
 34        .await?;
 35
 36    anyhow::ensure!(
 37        output.status.success(),
 38        "`git {}` within `{}` failed with status: {}\nstderr:\n{}\nstdout:\n{}",
 39        args.join(" "),
 40        repo_path.display(),
 41        output.status,
 42        String::from_utf8_lossy(&output.stderr),
 43        String::from_utf8_lossy(&output.stdout),
 44    );
 45    Ok(String::from_utf8(output.stdout)?.trim().to_string())
 46}
 47
 48pub fn parse_repo_url(url: &str) -> Result<(String, String)> {
 49    if url.contains('@') {
 50        let (_, path) = url.split_once(':').context("expected : in git url")?;
 51        let (owner, repo) = path.split_once('/').context("expected / in git url")?;
 52        Ok((owner.to_string(), repo.trim_end_matches(".git").to_string()))
 53    } else {
 54        let parsed = http_client::Url::parse(url)?;
 55        let mut segments = parsed.path_segments().context("empty http url")?;
 56        let owner = segments.next().context("expected owner")?;
 57        let repo = segments.next().context("expected repo")?;
 58        Ok((owner.to_string(), repo.trim_end_matches(".git").to_string()))
 59    }
 60}
 61
 62pub fn repo_path_for_url(url: &str) -> Result<PathBuf> {
 63    let (owner, name) = parse_repo_url(url)?;
 64    Ok(REPOS_DIR.join(&owner).join(&name))
 65}
 66
 67pub async fn ensure_repo_cloned(repo_url: &str) -> Result<PathBuf> {
 68    let repo_path = repo_path_for_url(repo_url)?;
 69    let _lock = lock_repo(&repo_path).await;
 70
 71    if !repo_path.is_dir() {
 72        log::info!("Cloning {} into {:?}", repo_url, repo_path);
 73        std::fs::create_dir_all(&repo_path)?;
 74        run_git(&repo_path, &["init"]).await?;
 75        run_git(&repo_path, &["remote", "add", "origin", repo_url]).await?;
 76    }
 77
 78    // Always fetch to get latest commits
 79    run_git(&repo_path, &["fetch", "origin"]).await?;
 80
 81    // Check if we have a valid HEAD, if not checkout FETCH_HEAD
 82    let has_head = run_git(&repo_path, &["rev-parse", "HEAD"]).await.is_ok();
 83    if !has_head {
 84        // Use reset to set HEAD without needing a branch
 85        run_git(&repo_path, &["reset", "--hard", "FETCH_HEAD"]).await?;
 86    }
 87
 88    Ok(repo_path)
 89}
 90
 91pub async fn fetch_if_needed(repo_path: &Path, revision: &str) -> Result<String> {
 92    let resolved = run_git(
 93        repo_path,
 94        &["rev-parse", &format!("{}^{{commit}}", revision)],
 95    )
 96    .await;
 97
 98    if let Ok(sha) = resolved {
 99        return Ok(sha);
100    }
101
102    if run_git(repo_path, &["fetch", "--depth", "1", "origin", revision])
103        .await
104        .is_err()
105    {
106        run_git(repo_path, &["fetch", "origin"]).await?;
107    }
108
109    run_git(repo_path, &["rev-parse", "FETCH_HEAD"]).await
110}