use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::process::{Command, Output};
use crate::error::{CtxError, Result};
pub fn is_git_repo() -> bool {
is_git_repo_in(Path::new("."))
}
pub fn repo_prefix() -> Result<String> {
repo_prefix_in(Path::new("."))
}
pub fn changed_files_against(reference: &str) -> Result<HashSet<String>> {
changed_files_against_in(Path::new("."), reference)
}
pub fn churn_since(since: &str) -> Result<HashMap<String, u32>> {
churn_since_in(Path::new("."), since)
}
pub fn show_file(reference: &str, path: &str) -> Result<Option<String>> {
show_file_in(Path::new("."), reference, path)
}
pub fn is_git_repo_in(dir: &Path) -> bool {
Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(dir)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn repo_prefix_in(dir: &Path) -> Result<String> {
let output = run_git(dir, &["rev-parse", "--show-prefix"])?;
let stdout = stdout_or_err(output, None)?;
Ok(stdout.trim_end_matches(['\n', '\r']).to_string())
}
pub fn changed_files_against_in(dir: &Path, reference: &str) -> Result<HashSet<String>> {
if !is_git_repo_in(dir) {
return Err(CtxError::NotGitRepo);
}
let prefix = repo_prefix_in(dir)?;
let mut files = HashSet::new();
let range = format!("{}...HEAD", reference);
let output = run_git(dir, &["diff", "--name-only", &range])?;
let committed = stdout_or_err(output, Some(reference))?;
collect_paths(&committed, &prefix, &mut files);
let output = run_git(dir, &["diff", "--name-only", "HEAD"])?;
let uncommitted = stdout_or_err(output, None)?;
collect_paths(&uncommitted, &prefix, &mut files);
let output = run_git(
dir,
&["ls-files", "--others", "--exclude-standard", "--full-name"],
)?;
let untracked = stdout_or_err(output, None)?;
collect_paths(&untracked, &prefix, &mut files);
Ok(files)
}
fn churn_since_in(dir: &Path, since: &str) -> Result<HashMap<String, u32>> {
if !is_git_repo_in(dir) {
return Err(CtxError::NotGitRepo);
}
let prefix = repo_prefix_in(dir)?;
let since_arg = format!("--since={}", since);
let output = run_git(
dir,
&[
"log",
&since_arg,
"--format=",
"--name-only",
"--no-renames",
"--",
".",
],
)?;
let log = stdout_or_err(output, None)?;
let mut churn = HashMap::new();
for (path, count) in parse_name_only_log(&log) {
if let Some(local) = strip_repo_prefix(&path, &prefix) {
*churn.entry(local).or_insert(0) += count;
}
}
Ok(churn)
}
pub fn churn_between_in(
dir: &Path,
since: &str,
until: Option<&str>,
) -> Result<HashMap<String, u32>> {
if !is_git_repo_in(dir) {
return Err(CtxError::NotGitRepo);
}
let prefix = repo_prefix_in(dir)?;
let since_arg = format!("--since={}", since);
let until_arg = until.map(|u| format!("--until={}", u));
let mut args = vec!["log", &since_arg];
if let Some(ref until_arg) = until_arg {
args.push(until_arg);
}
args.extend(["--format=", "--name-only", "--no-renames", "--", "."]);
let output = run_git(dir, &args)?;
let log = stdout_or_err(output, None)?;
let mut churn = HashMap::new();
for (path, count) in parse_name_only_log(&log) {
if let Some(local) = strip_repo_prefix(&path, &prefix) {
*churn.entry(local).or_insert(0) += count;
}
}
Ok(churn)
}
pub fn head_commit_in(dir: &Path) -> Result<(String, String)> {
if !is_git_repo_in(dir) {
return Err(CtxError::NotGitRepo);
}
let output = run_git(dir, &["log", "-1", "--format=%H%x00%cI"])?;
let stdout = stdout_or_err(output, Some("HEAD"))?;
let line = stdout.trim();
let (sha, date) = line
.split_once('\0')
.ok_or_else(|| CtxError::git(format!("unexpected `git log -1` output: {:?}", line)))?;
Ok((sha.to_string(), date.to_string()))
}
pub fn rev_list_first_parent_in(dir: &Path, range: &str) -> Result<Vec<String>> {
if !is_git_repo_in(dir) {
return Err(CtxError::NotGitRepo);
}
let output = run_git(dir, &["rev-list", "--first-parent", "--reverse", range])?;
let stdout = stdout_or_err(output, Some(range))?;
Ok(stdout
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect())
}
pub fn is_dirty_in(dir: &Path) -> Result<bool> {
if !is_git_repo_in(dir) {
return Err(CtxError::NotGitRepo);
}
let output = run_git(dir, &["status", "--porcelain"])?;
let stdout = stdout_or_err(output, None)?;
Ok(stdout.lines().any(|l| !l.trim().is_empty()))
}
pub fn show_file_in(dir: &Path, reference: &str, path: &str) -> Result<Option<String>> {
if !is_git_repo_in(dir) {
return Err(CtxError::NotGitRepo);
}
let spec = format!("{}:./{}", reference, path);
let output = run_git(dir, &["show", &spec])?;
if output.status.success() {
return Ok(Some(String::from_utf8_lossy(&output.stdout).into_owned()));
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not exist") || stderr.contains("exists on disk, but not in") {
return Ok(None);
}
stdout_or_err(output, Some(reference)).map(Some)
}
fn run_git(dir: &Path, args: &[&str]) -> Result<Output> {
Ok(Command::new("git").args(args).current_dir(dir).output()?)
}
fn stdout_or_err(output: Output, revision: Option<&str>) -> Result<String> {
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not a git repository") {
return Err(CtxError::NotGitRepo);
}
if let Some(rev) = revision {
if stderr.contains("unknown revision")
|| stderr.contains("bad revision")
|| stderr.contains("invalid object name")
|| stderr.contains("bad object")
{
return Err(CtxError::InvalidRevision(rev.to_string()));
}
}
Err(CtxError::git(stderr.trim().to_string()))
}
fn parse_name_only_log(s: &str) -> HashMap<String, u32> {
let mut counts = HashMap::new();
for line in s.lines() {
let line = line.trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
*counts.entry(line.to_string()).or_insert(0) += 1;
}
counts
}
fn strip_repo_prefix(path: &str, prefix: &str) -> Option<String> {
if prefix.is_empty() {
Some(path.to_string())
} else {
path.strip_prefix(prefix).map(|p| p.to_string())
}
}
fn collect_paths(raw: &str, prefix: &str, files: &mut HashSet<String>) {
for line in raw.lines() {
let line = line.trim_end_matches('\r');
if line.trim().is_empty() {
continue;
}
if let Some(local) = strip_repo_prefix(line, prefix) {
files.insert(local);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::GitRepo;
#[test]
fn test_parse_name_only_log() {
let log = "src/a.rs\nsrc/b.rs\n\nsrc/a.rs\n\n\nsrc/c.rs\n";
let counts = parse_name_only_log(log);
assert_eq!(counts.len(), 3);
assert_eq!(counts.get("src/a.rs"), Some(&2));
assert_eq!(counts.get("src/b.rs"), Some(&1));
assert_eq!(counts.get("src/c.rs"), Some(&1));
}
#[test]
fn test_parse_name_only_log_empty() {
assert!(parse_name_only_log("").is_empty());
assert!(parse_name_only_log("\n\n\n").is_empty());
}
#[test]
fn test_strip_repo_prefix() {
assert_eq!(
strip_repo_prefix("src/a.rs", ""),
Some("src/a.rs".to_string())
);
assert_eq!(
strip_repo_prefix("sub/src/a.rs", "sub/"),
Some("src/a.rs".to_string())
);
assert_eq!(strip_repo_prefix("other/a.rs", "sub/"), None);
}
#[test]
fn test_is_git_repo() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_git_repo_in(dir.path()));
let repo = GitRepo::init(dir.path());
assert!(is_git_repo_in(&repo.root));
}
#[test]
fn test_changed_files_against() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path());
repo.commit_file("src/a.rs", "fn a() {}", "initial");
repo.branch("feature");
repo.commit_file("src/b.rs", "fn b() {}", "add b");
repo.write("src/a.rs", "fn a() { /* changed */ }");
repo.write("src/c.rs", "fn c() {}");
let changed = changed_files_against_in(&repo.root, "main").unwrap();
assert_eq!(changed.len(), 3);
assert!(changed.contains("src/a.rs"));
assert!(changed.contains("src/b.rs"));
assert!(changed.contains("src/c.rs"));
}
#[test]
fn test_changed_files_strips_prefix_in_subdir() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path());
repo.write("top.rs", "fn top() {}");
repo.write("sub/x.rs", "fn x() {}");
repo.commit_all("initial");
repo.branch("feature");
repo.write("top.rs", "fn top() { /* changed */ }");
repo.write("sub/x.rs", "fn x() { /* changed */ }");
repo.commit_all("change both");
let subdir = repo.root.join("sub");
let changed = changed_files_against_in(&subdir, "main").unwrap();
assert_eq!(changed.len(), 1);
assert!(changed.contains("x.rs"));
}
#[test]
fn test_changed_files_bad_reference() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path());
repo.commit_file("a.rs", "fn a() {}", "initial");
let err = changed_files_against_in(&repo.root, "no-such-ref").unwrap_err();
assert!(
matches!(err, CtxError::InvalidRevision(ref r) if r == "no-such-ref"),
"expected InvalidRevision, got: {}",
err
);
}
#[test]
fn test_not_a_repo_errors() {
let dir = tempfile::tempdir().unwrap();
let err = changed_files_against_in(dir.path(), "main").unwrap_err();
assert!(matches!(err, CtxError::NotGitRepo));
let err = churn_since_in(dir.path(), "1 week ago").unwrap_err();
assert!(matches!(err, CtxError::NotGitRepo));
}
#[test]
fn test_churn_since() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path());
repo.commit_file("src/a.rs", "v1", "one");
repo.commit_file("src/a.rs", "v2", "two");
repo.commit_file("src/b.rs", "v1", "three");
let churn = churn_since_in(&repo.root, "2000-01-01").unwrap();
assert_eq!(churn.get("src/a.rs"), Some(&2));
assert_eq!(churn.get("src/b.rs"), Some(&1));
}
#[test]
fn test_churn_between() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path());
repo.write("src/a.rs", "v1");
repo.commit_all_with_date("one", "2020-01-01T12:00:00 +0000");
repo.write("src/a.rs", "v2");
repo.commit_all_with_date("two", "2021-01-01T12:00:00 +0000");
repo.write("src/b.rs", "v1");
repo.commit_all_with_date("three", "2022-01-01T12:00:00 +0000");
let churn = churn_between_in(&repo.root, "2000-01-01", None).unwrap();
assert_eq!(churn.get("src/a.rs"), Some(&2));
assert_eq!(churn.get("src/b.rs"), Some(&1));
let churn = churn_between_in(&repo.root, "2000-01-01", Some("2021-06-01")).unwrap();
assert_eq!(churn.get("src/a.rs"), Some(&2));
assert_eq!(churn.get("src/b.rs"), None);
}
#[test]
fn test_churn_between_not_a_repo() {
let dir = tempfile::tempdir().unwrap();
let err = churn_between_in(dir.path(), "1 week ago", None).unwrap_err();
assert!(matches!(err, CtxError::NotGitRepo));
}
#[test]
fn test_head_commit() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path());
repo.write("a.rs", "fn a() {}");
repo.commit_all_with_date("initial", "2020-01-02T03:04:05 +0000");
let (sha, date) = head_commit_in(&repo.root).unwrap();
assert_eq!(sha.len(), 40, "expected a full sha: {}", sha);
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
assert!(date.starts_with("2020-01-02T03:04:05"), "date: {}", date);
let empty = tempfile::tempdir().unwrap();
let err = head_commit_in(empty.path()).unwrap_err();
assert!(matches!(err, CtxError::NotGitRepo));
}
#[test]
fn test_rev_list_first_parent() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path());
repo.commit_file("a.rs", "v1", "one");
let (first, _) = head_commit_in(&repo.root).unwrap();
repo.commit_file("a.rs", "v2", "two");
repo.commit_file("a.rs", "v3", "three");
let (head, _) = head_commit_in(&repo.root).unwrap();
let shas = rev_list_first_parent_in(&repo.root, &format!("{}..HEAD", first)).unwrap();
assert_eq!(shas.len(), 2, "expected the two commits after the first");
assert_eq!(shas.last(), Some(&head), "oldest-first order");
let err = rev_list_first_parent_in(&repo.root, "no-such..HEAD").unwrap_err();
assert!(matches!(
err,
CtxError::InvalidRevision(_) | CtxError::Git(_)
));
}
#[test]
fn test_is_dirty() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path());
repo.commit_file("a.rs", "fn a() {}", "initial");
assert!(!is_dirty_in(&repo.root).unwrap());
repo.write("b.rs", "fn b() {}");
assert!(is_dirty_in(&repo.root).unwrap());
std::fs::remove_file(repo.root.join("b.rs")).unwrap();
assert!(!is_dirty_in(&repo.root).unwrap());
repo.write("a.rs", "fn a() { /* changed */ }");
assert!(is_dirty_in(&repo.root).unwrap());
}
#[test]
fn test_show_file() {
let dir = tempfile::tempdir().unwrap();
let repo = GitRepo::init(dir.path());
repo.commit_file("src/a.rs", "fn a() {}", "initial");
let content = show_file_in(&repo.root, "HEAD", "src/a.rs").unwrap();
assert_eq!(content.as_deref(), Some("fn a() {}"));
let missing = show_file_in(&repo.root, "HEAD", "src/nope.rs").unwrap();
assert!(missing.is_none());
let err = show_file_in(&repo.root, "no-such-ref", "src/a.rs").unwrap_err();
assert!(matches!(err, CtxError::InvalidRevision(_)));
}
}