use std::path::Path;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone)]
pub struct CommitSummary {
pub sha: String,
pub msg: String,
pub author: String,
pub ts: Option<DateTime<Utc>>,
}
pub fn git_commits_since(repo: &Path, since: Option<DateTime<Utc>>) -> Vec<CommitSummary> {
let mut cmd = std::process::Command::new("git");
cmd.arg("-C").arg(repo);
cmd.arg("log");
cmd.arg("--format=%H|%s|%an|%aI");
if let Some(ts) = since {
cmd.arg(format!("--since={}", ts.to_rfc3339()));
} else {
cmd.arg("-n").arg("50");
}
let output = match cmd.output() {
Ok(o) if o.status.success() => o,
Ok(_) | Err(_) => return vec![],
};
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(parse_commit_line)
.collect()
}
pub fn git_branches_changed_since(repo: &Path, since: Option<DateTime<Utc>>) -> Vec<String> {
let ts = match since {
Some(ts) => ts,
None => return vec![],
};
let branch_output = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.arg("branch")
.arg("--format=%(refname:short)")
.output();
let Ok(out) = branch_output else {
return vec![];
};
if !out.status.success() {
return vec![];
}
let branches: Vec<String> = String::from_utf8_lossy(&out.stdout)
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
branches
.into_iter()
.filter(|branch| {
let check = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.arg("log")
.arg(branch)
.arg(format!("--since={}", ts.to_rfc3339()))
.arg("-n")
.arg("1")
.arg("--format=%H")
.output();
matches!(check, Ok(o) if o.status.success() && !o.stdout.is_empty())
})
.collect()
}
fn parse_commit_line(line: &str) -> Option<CommitSummary> {
let mut parts = line.splitn(4, '|');
let sha = parts.next()?.trim().to_string();
let msg = parts.next()?.trim().to_string();
let author = parts.next()?.trim().to_string();
let ts_str = parts.next().unwrap_or("").trim();
let ts = ts_str.parse::<DateTime<Utc>>().ok();
if sha.is_empty() {
return None;
}
Some(CommitSummary {
sha,
msg,
author,
ts,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
fn init_repo_with_commits(tmp: &TempDir) {
let p = tmp.path();
Command::new("git")
.arg("-C")
.arg(p)
.args(["init"])
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(p)
.args(["config", "user.email", "test@test.com"])
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(p)
.args(["config", "user.name", "Test"])
.output()
.unwrap();
std::fs::write(p.join("a.txt"), b"a").unwrap();
Command::new("git")
.arg("-C")
.arg(p)
.args(["add", "."])
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "-m", "first commit"])
.output()
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(100));
std::fs::write(p.join("b.txt"), b"b").unwrap();
Command::new("git")
.arg("-C")
.arg(p)
.args(["add", "."])
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(p)
.args(["commit", "-m", "second commit"])
.output()
.unwrap();
}
#[test]
fn git_commits_since_returns_commits() {
let tmp = TempDir::new().unwrap();
init_repo_with_commits(&tmp);
let commits = git_commits_since(tmp.path(), None);
assert_eq!(commits.len(), 2, "should return both commits");
assert!(commits.iter().any(|c| c.msg.contains("second commit")));
assert!(commits.iter().any(|c| c.msg.contains("first commit")));
}
#[test]
fn git_commits_since_filters_by_date() {
let tmp = TempDir::new().unwrap();
init_repo_with_commits(&tmp);
let future: DateTime<Utc> = "2099-01-01T00:00:00Z".parse().unwrap();
let commits = git_commits_since(tmp.path(), Some(future));
assert!(commits.is_empty(), "future since should return empty");
}
#[test]
fn git_commits_since_nonrepo_returns_empty() {
let tmp = TempDir::new().unwrap();
let commits = git_commits_since(tmp.path(), None);
assert!(commits.is_empty(), "non-repo dir should return empty vec");
}
#[test]
fn parse_commit_line_basic() {
let line = "abc1234|fix the bug|Alice|2026-06-27T10:00:00+00:00";
let c = parse_commit_line(line).unwrap();
assert_eq!(c.sha, "abc1234");
assert_eq!(c.msg, "fix the bug");
assert_eq!(c.author, "Alice");
assert!(c.ts.is_some());
}
#[test]
fn parse_commit_line_with_pipe_in_subject() {
let line = "abc1234|feat: add something|Alice|2026-06-27T10:00:00+00:00";
let c = parse_commit_line(line).unwrap();
assert_eq!(c.sha, "abc1234");
assert_eq!(c.msg, "feat: add something");
assert_eq!(c.author, "Alice");
assert!(c.ts.is_some());
}
#[test]
fn parse_commit_line_empty_returns_none() {
assert!(parse_commit_line("").is_none());
assert!(parse_commit_line("|").is_none());
}
#[test]
fn parse_commit_line_missing_ts_is_ok() {
let line = "abc1234|fix|Alice";
let c = parse_commit_line(line).unwrap();
assert_eq!(c.sha, "abc1234");
assert!(c.ts.is_none());
}
}