use std::future::Future;
use std::io;
use std::path::PathBuf;
use jiff::Timestamp;
use crate::process::capture;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FirstParentCommit {
pub commit_id: String,
pub committer_time: Option<Timestamp>,
pub subject: String,
}
pub trait GitHistory {
fn resolve(&self, reference: &str) -> impl Future<Output = io::Result<Option<String>>>;
fn default_branch(&self) -> impl Future<Output = io::Result<Option<String>>>;
fn merge_base(&self, a: &str, b: &str) -> impl Future<Output = io::Result<Option<String>>>;
fn first_parent(
&self,
reference: &str,
) -> impl Future<Output = io::Result<Vec<FirstParentCommit>>>;
fn committer_time(
&self,
reference: &str,
) -> impl Future<Output = io::Result<Option<Timestamp>>>;
fn is_dirty(&self) -> impl Future<Output = io::Result<bool>>;
}
#[derive(Clone, Debug)]
pub struct SystemGitHistory {
repo: PathBuf,
}
impl SystemGitHistory {
#[must_use]
pub fn new(repo: impl Into<PathBuf>) -> Self {
Self { repo: repo.into() }
}
#[cfg_attr(test, mutants::skip)] async fn run(&self, args: &[&str]) -> io::Result<Option<String>> {
let repo = self.repo.to_string_lossy().into_owned();
let mut full: Vec<&str> = vec!["-C", repo.as_str()];
full.extend_from_slice(args);
let output = capture("git", &full).await?;
if output.status.success() {
Ok(Some(output.stdout))
} else {
Ok(None)
}
}
}
impl GitHistory for SystemGitHistory {
#[cfg_attr(test, mutants::skip)] async fn resolve(&self, reference: &str) -> io::Result<Option<String>> {
let spec = format!("{reference}^{{commit}}");
let output = self
.run(&["rev-parse", "--verify", "--quiet", &spec])
.await?;
Ok(output.and_then(|stdout| parse_commit_id(&stdout)))
}
#[cfg_attr(test, mutants::skip)] async fn default_branch(&self) -> io::Result<Option<String>> {
if let Some(stdout) = self
.run(&["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"])
.await?
&& let Some(branch) = branch_from_symbolic_ref(&stdout)
{
return Ok(Some(branch));
}
for candidate in ["main", "master"] {
if self.resolve(candidate).await?.is_some() {
return Ok(Some(candidate.to_owned()));
}
}
Ok(None)
}
#[cfg_attr(test, mutants::skip)] async fn merge_base(&self, a: &str, b: &str) -> io::Result<Option<String>> {
let output = self.run(&["merge-base", a, b]).await?;
Ok(output.and_then(|stdout| parse_commit_id(&stdout)))
}
#[cfg_attr(test, mutants::skip)] async fn first_parent(&self, reference: &str) -> io::Result<Vec<FirstParentCommit>> {
let output = self
.run(&[
"log",
"--first-parent",
"--reverse",
"-z",
"--format=%H%x00%cI%x00%s",
reference,
])
.await?;
Ok(output
.map(|stdout| parse_first_parent_log(&stdout))
.unwrap_or_default())
}
#[cfg_attr(test, mutants::skip)] async fn committer_time(&self, reference: &str) -> io::Result<Option<Timestamp>> {
let output = self.run(&["log", "-1", "--format=%cI", reference]).await?;
Ok(output.and_then(|stdout| parse_committer_time(&stdout)))
}
#[cfg_attr(test, mutants::skip)] async fn is_dirty(&self) -> io::Result<bool> {
let output = self.run(&["status", "--porcelain"]).await?;
Ok(output.as_deref().is_some_and(porcelain_is_dirty))
}
}
fn porcelain_is_dirty(stdout: &str) -> bool {
!stdout.trim().is_empty()
}
fn parse_commit_id(stdout: &str) -> Option<String> {
stdout
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(ToOwned::to_owned)
}
fn parse_first_parent_log(stdout: &str) -> Vec<FirstParentCommit> {
let mut fields = stdout.split('\0');
let mut commits = Vec::new();
while let Some(commit_id) = fields.next() {
let commit_id = commit_id.trim();
if commit_id.is_empty() {
break;
}
let committer_time = fields
.next()
.and_then(|field| field.trim().parse::<Timestamp>().ok());
let subject = fields.next().unwrap_or_default().to_owned();
commits.push(FirstParentCommit {
commit_id: commit_id.to_owned(),
committer_time,
subject,
});
}
commits
}
fn parse_committer_time(stdout: &str) -> Option<Timestamp> {
stdout
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.and_then(|line| line.parse::<Timestamp>().ok())
}
fn branch_from_symbolic_ref(stdout: &str) -> Option<String> {
let trimmed = stdout.trim();
trimmed
.strip_prefix("refs/remotes/origin/")
.filter(|branch| !branch.is_empty())
.map(ToOwned::to_owned)
}
#[cfg(any(test, feature = "private-test-util"))]
pub use fake::FakeGitHistory;
#[cfg(any(test, feature = "private-test-util"))]
mod fake {
use std::collections::HashMap;
use std::future::{Future, ready};
use std::io;
use jiff::Timestamp;
use super::{FirstParentCommit, GitHistory};
#[derive(Clone, Debug, Default)]
pub struct FakeGitHistory {
refs: HashMap<String, String>,
parents: HashMap<String, Option<String>>,
times: HashMap<String, Timestamp>,
subjects: HashMap<String, String>,
default_branch: Option<String>,
dirty: bool,
}
impl FakeGitHistory {
#[must_use]
pub fn new() -> Self {
Self {
refs: HashMap::new(),
parents: HashMap::new(),
times: HashMap::new(),
subjects: HashMap::new(),
default_branch: None,
dirty: false,
}
}
pub fn commit(&mut self, commit_id: &str, parent: Option<&str>) -> &mut Self {
self.parents
.insert(commit_id.to_owned(), parent.map(ToOwned::to_owned));
self
}
pub fn commit_at(
&mut self,
commit_id: &str,
parent: Option<&str>,
time: Timestamp,
) -> &mut Self {
self.commit(commit_id, parent);
self.times.insert(commit_id.to_owned(), time);
self
}
pub fn subject(&mut self, commit_id: &str, subject: &str) -> &mut Self {
self.subjects
.insert(commit_id.to_owned(), subject.to_owned());
self
}
pub fn branch(&mut self, name: &str, commit_id: &str) -> &mut Self {
self.refs.insert(name.to_owned(), commit_id.to_owned());
self
}
pub fn head(&mut self, reference: &str) -> &mut Self {
let commit_id = self.resolve_sync(reference).unwrap();
self.refs.insert("HEAD".to_owned(), commit_id);
self
}
pub fn mark_default(&mut self, name: &str) -> &mut Self {
self.default_branch = Some(name.to_owned());
self
}
pub fn mark_dirty(&mut self) -> &mut Self {
self.dirty = true;
self
}
fn resolve_sync(&self, reference: &str) -> Option<String> {
if let Some(commit_id) = self.refs.get(reference) {
return Some(commit_id.clone());
}
if self.parents.contains_key(reference) {
return Some(reference.to_owned());
}
None
}
fn chain(&self, commit_id: &str) -> Vec<String> {
let mut chain = Vec::new();
let mut current = Some(commit_id.to_owned());
while let Some(commit) = current {
let next = self.parents.get(&commit).cloned().flatten();
chain.push(commit);
current = next;
}
chain
}
fn merge_base_sync(&self, a: &str, b: &str) -> Option<String> {
let a = self.resolve_sync(a)?;
let b = self.resolve_sync(b)?;
let ancestors_a: std::collections::HashSet<String> =
self.chain(&a).into_iter().collect();
self.chain(&b)
.into_iter()
.find(|commit| ancestors_a.contains(commit))
}
}
impl GitHistory for FakeGitHistory {
fn resolve(&self, reference: &str) -> impl Future<Output = io::Result<Option<String>>> {
ready(Ok(self.resolve_sync(reference)))
}
fn default_branch(&self) -> impl Future<Output = io::Result<Option<String>>> {
ready(Ok(self.default_branch.clone()))
}
fn merge_base(&self, a: &str, b: &str) -> impl Future<Output = io::Result<Option<String>>> {
let base = self.merge_base_sync(a, b);
ready(Ok(base))
}
fn first_parent(
&self,
reference: &str,
) -> impl Future<Output = io::Result<Vec<FirstParentCommit>>> {
let Some(commit_id) = self.resolve_sync(reference) else {
return ready(Ok(Vec::new()));
};
let mut chain = self.chain(&commit_id);
chain.reverse(); let commits = chain
.into_iter()
.map(|commit_id| FirstParentCommit {
committer_time: self.times.get(&commit_id).copied(),
subject: self.subjects.get(&commit_id).cloned().unwrap_or_default(),
commit_id,
})
.collect();
ready(Ok(commits))
}
fn committer_time(
&self,
reference: &str,
) -> impl Future<Output = io::Result<Option<Timestamp>>> {
let time = self
.resolve_sync(reference)
.and_then(|commit_id| self.times.get(&commit_id).copied());
ready(Ok(time))
}
fn is_dirty(&self) -> impl Future<Output = io::Result<bool>> {
ready(Ok(self.dirty))
}
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use futures::executor::block_on;
use super::*;
#[test]
fn parse_commit_id_takes_first_non_empty_line() {
assert_eq!(
parse_commit_id(" \n abc123 \n def \n"),
Some("abc123".to_owned())
);
assert_eq!(parse_commit_id(" \n \n"), None);
assert_eq!(parse_commit_id(""), None);
}
#[test]
fn parse_first_parent_log_keeps_order_times_subjects_and_drops_blanks() {
let parsed = parse_first_parent_log(
"c0\x002024-01-01T00:00:00+00:00\x00First commit\x00c1\x002024-02-01T00:00:00+00:00\x00Fix the thing\x00c2\x002024-03-01T00:00:00+00:00\x00Subject with spaces \x00",
);
assert_eq!(
parsed,
vec![
FirstParentCommit {
commit_id: "c0".to_owned(),
committer_time: Some("2024-01-01T00:00:00+00:00".parse().unwrap()),
subject: "First commit".to_owned(),
},
FirstParentCommit {
commit_id: "c1".to_owned(),
committer_time: Some("2024-02-01T00:00:00+00:00".parse().unwrap()),
subject: "Fix the thing".to_owned(),
},
FirstParentCommit {
commit_id: "c2".to_owned(),
committer_time: Some("2024-03-01T00:00:00+00:00".parse().unwrap()),
subject: "Subject with spaces ".to_owned(),
},
]
);
assert_eq!(
parse_first_parent_log(
"c0\x002024-01-01T00:00:00+00:00\x00\x00c1\x00not-a-date\x00has subject\x00"
),
vec![
FirstParentCommit {
commit_id: "c0".to_owned(),
committer_time: Some("2024-01-01T00:00:00+00:00".parse().unwrap()),
subject: String::new(),
},
FirstParentCommit {
commit_id: "c1".to_owned(),
committer_time: None,
subject: "has subject".to_owned(),
},
]
);
assert!(parse_first_parent_log("").is_empty());
assert!(parse_first_parent_log("\x00").is_empty());
}
#[test]
fn parse_first_parent_log_preserves_delimiter_like_bytes_in_subjects() {
assert_eq!(
parse_first_parent_log("c0\x002024-01-01T00:00:00+00:00\x00Weird\u{1f}subject\x00"),
vec![FirstParentCommit {
commit_id: "c0".to_owned(),
committer_time: Some("2024-01-01T00:00:00+00:00".parse().unwrap()),
subject: "Weird\u{1f}subject".to_owned(),
}]
);
}
#[test]
fn parse_committer_time_takes_first_non_empty_line() {
assert_eq!(
parse_committer_time(" \n 2024-03-01T00:00:00+00:00 \n ignored \n"),
Some("2024-03-01T00:00:00+00:00".parse().unwrap())
);
assert_eq!(parse_committer_time("not-a-date\n"), None);
assert_eq!(parse_committer_time(" \n \n"), None);
assert_eq!(parse_committer_time(""), None);
}
#[test]
fn porcelain_is_dirty_detects_any_nonblank_content() {
assert!(porcelain_is_dirty(" M src/lib.rs\n"));
assert!(porcelain_is_dirty("?? new_file\n"));
assert!(!porcelain_is_dirty(""));
assert!(!porcelain_is_dirty(" \n \n"));
}
#[test]
fn branch_from_symbolic_ref_strips_remote_prefix() {
assert_eq!(
branch_from_symbolic_ref("refs/remotes/origin/main\n"),
Some("main".to_owned())
);
assert_eq!(
branch_from_symbolic_ref("refs/remotes/origin/release/v2"),
Some("release/v2".to_owned())
);
assert_eq!(branch_from_symbolic_ref("refs/heads/main"), None);
assert_eq!(branch_from_symbolic_ref("refs/remotes/origin/"), None);
}
fn fixture() -> FakeGitHistory {
let mut git = FakeGitHistory::new();
git.commit("c0", None)
.commit("c1", Some("c0"))
.commit("c2", Some("c1"))
.commit("c3", Some("c2"))
.commit("f1", Some("c1"))
.commit("f2", Some("f1"))
.branch("master", "c3")
.branch("feature", "f2")
.head("feature")
.mark_default("master");
git
}
#[test]
fn fake_resolves_refs_and_raw_commit_ids() {
let git = fixture();
assert_eq!(
block_on(git.resolve("master")).unwrap(),
Some("c3".to_owned())
);
assert_eq!(
block_on(git.resolve("HEAD")).unwrap(),
Some("f2".to_owned())
);
assert_eq!(block_on(git.resolve("c1")).unwrap(), Some("c1".to_owned()));
assert_eq!(block_on(git.resolve("absent")).unwrap(), None);
}
#[test]
fn fake_reports_default_branch() {
assert_eq!(
block_on(fixture().default_branch()).unwrap(),
Some("master".to_owned())
);
assert_eq!(
block_on(FakeGitHistory::new().default_branch()).unwrap(),
None
);
}
#[test]
fn fake_reports_working_tree_dirtiness() {
assert!(!block_on(fixture().is_dirty()).unwrap());
let mut dirty = fixture();
dirty.mark_dirty();
assert!(block_on(dirty.is_dirty()).unwrap());
}
#[test]
fn fake_first_parent_is_oldest_first() {
let git = fixture();
let commit_ids = |reference: &str| {
block_on(git.first_parent(reference))
.unwrap()
.into_iter()
.map(|commit| commit.commit_id)
.collect::<Vec<_>>()
};
assert_eq!(commit_ids("master"), vec!["c0", "c1", "c2", "c3"]);
assert_eq!(commit_ids("feature"), vec!["c0", "c1", "f1", "f2"]);
assert!(block_on(git.first_parent("absent")).unwrap().is_empty());
}
#[test]
fn fake_first_parent_carries_committer_times_and_subjects() {
let mut git = FakeGitHistory::new();
let t0: Timestamp = "2024-01-01T00:00:00+00:00".parse().unwrap();
let t1: Timestamp = "2024-02-01T00:00:00+00:00".parse().unwrap();
git.commit_at("c0", None, t0)
.commit_at("c1", Some("c0"), t1)
.commit("c2", Some("c1")) .subject("c0", "Initial commit")
.subject("c1", "Fix the thing")
.branch("master", "c2")
.head("master");
assert_eq!(
block_on(git.first_parent("master")).unwrap(),
vec![
FirstParentCommit {
commit_id: "c0".to_owned(),
committer_time: Some(t0),
subject: "Initial commit".to_owned(),
},
FirstParentCommit {
commit_id: "c1".to_owned(),
committer_time: Some(t1),
subject: "Fix the thing".to_owned(),
},
FirstParentCommit {
commit_id: "c2".to_owned(),
committer_time: None,
subject: String::new(),
},
]
);
}
#[test]
fn fake_committer_time_reads_a_single_commit() {
let mut git = FakeGitHistory::new();
let t0: Timestamp = "2024-01-01T00:00:00+00:00".parse().unwrap();
let t1: Timestamp = "2024-02-01T00:00:00+00:00".parse().unwrap();
git.commit_at("c0", None, t0)
.commit_at("c1", Some("c0"), t1)
.commit("c2", Some("c1")) .branch("master", "c1")
.head("master");
assert_eq!(block_on(git.committer_time("HEAD")).unwrap(), Some(t1));
assert_eq!(block_on(git.committer_time("c0")).unwrap(), Some(t0));
assert_eq!(block_on(git.committer_time("c2")).unwrap(), None);
assert_eq!(block_on(git.committer_time("absent")).unwrap(), None);
}
#[test]
fn fake_merge_base_is_the_branch_point() {
let git = fixture();
assert_eq!(
block_on(git.merge_base("feature", "master")).unwrap(),
Some("c1".to_owned())
);
assert_eq!(
block_on(git.merge_base("master", "feature")).unwrap(),
Some("c1".to_owned())
);
assert_eq!(
block_on(git.merge_base("master", "master")).unwrap(),
Some("c3".to_owned())
);
}
#[test]
fn fake_merge_base_is_none_for_disjoint_histories() {
let mut git = FakeGitHistory::new();
git.commit("a0", None)
.commit("b0", None)
.branch("a", "a0")
.branch("b", "b0");
assert_eq!(block_on(git.merge_base("a", "b")).unwrap(), None);
assert_eq!(block_on(git.merge_base("a", "absent")).unwrap(), None);
}
}