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 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_changed_files_not_a_repo() {
let dir = tempfile::tempdir().unwrap();
let err = changed_files_against_in(dir.path(), "main").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_since_not_a_repo() {
let dir = tempfile::tempdir().unwrap();
let err = churn_since_in(dir.path(), "1 week ago").unwrap_err();
assert!(matches!(err, CtxError::NotGitRepo));
}
#[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(_)));
}
}