use std::path::Path;
use std::process::Command;
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("`git` not found on PATH: {0}")]
NotFound(String),
#[error("git {operation} failed (exit {exit_code}): {stderr}")]
CommandFailed {
operation: String,
exit_code: i32,
stderr: String,
},
#[error("git {operation} produced unexpected output: {detail}")]
BadOutput { operation: String, detail: String },
}
impl GitError {
pub fn is_transient(&self) -> bool {
match self {
Self::NotFound(_) => false,
Self::CommandFailed { .. } => true,
Self::BadOutput { .. } => true,
}
}
}
pub trait GitRunner: Send + Sync {
fn ls_remote(&self, url: &str, reference: &str) -> std::result::Result<String, GitError>;
fn shallow_clone(
&self,
url: &str,
dest: &Path,
reference: Option<&str>,
subpath: Option<&str>,
) -> std::result::Result<String, GitError>;
fn fetch_and_reset(
&self,
repo: &Path,
reference: &str,
) -> std::result::Result<String, GitError>;
fn checkout(&self, repo: &Path, target: &str) -> std::result::Result<(), GitError>;
fn local_head(&self, repo: &Path) -> std::result::Result<String, GitError>;
fn status_porcelain(&self, repo: &Path) -> std::result::Result<String, GitError>;
}
pub struct ShellGitRunner;
impl ShellGitRunner {
pub fn new() -> Self {
Self
}
fn run(operation: &str, cmd: &mut Command) -> std::result::Result<String, GitError> {
let output = cmd.output().map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => GitError::NotFound(e.to_string()),
_ => GitError::CommandFailed {
operation: operation.to_string(),
exit_code: -1,
stderr: format!("spawn failed: {e}"),
},
})?;
if !output.status.success() {
return Err(GitError::CommandFailed {
operation: operation.to_string(),
exit_code: output.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
}
impl Default for ShellGitRunner {
fn default() -> Self {
Self::new()
}
}
impl GitRunner for ShellGitRunner {
fn ls_remote(&self, url: &str, reference: &str) -> std::result::Result<String, GitError> {
let stdout = Self::run(
"ls-remote",
Command::new("git").args(["ls-remote", "--exit-code", url, reference]),
)?;
let rows: Vec<(&str, &str)> = stdout
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let sha = parts.next()?;
let refname = parts.next().unwrap_or("");
Some((sha, refname))
})
.collect();
if rows.is_empty() {
return Err(GitError::BadOutput {
operation: "ls-remote".into(),
detail: format!("no rows for {reference} on {url}"),
});
}
let chosen = rows
.iter()
.find(|(_, refname)| refname.ends_with("^{}"))
.or_else(|| rows.first())
.expect("rows is non-empty");
let sha = chosen.0.to_string();
if sha.len() < 7 {
return Err(GitError::BadOutput {
operation: "ls-remote".into(),
detail: format!("sha too short: {sha:?}"),
});
}
Ok(sha)
}
fn shallow_clone(
&self,
url: &str,
dest: &Path,
reference: Option<&str>,
subpath: Option<&str>,
) -> std::result::Result<String, GitError> {
let mut cmd = Command::new("git");
cmd.args(["clone", "--depth=1", "--filter=blob:none"]);
if let Some(r) = reference {
cmd.args(["--branch", r, "--single-branch"]);
}
if subpath.is_some() {
cmd.arg("--no-checkout");
}
cmd.arg("--").arg(url).arg(dest);
Self::run("clone", &mut cmd)?;
if let Some(pattern) = subpath {
Self::run(
"sparse-checkout init",
Command::new("git")
.arg("-C")
.arg(dest)
.args(["sparse-checkout", "init", "--cone"]),
)?;
Self::run(
"sparse-checkout set",
Command::new("git").arg("-C").arg(dest).args([
"sparse-checkout",
"set",
"--",
pattern,
]),
)?;
Self::run(
"checkout",
Command::new("git").arg("-C").arg(dest).args(["checkout"]),
)?;
}
self.local_head(dest)
}
fn fetch_and_reset(
&self,
repo: &Path,
reference: &str,
) -> std::result::Result<String, GitError> {
Self::run(
"fetch",
Command::new("git").arg("-C").arg(repo).args([
"fetch",
"--depth=1",
"origin",
reference,
]),
)?;
Self::run(
"reset",
Command::new("git")
.arg("-C")
.arg(repo)
.args(["reset", "--hard", "FETCH_HEAD"]),
)?;
self.local_head(repo)
}
fn checkout(&self, repo: &Path, target: &str) -> std::result::Result<(), GitError> {
Self::run(
"checkout",
Command::new("git")
.arg("-C")
.arg(repo)
.args(["checkout", target]),
)?;
Ok(())
}
fn local_head(&self, repo: &Path) -> std::result::Result<String, GitError> {
Self::run(
"rev-parse",
Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"]),
)
}
fn status_porcelain(&self, repo: &Path) -> std::result::Result<String, GitError> {
Self::run(
"status",
Command::new("git")
.arg("-C")
.arg(repo)
.args(["status", "--porcelain"]),
)
}
}
#[cfg(any(test, feature = "test-utils"))]
pub struct MockGitRunner {
inner: std::sync::Mutex<MockGitInner>,
}
#[cfg(any(test, feature = "test-utils"))]
struct MockGitInner {
pub ls_remote_sha: Option<String>,
pub local_sha: Option<String>,
pub ls_remote_offline: bool,
pub fetch_offline: bool,
pub status_porcelain: String,
pub calls: Vec<String>,
pub clone_marker_content: Vec<u8>,
}
#[cfg(any(test, feature = "test-utils"))]
impl MockGitRunner {
pub fn new(upstream_sha: &str, clone_marker: &[u8]) -> Self {
Self {
inner: std::sync::Mutex::new(MockGitInner {
ls_remote_sha: Some(upstream_sha.into()),
local_sha: None,
ls_remote_offline: false,
fetch_offline: false,
status_porcelain: String::new(),
calls: Vec::new(),
clone_marker_content: clone_marker.to_vec(),
}),
}
}
pub fn set_status_porcelain(&self, output: &str) {
let mut g = self.inner.lock().unwrap();
g.status_porcelain = output.into();
}
pub fn set_upstream_sha(&self, sha: &str) {
let mut g = self.inner.lock().unwrap();
g.ls_remote_sha = Some(sha.into());
}
pub fn set_ls_remote_offline(&self, offline: bool) {
let mut g = self.inner.lock().unwrap();
g.ls_remote_offline = offline;
}
pub fn set_fetch_offline(&self, offline: bool) {
let mut g = self.inner.lock().unwrap();
g.fetch_offline = offline;
}
pub fn calls(&self) -> Vec<String> {
self.inner.lock().unwrap().calls.clone()
}
}
#[cfg(any(test, feature = "test-utils"))]
impl GitRunner for MockGitRunner {
fn ls_remote(&self, url: &str, reference: &str) -> std::result::Result<String, GitError> {
let mut g = self.inner.lock().unwrap();
g.calls.push(format!("ls-remote {url} {reference}"));
if g.ls_remote_offline {
return Err(GitError::CommandFailed {
operation: "ls-remote".into(),
exit_code: 1,
stderr: "simulated offline".into(),
});
}
g.ls_remote_sha.clone().ok_or_else(|| GitError::BadOutput {
operation: "ls-remote".into(),
detail: "mock returned no SHA".into(),
})
}
fn shallow_clone(
&self,
url: &str,
dest: &Path,
reference: Option<&str>,
subpath: Option<&str>,
) -> std::result::Result<String, GitError> {
let mut g = self.inner.lock().unwrap();
g.calls.push(format!(
"clone {url} ref={} subpath={} -> {}",
reference.unwrap_or("HEAD"),
subpath.unwrap_or("-"),
dest.display()
));
std::fs::create_dir_all(dest).map_err(|e| GitError::CommandFailed {
operation: "clone".into(),
exit_code: -1,
stderr: e.to_string(),
})?;
let marker_dir = match subpath {
Some(p) => {
let d = dest.join(p);
std::fs::create_dir_all(&d).map_err(|e| GitError::CommandFailed {
operation: "sparse-checkout".into(),
exit_code: -1,
stderr: e.to_string(),
})?;
d
}
None => dest.to_path_buf(),
};
let marker = marker_dir.join("README.md");
std::fs::write(&marker, &g.clone_marker_content).map_err(|e| GitError::CommandFailed {
operation: "clone".into(),
exit_code: -1,
stderr: e.to_string(),
})?;
let sha = g
.ls_remote_sha
.clone()
.unwrap_or_else(|| "0000000000000000000000000000000000000000".into());
g.local_sha = Some(sha.clone());
Ok(sha)
}
fn fetch_and_reset(
&self,
repo: &Path,
reference: &str,
) -> std::result::Result<String, GitError> {
let mut g = self.inner.lock().unwrap();
g.calls
.push(format!("fetch+reset {} ref={reference}", repo.display()));
if g.fetch_offline {
return Err(GitError::CommandFailed {
operation: "fetch".into(),
exit_code: 1,
stderr: "simulated offline".into(),
});
}
let marker = repo.join("README.md");
let mut buf = g.clone_marker_content.clone();
buf.extend_from_slice(b"\n# refreshed");
let _ = std::fs::write(&marker, &buf);
let sha = g
.ls_remote_sha
.clone()
.unwrap_or_else(|| "1111111111111111111111111111111111111111".into());
g.local_sha = Some(sha.clone());
Ok(sha)
}
fn checkout(&self, repo: &Path, target: &str) -> std::result::Result<(), GitError> {
let mut g = self.inner.lock().unwrap();
g.calls
.push(format!("checkout {} {target}", repo.display()));
g.local_sha = Some(target.into());
Ok(())
}
fn local_head(&self, _repo: &Path) -> std::result::Result<String, GitError> {
let mut g = self.inner.lock().unwrap();
g.calls.push("rev-parse".into());
g.local_sha.clone().ok_or_else(|| GitError::BadOutput {
operation: "rev-parse".into(),
detail: "mock has no local sha (clone wasn't called)".into(),
})
}
fn status_porcelain(&self, repo: &Path) -> std::result::Result<String, GitError> {
let mut g = self.inner.lock().unwrap();
g.calls.push(format!("status {}", repo.display()));
Ok(g.status_porcelain.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transient_classification() {
assert!(GitError::CommandFailed {
operation: "fetch".into(),
exit_code: 128,
stderr: "dns failure".into(),
}
.is_transient());
assert!(GitError::BadOutput {
operation: "ls-remote".into(),
detail: "x".into(),
}
.is_transient());
assert!(!GitError::NotFound("missing".into()).is_transient());
}
#[test]
fn mock_clone_writes_marker_and_records_sha() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("clone");
let mock = MockGitRunner::new("abc123def456", b"# hello\n");
let sha = mock
.shallow_clone("https://example.com/repo.git", &dest, None, None)
.unwrap();
assert_eq!(sha, "abc123def456");
assert!(dest.join("README.md").exists());
assert_eq!(mock.local_head(&dest).unwrap(), "abc123def456");
}
#[test]
fn mock_clone_subpath_places_marker_inside_subpath() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("clone");
let mock = MockGitRunner::new("abc", b"# theme\n");
mock.shallow_clone("https://x/r.git", &dest, None, Some("themes"))
.unwrap();
assert!(dest.join("themes/README.md").exists());
assert!(!dest.join("README.md").exists());
}
#[test]
fn mock_fetch_updates_marker_and_sha() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("clone");
let mock = MockGitRunner::new("aaa", b"# v1\n");
mock.shallow_clone("https://x/r.git", &dest, None, None)
.unwrap();
mock.set_upstream_sha("bbb");
let new_sha = mock.fetch_and_reset(&dest, "HEAD").unwrap();
assert_eq!(new_sha, "bbb");
let body = std::fs::read_to_string(dest.join("README.md")).unwrap();
assert!(body.contains("# refreshed"));
}
#[test]
fn mock_checkout_updates_local_sha() {
let tmp = tempfile::tempdir().unwrap();
let dest = tmp.path().join("clone");
let mock = MockGitRunner::new("abc", b"");
mock.shallow_clone("https://x/r.git", &dest, None, None)
.unwrap();
mock.checkout(&dest, "frozen-commit-sha").unwrap();
assert_eq!(mock.local_head(&dest).unwrap(), "frozen-commit-sha");
}
#[test]
fn mock_offline_ls_remote() {
let mock = MockGitRunner::new("aaa", b"");
mock.set_ls_remote_offline(true);
let err = mock.ls_remote("https://x/r.git", "HEAD").unwrap_err();
assert!(err.is_transient());
}
}