use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail};
use bytes::Bytes;
use tokio::io::AsyncWriteExt;
use tokio::process::{ChildStdout, Command};
use tokio::sync::Mutex;
use tokio_util::io::ReaderStream;
use crate::metrics::Metrics;
use crate::repo::RepoRef;
#[derive(Debug, Clone, Copy)]
pub enum CacheOutcome {
Cloned,
Fetched,
Cached,
}
#[derive(Clone)]
pub struct GitConfig {
pub git_binary: String,
pub upstream_auth_header: Option<String>,
pub fetch_ttl: Duration,
}
struct RepoSlot {
fetch_lock: Mutex<Option<Instant>>,
}
pub struct GitCache {
cfg: GitConfig,
metrics: Arc<Metrics>,
slots: Mutex<HashMap<String, Arc<RepoSlot>>>,
index: Option<Arc<crate::evict::CacheIndex>>,
}
impl GitCache {
pub fn new(
cfg: GitConfig,
metrics: Arc<Metrics>,
index: Option<Arc<crate::evict::CacheIndex>>,
) -> Self {
Self {
cfg,
metrics,
slots: Mutex::new(HashMap::new()),
index,
}
}
async fn slot(&self, name: &str) -> Arc<RepoSlot> {
self.slots
.lock()
.await
.entry(name.to_string())
.or_insert_with(|| {
Arc::new(RepoSlot {
fetch_lock: Mutex::new(None),
})
})
.clone()
}
fn mark_changed(&self, repo: &RepoRef) {
if let Some(idx) = &self.index {
idx.mark_changed(&repo.name);
}
}
pub async fn evict(&self, name: &str, cache_dir: &Path) -> Result<()> {
let slot = self.slot(name).await;
let guard = slot.fetch_lock.lock().await;
if !cache_dir.join("HEAD").exists() {
return Ok(()); }
let mut trash = cache_dir.as_os_str().to_owned();
trash.push(crate::repo::EVICTING_SUFFIX);
let trash = std::path::PathBuf::from(trash);
let _ = tokio::fs::remove_dir_all(&trash).await;
tokio::fs::rename(cache_dir, &trash)
.await
.with_context(|| format!("rename mirror for eviction: {name}"))?;
drop(guard); let _ = tokio::fs::remove_dir_all(&trash).await;
Ok(())
}
pub async fn ensure_fresh(&self, repo: &RepoRef, want_fetch: bool) -> Result<CacheOutcome> {
let slot = self.slot(&repo.name).await;
if let Some(idx) = &self.index {
idx.touch(&repo.name);
}
let mut last = slot.fetch_lock.lock().await;
if !repo.cache_dir.join("HEAD").exists() {
self.clone_mirror(repo).await?;
*last = Some(Instant::now());
return Ok(CacheOutcome::Cloned);
}
if want_fetch {
let stale = match *last {
None => true,
Some(t) => self.cfg.fetch_ttl.is_zero() || t.elapsed() >= self.cfg.fetch_ttl,
};
if stale {
self.fetch(repo).await?;
*last = Some(Instant::now());
return Ok(CacheOutcome::Fetched);
}
}
Ok(CacheOutcome::Cached)
}
pub async fn advertise_refs(
&self,
repo: &RepoRef,
git_protocol: Option<&str>,
) -> Result<Bytes> {
let mut cmd = self.local_cmd(git_protocol);
cmd.arg("upload-pack")
.arg("--stateless-rpc")
.arg("--advertise-refs")
.arg(&repo.cache_dir);
let out = cmd
.output()
.await
.context("spawn git upload-pack --advertise-refs")?;
if !out.status.success() {
bail!(
"advertise-refs failed for {}: {}",
repo.name,
String::from_utf8_lossy(&out.stderr)
);
}
let mut body = pkt_line("# service=git-upload-pack\n");
body.extend_from_slice(b"0000"); body.extend_from_slice(&out.stdout);
Ok(Bytes::from(body))
}
pub async fn upload_pack_rpc(
&self,
repo: &RepoRef,
git_protocol: Option<&str>,
body: Bytes,
) -> Result<ReaderStream<ChildStdout>> {
let mut cmd = self.local_cmd(git_protocol);
cmd.arg("upload-pack")
.arg("--stateless-rpc")
.arg(&repo.cache_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
let mut child = cmd.spawn().context("spawn git upload-pack")?;
let mut stdin = child.stdin.take().context("upload-pack: no stdin")?;
let stdout = child.stdout.take().context("upload-pack: no stdout")?;
stdin
.write_all(&body)
.await
.context("write upload-pack request")?;
drop(stdin);
let name = repo.name.clone();
tokio::spawn(async move {
match child.wait().await {
Ok(s) if !s.success() => {
tracing::warn!(repo = %name, "git upload-pack exited: {s}")
}
Err(e) => tracing::warn!(repo = %name, "git upload-pack wait failed: {e}"),
_ => {}
}
});
Ok(ReaderStream::new(stdout))
}
async fn clone_mirror(&self, repo: &RepoRef) -> Result<()> {
if let Some(parent) = repo.cache_dir.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("create cache parent for {}", repo.name))?;
}
let mut tmp = repo.cache_dir.clone().into_os_string();
tmp.push(crate::repo::INCOMING_SUFFIX);
let tmp = std::path::PathBuf::from(tmp);
let _ = tokio::fs::remove_dir_all(&tmp).await;
tracing::info!(repo = %repo.name, "cloning mirror from upstream");
let status = self
.fetch_cmd()
.arg("clone")
.arg("--mirror")
.arg("--quiet")
.arg(&repo.upstream_url)
.arg(&tmp)
.status()
.await
.context("spawn git clone --mirror")?;
if !status.success() {
let _ = tokio::fs::remove_dir_all(&tmp).await;
self.metrics.record_upstream("clone", "error", "-");
bail!("git clone --mirror failed for {}", repo.name);
}
tokio::fs::rename(&tmp, &repo.cache_dir)
.await
.context("rename mirror into place")?;
self.metrics.record_upstream("clone", "ok", &repo.name);
self.mark_changed(repo);
Ok(())
}
async fn fetch(&self, repo: &RepoRef) -> Result<()> {
tracing::debug!(repo = %repo.name, "fetching updates from upstream");
let status = self
.fetch_cmd()
.current_dir(&repo.cache_dir)
.arg("fetch")
.arg("--prune")
.arg("--quiet")
.arg("origin")
.status()
.await
.context("spawn git fetch")?;
if !status.success() {
self.metrics.record_upstream("fetch", "error", "-");
bail!("git fetch failed for {}", repo.name);
}
self.metrics.record_upstream("fetch", "ok", &repo.name);
self.mark_changed(repo);
Ok(())
}
fn fetch_cmd(&self) -> Command {
let mut c = Command::new(&self.cfg.git_binary);
c.env("GIT_TERMINAL_PROMPT", "0"); if let Some(h) = &self.cfg.upstream_auth_header {
c.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
.env("GIT_CONFIG_VALUE_0", h);
}
c
}
fn local_cmd(&self, git_protocol: Option<&str>) -> Command {
let mut c = Command::new(&self.cfg.git_binary);
if let Some(p) = git_protocol {
c.env("GIT_PROTOCOL", p);
}
c
}
}
fn pkt_line(s: &str) -> Vec<u8> {
let mut v = format!("{:04x}", s.len() + 4).into_bytes();
v.extend_from_slice(s.as_bytes());
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pkt_line_encodes_length() {
assert_eq!(pkt_line("a"), b"0005a");
assert_eq!(&pkt_line("# service=git-upload-pack\n")[..4], b"001e");
}
}