use std::path::{Path, PathBuf};
use std::process::Command;
use super::FloorError;
fn git_bin() -> String {
std::env::var("GIT_BIN").unwrap_or_else(|_| "git".to_string())
}
fn git_at(repo: &Path) -> Command {
let mut cmd = Command::new(git_bin());
cmd.arg("-C")
.arg(repo)
.args(["-c", "core.quotePath=false"])
.env("LC_ALL", "C");
cmd
}
pub fn resolve_commit(repo: &Path, r#ref: &str) -> Result<String, FloorError> {
let rev = format!("{ref}^{{commit}}", r#ref = r#ref);
let out = git_at(repo)
.args(["rev-parse", "--verify", "--quiet", &rev])
.output()
.map_err(|e| FloorError::Git {
message: format!("could not run git rev-parse in {}: {e}", repo.display()),
})?;
if !out.status.success() {
return Err(FloorError::Git {
message: format!("ref {ref:?} does not resolve to a commit", r#ref = r#ref),
});
}
let oid = String::from_utf8_lossy(&out.stdout).trim().to_string();
if oid.is_empty() {
return Err(FloorError::Git {
message: format!(
"git rev-parse returned an empty oid for ref {ref:?}",
r#ref = r#ref
),
});
}
Ok(oid)
}
pub fn changed_files(repo: &Path, base: &str, tip: &str) -> Result<Vec<PathBuf>, FloorError> {
let out = git_at(repo)
.args(["diff", "--name-only", "-z", &format!("{base}..{tip}")])
.output()
.map_err(|e| FloorError::Git {
message: format!("could not run git diff in {}: {e}", repo.display()),
})?;
if !out.status.success() {
return Err(FloorError::Git {
message: format!(
"git diff {base}..{tip} failed in {}: {}",
repo.display(),
String::from_utf8_lossy(&out.stderr).trim()
),
});
}
Ok(String::from_utf8_lossy(&out.stdout)
.split('\0')
.filter(|l| !l.is_empty())
.map(PathBuf::from)
.collect())
}
pub fn file_at_ref(repo: &Path, r#ref: &str, path: &Path) -> Result<Option<String>, FloorError> {
let commit = {
let rev = format!("{ref}^{{commit}}", r#ref = r#ref);
let out = git_at(repo)
.args(["rev-parse", "--verify", "--quiet", &rev])
.output()
.map_err(|e| FloorError::Git {
message: format!("could not run git rev-parse in {}: {e}", repo.display()),
})?;
if !out.status.success() {
let ref_label = r#ref;
return Err(FloorError::Git {
message: format!("ref {ref_label:?} does not resolve to a commit"),
});
}
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let spec = format!("{commit}:{}", path.display());
let exists = git_at(repo)
.args(["cat-file", "-e", &spec])
.output()
.map_err(|e| FloorError::Git {
message: format!("could not run git cat-file in {}: {e}", repo.display()),
})?
.status
.success();
if !exists {
return Ok(None);
}
let out = git_at(repo)
.args(["show", &spec])
.output()
.map_err(|e| FloorError::Git {
message: format!("could not run git show in {}: {e}", repo.display()),
})?;
if out.status.success() {
Ok(Some(String::from_utf8_lossy(&out.stdout).into_owned()))
} else {
Err(FloorError::Git {
message: format!(
"git show {spec} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn git_in(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args([
"-c",
"user.email=t@t",
"-c",
"user.name=t",
"-c",
"commit.gpgsign=false",
])
.args(args)
.output()
.expect("git runs")
.status
.success();
assert!(ok, "git {args:?} failed");
}
fn init_repo() -> TempDir {
let dir = TempDir::new().unwrap();
git_in(dir.path(), &["init", "-q", "-b", "main"]);
dir
}
#[test]
fn changed_files_lists_diff_between_commits() {
let repo = init_repo();
let p = repo.path();
fs::write(p.join("a.rs"), "fn a() {}\n").unwrap();
git_in(p, &["add", "."]);
git_in(p, &["commit", "-qm", "base"]);
let base = String::from_utf8_lossy(
&Command::new("git")
.arg("-C")
.arg(p)
.args(["rev-parse", "HEAD"])
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
fs::write(p.join("a.rs"), "fn a() { let _ = 1; }\n").unwrap();
fs::write(p.join("b.rs"), "fn b() {}\n").unwrap();
git_in(p, &["add", "."]);
git_in(p, &["commit", "-qm", "tip"]);
let mut changed = changed_files(p, &base, "HEAD").unwrap();
changed.sort();
assert_eq!(changed, vec![PathBuf::from("a.rs"), PathBuf::from("b.rs")]);
}
#[test]
fn file_at_ref_reads_old_content_and_reports_absent() {
let repo = init_repo();
let p = repo.path();
fs::write(p.join("a.rs"), "assert!(x);\n").unwrap();
git_in(p, &["add", "."]);
git_in(p, &["commit", "-qm", "base"]);
let content = file_at_ref(p, "HEAD", &PathBuf::from("a.rs")).unwrap();
assert_eq!(content.as_deref(), Some("assert!(x);\n"));
let absent = file_at_ref(p, "HEAD", &PathBuf::from("nope.rs")).unwrap();
assert!(absent.is_none());
}
#[test]
fn resolve_commit_pins_ref_to_oid_and_rejects_bad_ref() {
let repo = init_repo();
let p = repo.path();
fs::write(p.join("a.rs"), "x\n").unwrap();
git_in(p, &["add", "."]);
git_in(p, &["commit", "-qm", "base"]);
let head = String::from_utf8_lossy(
&Command::new("git")
.arg("-C")
.arg(p)
.args(["rev-parse", "HEAD"])
.output()
.unwrap()
.stdout,
)
.trim()
.to_string();
let oid = resolve_commit(p, "HEAD").unwrap();
assert_eq!(oid, head);
assert_eq!(oid.len(), 40, "a full oid");
assert!(resolve_commit(p, "no-such-ref").is_err());
}
#[test]
fn bad_ref_is_an_error() {
let repo = init_repo();
let p = repo.path();
fs::write(p.join("a.rs"), "x\n").unwrap();
git_in(p, &["add", "."]);
git_in(p, &["commit", "-qm", "base"]);
assert!(changed_files(p, "deadbeef", "HEAD").is_err());
}
}