use anyhow::{bail, Context, Result};
use std::path::Path;
use std::process::Command;
fn git(args: &[&str], cwd: Option<&Path>) -> Result<String> {
let mut cmd = Command::new("git");
cmd.args(["-c", "core.longpaths=true"]);
cmd.args(args);
cmd.env("GIT_TERMINAL_PROMPT", "0");
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let out = cmd
.output()
.with_context(|| format!("failed to spawn `git {}`", args.join(" ")))?;
if !out.status.success() {
bail!(
"git {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn ls_remote(url: &str, refspecs: &[&str]) -> Result<String> {
let mut args = vec!["ls-remote", url];
args.extend_from_slice(refspecs);
let out = git(&args, None)?;
if out.is_empty() {
bail!("ref `{}` not found in {url}", refspecs.join(" "));
}
let mut fallback: Option<String> = None;
for line in out.lines() {
let (sha, name) = line.split_once('\t').unwrap_or((line, ""));
if name.ends_with("^{}") {
return Ok(sha.to_string());
}
fallback.get_or_insert_with(|| sha.to_string());
}
fallback.context("could not parse ls-remote output")
}
pub fn is_at_commit(dir: &Path, sha: &str) -> bool {
if !dir.join(".git").exists() {
return false;
}
matches!(git(&["rev-parse", "HEAD"], Some(dir)), Ok(head) if head == sha)
}
pub fn fetch_commit(url: &str, sha: &str, dest: &Path) -> Result<()> {
std::fs::create_dir_all(dest)?;
git(&["init", "-q"], Some(dest))?;
let _ = git(&["remote", "add", "origin", url], Some(dest));
if git(&["fetch", "--depth", "1", "origin", sha], Some(dest)).is_err() {
git(&["fetch", "origin"], Some(dest)).with_context(|| format!("fetching {url}"))?;
}
git(&["checkout", "--detach", sha], Some(dest))
.with_context(|| format!("checking out {sha} in {}", dest.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::process::Command as StdCommand;
fn make_repo(root: &Path) -> String {
std::fs::create_dir_all(root).unwrap();
let run = |args: &[&str]| {
assert!(StdCommand::new("git")
.args([
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(root)
.status()
.unwrap()
.success());
};
run(&["init", "-q", "-b", "main"]);
std::fs::write(root.join("f.txt"), "hi").unwrap();
run(&["add", "-A"]);
run(&["commit", "-qm", "initial"]);
let out = StdCommand::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn scratch(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"spm-git-test-{name}-{}-{nanos}",
std::process::id(),
))
}
#[test]
fn ls_remote_surfaces_git_failure_for_bad_url() {
let dir = scratch("bad-url");
std::fs::create_dir_all(&dir).unwrap();
let bogus = format!("file://{}/does-not-exist", dir.display());
let err = ls_remote(&bogus, &["refs/heads/main"]).unwrap_err();
assert!(
format!("{err:#}").contains("git ls-remote") || format!("{err:#}").contains("failed"),
"{err:#}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ls_remote_reports_missing_ref() {
let dir = scratch("missing-ref");
make_repo(&dir);
let url = format!("file://{}", dir.display());
let err = ls_remote(&url, &["refs/heads/does-not-exist"]).unwrap_err();
assert!(format!("{err}").contains("not found"), "{err}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn is_at_commit_false_cases() {
let dir = scratch("is-at-commit");
std::fs::create_dir_all(&dir).unwrap();
assert!(!is_at_commit(&dir, "deadbeef"), "no .git at all");
let sha = make_repo(&dir);
assert!(is_at_commit(&dir, &sha), "checkout is at HEAD");
assert!(
!is_at_commit(&dir, "0000000000000000000000000000000000000000"),
"different sha must not match"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn fetch_commit_falls_back_to_full_fetch_then_reports_checkout_failure() {
let src = scratch("fetch-fallback-src");
make_repo(&src);
let url = format!("file://{}", src.display());
let dest = scratch("fetch-fallback-dest");
let fake_sha = "a".repeat(40);
let err = fetch_commit(&url, &fake_sha, &dest).unwrap_err();
assert!(
format!("{err:#}").contains("checking out"),
"expected the failure to surface from the checkout step (proving the \
depth-1 fetch failed and the full-fetch fallback ran first): {err:#}"
);
std::fs::remove_dir_all(&src).unwrap();
std::fs::remove_dir_all(&dest).unwrap();
}
}