use std::path::Path;
use std::process::Command;
pub fn current_head(cwd: &Path) -> Option<String> {
current_head_with(&["git"], cwd)
}
pub fn last_commit_for_path(cwd: &Path, path: &Path) -> Option<String> {
last_commit_for_path_with(&["git"], cwd, path)
}
pub fn resolve_closing_commit(cwd: &Path, issue_path: &Path) -> Option<String> {
last_commit_for_path(cwd, issue_path).or_else(|| current_head(cwd))
}
fn current_head_with(program: &[&str], cwd: &Path) -> Option<String> {
non_empty(&run_git(program, cwd, &["rev-parse", "HEAD"])?)
}
fn last_commit_for_path_with(program: &[&str], cwd: &Path, path: &Path) -> Option<String> {
let path = path.to_string_lossy();
non_empty(&run_git(
program,
cwd,
&["log", "-1", "--format=%H", "--", &path],
)?)
}
fn non_empty(stdout: &str) -> Option<String> {
let value = stdout.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn run_git(program: &[&str], cwd: &Path, args: &[&str]) -> Option<String> {
let (command, prefix) = program.split_first()?;
let output = Command::new(command)
.args(prefix)
.args(args)
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
fn fake_git(dir: &TempDir, script: &str) -> Vec<String> {
let path = dir.path().join("fake-git");
std::fs::write(&path, script).unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
vec!["sh".to_string(), path.display().to_string()]
}
fn script(head: Option<&str>, log: Option<&str>) -> String {
let head_arm = match head {
Some(h) => format!(" \"rev-parse\") printf '%s\\n' '{h}';;\n"),
None => " \"rev-parse\") exit 1;;\n".to_string(),
};
let log_arm = match log {
Some(h) => format!(" \"log\") printf '%s\\n' '{h}';;\n"),
None => " \"log\") exit 1;;\n".to_string(),
};
format!("#!/bin/sh\ncase \"$1\" in\n{head_arm}{log_arm} *) exit 1;;\nesac\n")
}
fn cmd(parts: &[String]) -> Vec<&str> {
parts.iter().map(String::as_str).collect()
}
#[test]
fn current_head_reads_and_trims_rev_parse() {
let tmp = TempDir::new().unwrap();
let git = fake_git(&tmp, &script(Some(" abc123\n"), None));
assert_eq!(
current_head_with(&cmd(&git), tmp.path()),
Some("abc123".to_string())
);
}
#[test]
fn current_head_is_none_on_failure() {
let tmp = TempDir::new().unwrap();
let git = fake_git(&tmp, &script(None, None));
assert_eq!(current_head_with(&cmd(&git), tmp.path()), None);
}
#[test]
fn last_commit_for_path_reads_log() {
let tmp = TempDir::new().unwrap();
let git = fake_git(&tmp, &script(None, Some("def456\n")));
let path = tmp.path().join("docs/issues/a.md");
assert_eq!(
last_commit_for_path_with(&cmd(&git), tmp.path(), &path),
Some("def456".to_string())
);
}
#[test]
fn last_commit_for_path_is_none_on_failure() {
let tmp = TempDir::new().unwrap();
let git = fake_git(&tmp, &script(None, None));
let path = tmp.path().join("docs/issues/a.md");
assert_eq!(
last_commit_for_path_with(&cmd(&git), tmp.path(), &path),
None
);
}
#[test]
fn resolve_closing_commit_prefers_the_file_commit() {
let tmp = TempDir::new().unwrap();
let git = fake_git(&tmp, &script(Some("head999"), Some("file111")));
let path = tmp.path().join("docs/issues/a.md");
assert_eq!(
resolve_closing_commit_with(&cmd(&git), tmp.path(), &path),
Some("file111".to_string())
);
}
#[test]
fn resolve_closing_commit_falls_back_to_head() {
let tmp = TempDir::new().unwrap();
let git = fake_git(&tmp, &script(Some("head999"), None));
let path = tmp.path().join("docs/issues/a.md");
assert_eq!(
resolve_closing_commit_with(&cmd(&git), tmp.path(), &path),
Some("head999".to_string())
);
}
#[test]
fn missing_binary_is_none() {
let tmp = TempDir::new().unwrap();
assert_eq!(current_head_with(&["/nonexistent/git"], tmp.path()), None);
assert_eq!(
last_commit_for_path_with(&["/nonexistent/git"], tmp.path(), &tmp.path().join("a.md")),
None
);
}
fn resolve_closing_commit_with(program: &[&str], cwd: &Path, path: &Path) -> Option<String> {
last_commit_for_path_with(program, cwd, path).or_else(|| current_head_with(program, cwd))
}
}