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}