use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::sync::OwnedMutexGuard;
use crate::error::ForgeError;
pub(crate) const GIT_TIMEOUT: Duration = Duration::from_secs(60);
pub(crate) const MAX_OUT: usize = 64 * 1024 * 1024;
const STDERR_KEEP: usize = 16 * 1024;
const SCRUBBED_ENV: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_NAMESPACE",
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_AUTHOR_DATE",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
"GIT_COMMITTER_DATE",
"HOME",
"XDG_CONFIG_HOME",
];
#[derive(Debug)]
pub(crate) struct Out {
pub code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: String,
}
impl Out {
pub fn ok(&self) -> bool {
self.code == Some(0)
}
pub fn text(&self) -> String {
String::from_utf8_lossy(&self.stdout).into_owned()
}
}
async fn read_capped<R: AsyncRead + Unpin>(
mut r: R,
cap: usize,
) -> std::io::Result<Option<Vec<u8>>> {
let mut buf = Vec::new();
let mut chunk = vec![0u8; 64 * 1024];
loop {
let n = r.read(&mut chunk).await?;
if n == 0 {
return Ok(Some(buf));
}
if buf.len() + n > cap {
return Ok(None);
}
buf.extend_from_slice(&chunk[..n]);
}
}
async fn read_keep<R: AsyncRead + Unpin>(mut r: R) -> Vec<u8> {
let mut keep = Vec::new();
let mut chunk = vec![0u8; 8 * 1024];
while let Ok(n) = r.read(&mut chunk).await {
if n == 0 {
break;
}
if keep.len() < STDERR_KEEP {
let room = STDERR_KEEP - keep.len();
keep.extend_from_slice(&chunk[..n.min(room)]);
}
}
keep
}
pub(crate) async fn run_at(
cwd: &Path,
git_dir: Option<&Path>,
args: &[&str],
envs: &[(&str, &str)],
) -> Result<Out, ForgeError> {
let mut cmd = solid_pod_rs_git::hardened_git_async();
for k in SCRUBBED_ENV {
cmd.env_remove(k);
}
cmd.env("LC_ALL", "C")
.args(args)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(gd) = git_dir {
cmd.env("GIT_DIR", gd);
}
for (k, v) in envs {
cmd.env(k, v);
}
let mut child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ForgeError::Unsupported("git binary not found in PATH".into())
} else {
ForgeError::Io(e)
}
})?;
let stdout = child
.stdout
.take()
.ok_or_else(|| ForgeError::Backend("git stdout unavailable".into()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| ForgeError::Backend("git stderr unavailable".into()))?;
let sub = args.first().copied().unwrap_or("");
let work = async {
let (out, err) = tokio::join!(read_capped(stdout, MAX_OUT), read_keep(stderr));
match out? {
None => {
let _ = child.kill().await;
Err(ForgeError::Backend(format!("git {sub}: output too large")))
}
Some(stdout) => {
let status = child.wait().await?;
Ok(Out {
code: status.code(),
stdout,
stderr: String::from_utf8_lossy(&err).into_owned(),
})
}
}
};
match tokio::time::timeout(GIT_TIMEOUT, work).await {
Ok(r) => r,
Err(_) => Err(ForgeError::Backend(format!("git {sub}: timed out"))),
}
}
pub(crate) async fn run(dir: &Path, args: &[&str]) -> Result<Out, ForgeError> {
run_at(dir, Some(dir), args, &[]).await
}
pub(crate) async fn text(dir: &Path, args: &[&str]) -> Result<String, ForgeError> {
let out = run(dir, args).await?;
if out.ok() {
Ok(out.text())
} else {
Err(ForgeError::Backend(format!(
"git {}: {}",
args.first().copied().unwrap_or(""),
out.stderr.trim()
)))
}
}
pub(crate) async fn rev_parse(dir: &Path, spec: &str) -> Option<String> {
let out = run(dir, &["rev-parse", "--verify", "--quiet", spec])
.await
.ok()?;
if !out.ok() {
return None;
}
let t = out.text().trim().to_string();
(!t.is_empty()).then_some(t)
}
pub(crate) async fn is_ancestor(dir: &Path, a: &str, b: &str) -> bool {
run(dir, &["merge-base", "--is-ancestor", a, b])
.await
.map(|o| o.ok())
.unwrap_or(false)
}
#[derive(Default)]
pub(crate) struct KeyedLocks {
map: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
}
impl KeyedLocks {
pub async fn lock_within(
&self,
key: &str,
wait: Duration,
) -> Result<OwnedMutexGuard<()>, ForgeError> {
tokio::time::timeout(wait, self.lock(key))
.await
.map_err(|_| ForgeError::Busy(format!("{key} is busy, retry shortly")))
}
pub async fn lock(&self, key: &str) -> OwnedMutexGuard<()> {
let m = {
let mut map = self
.map
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if map.len() > 1024 {
map.retain(|_, v| Arc::strong_count(v) > 1);
}
map.entry(key.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
};
m.lock_owned().await
}
}
pub(crate) fn pull_locks() -> &'static KeyedLocks {
static L: OnceLock<KeyedLocks> = OnceLock::new();
L.get_or_init(KeyedLocks::default)
}
pub(crate) fn fetch_locks() -> &'static KeyedLocks {
static L: OnceLock<KeyedLocks> = OnceLock::new();
L.get_or_init(KeyedLocks::default)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn lock_within_times_out_as_busy() {
let locks = KeyedLocks::default();
let held = locks.lock("a/b").await;
let err = locks
.lock_within("a/b", Duration::from_millis(50))
.await
.unwrap_err();
assert_eq!(err.status_code(), 503);
drop(held);
assert!(locks
.lock_within("a/b", Duration::from_millis(50))
.await
.is_ok());
}
fn git_available() -> bool {
std::process::Command::new("git")
.arg("--version")
.output()
.is_ok()
}
#[tokio::test]
async fn run_reports_exit_code_without_erroring() {
if !git_available() {
return;
}
let td = tempfile::TempDir::new().unwrap();
let out = run_at(
td.path(),
None,
&["rev-parse", "--verify", "--quiet", "nope"],
&[],
)
.await
.unwrap();
assert!(!out.ok());
assert!(out.code.is_some());
}
#[tokio::test]
async fn keyed_locks_serialise_same_key() {
let locks = Arc::new(KeyedLocks::default());
let a = locks.lock("k").await;
let l2 = locks.clone();
let t = tokio::spawn(async move {
let _g = l2.lock("k").await;
});
tokio::time::sleep(Duration::from_millis(30)).await;
assert!(!t.is_finished(), "second holder must wait");
let _b = locks.lock("other").await;
drop(a);
t.await.unwrap();
}
}