use super::provider::LOG_TARGET;
use crate::Result;
use chrono::{DateTime, Utc};
use core::time::Duration;
use ohno::{IntoAppError, bail};
use std::fs;
use std::path::Path;
use tokio::process::Command;
use url::Url;
const GIT_TIMEOUT: Duration = Duration::from_mins(5);
fn path_str(path: &Path) -> Result<&str> {
path.to_str().into_app_err("invalid UTF-8 in repository path")
}
pub enum RepoStatus {
Ok,
NotFound,
}
pub async fn get_repo(repo_path: &Path, repo_url: &Url) -> Result<RepoStatus> {
let start_time = std::time::Instant::now();
let status = get_repo_core(repo_path, repo_url).await?;
if matches!(status, RepoStatus::Ok) {
log::debug!(target: LOG_TARGET, "Successfully prepared cached repository from '{repo_url}' in {:.3}s", start_time.elapsed().as_secs_f64());
}
Ok(status)
}
async fn get_repo_core(repo_path: &Path, repo_url: &Url) -> Result<RepoStatus> {
let path_str = path_str(repo_path)?;
if !repo_path.exists() {
if let Some(parent) = repo_path.parent() {
fs::create_dir_all(parent).into_app_err_with(|| format!("creating directory '{}'", parent.display()))?;
}
return clone_repo(path_str, repo_url).await;
}
if !repo_path.join(".git").exists() {
log::warn!(target: LOG_TARGET, "Cached repository path '{path_str}' exists but .git directory missing, re-cloning");
fs::remove_dir_all(repo_path)
.into_app_err_with(|| format!("removing potentially corrupt cached repository '{path_str}'"))?;
return clone_repo(path_str, repo_url).await;
}
log::info!(target: LOG_TARGET, "Syncing repository '{repo_url}'");
let output = run_git_with_timeout(&["-C", path_str, "fetch", "origin", "--filter=blob:none", "--prune", "--force"]).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!(target: LOG_TARGET, "Git fetch failed ({}), removing and re-cloning", stderr.trim());
fs::remove_dir_all(path_str).into_app_err_with(|| format!("removing stale cached repository '{path_str}'"))?;
return clone_repo(path_str, repo_url).await;
}
let output = run_git_with_timeout(&["-C", path_str, "reset", "--hard", "origin/HEAD"]).await?;
check_git_output(&output, "git reset")?;
Ok(RepoStatus::Ok)
}
fn is_repo_not_found(stderr: &str) -> bool {
let stderr_lower = stderr.to_lowercase();
stderr_lower.contains("not found") || stderr_lower.contains("does not exist")
}
async fn clone_repo(repo_path: &str, repo_url: &Url) -> Result<RepoStatus> {
log::info!(target: LOG_TARGET, "Syncing repository '{repo_url}'");
let output = run_git_with_timeout(&[
"clone",
"--filter=blob:none",
"--single-branch",
"--no-tags",
repo_url.as_str(),
repo_path,
])
.await?;
if output.status.success() {
return Ok(RepoStatus::Ok);
}
let stderr = String::from_utf8_lossy(&output.stderr);
let path = Path::new(repo_path);
if path.exists() {
let _ = fs::remove_dir_all(path);
}
if is_repo_not_found(&stderr) {
log::debug!(target: LOG_TARGET, "Repository '{repo_url}' not found on remote");
return Ok(RepoStatus::NotFound);
}
bail!("git clone failed: {stderr}");
}
fn check_git_output(output: &std::process::Output, operation: &str) -> Result<()> {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("{operation} failed: {stderr}");
}
Ok(())
}
pub async fn count_contributors(repo_path: &Path) -> Result<u64> {
let path_str = path_str(repo_path)?;
let output = run_git_with_timeout(&["-C", path_str, "shortlog", "-sne", "--all"]).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git shortlog failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.lines().count() as u64)
}
pub struct CommitStats {
pub commit_count: u64,
pub first_commit_at: DateTime<Utc>,
pub last_commit_at: DateTime<Utc>,
pub commits_per_window: Vec<u64>,
}
pub async fn get_commit_stats(repo_path: &Path, day_windows: &[i64]) -> Result<CommitStats> {
let path_str = path_str(repo_path)?;
let output = run_git_with_timeout(&["-C", path_str, "log", "--format=%at"]).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("git log failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let now = Utc::now().timestamp();
let mut commit_count: u64 = 0;
let mut first_timestamp: Option<i64> = None;
let mut last_timestamp: Option<i64> = None;
let mut window_counts = vec![0u64; day_windows.len()];
let window_thresholds: Vec<i64> = day_windows.iter().map(|days| now - days * 86400).collect();
for line in stdout.lines() {
let ts: i64 = match line.trim().parse() {
Ok(v) => v,
Err(_) => continue,
};
commit_count += 1;
if last_timestamp.is_none() {
last_timestamp = Some(ts);
}
first_timestamp = Some(ts);
for (i, threshold) in window_thresholds.iter().enumerate() {
if ts >= *threshold {
window_counts[i] += 1;
}
}
}
let first_commit_at = first_timestamp
.and_then(|ts| DateTime::from_timestamp(ts, 0))
.unwrap_or(DateTime::UNIX_EPOCH);
let last_commit_at = last_timestamp
.and_then(|ts| DateTime::from_timestamp(ts, 0))
.unwrap_or(DateTime::UNIX_EPOCH);
Ok(CommitStats {
commit_count,
first_commit_at,
last_commit_at,
commits_per_window: window_counts,
})
}
async fn run_git_with_timeout(args: &[&str]) -> Result<std::process::Output> {
let child = Command::new("git")
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.into_app_err("spawning git command")?;
match tokio::time::timeout(GIT_TIMEOUT, child.wait_with_output()).await {
Ok(Ok(output)) => Ok(output),
Ok(Err(e)) => Err(e).into_app_err_with(|| format!("running 'git {}'", args.join(" "))),
Err(_) => {
bail!("'git {}' timed out after {} seconds", args.join(" "), GIT_TIMEOUT.as_secs());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::{ExitStatus, Output};
#[test]
fn test_check_git_output_success() {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
ExitStatus::from_raw(0)
};
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
ExitStatus::from_raw(0)
};
let output = Output {
status,
stdout: vec![],
stderr: vec![],
};
check_git_output(&output, "test operation").unwrap();
}
#[test]
fn test_check_git_output_failure() {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
ExitStatus::from_raw(256) };
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
ExitStatus::from_raw(1)
};
let output = Output {
status,
stdout: vec![],
stderr: b"error: failed to do something".to_vec(),
};
let result = check_git_output(&output, "test operation");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("test operation failed"));
}
#[test]
fn test_is_repo_not_found_positive() {
assert!(is_repo_not_found("Repository not found"));
assert!(is_repo_not_found("ERROR: Repository not found."));
assert!(is_repo_not_found("remote: Repository does not exist"));
assert!(is_repo_not_found("fatal: repository 'https://...' does not exist"));
}
#[test]
fn test_is_repo_not_found_negative() {
assert!(!is_repo_not_found("fatal: unable to access"));
assert!(!is_repo_not_found("Permission denied"));
assert!(!is_repo_not_found(""));
}
#[test]
fn test_is_repo_not_found_case_insensitive() {
assert!(is_repo_not_found("NOT FOUND"));
assert!(is_repo_not_found("DOES NOT EXIST"));
assert!(is_repo_not_found("Not Found"));
}
#[test]
fn test_path_str_valid_utf8() {
let path = Path::new("/tmp/test");
assert_eq!(path_str(path).unwrap(), "/tmp/test");
}
#[test]
fn test_check_git_output_with_stderr() {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
ExitStatus::from_raw(256)
};
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
ExitStatus::from_raw(1)
};
let stderr_msg = b"fatal: not a git repository";
let output = Output {
status,
stdout: vec![],
stderr: stderr_msg.to_vec(),
};
let result = check_git_output(&output, "git status");
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("git status failed"));
assert!(error_msg.contains("not a git repository"));
}
fn create_test_repo() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().expect("create temp dir");
let repo_path = tmp.path().join("test-repo");
fs::create_dir_all(&repo_path).expect("create repo dir");
let init = |args: &[&str]| {
let _ = std::process::Command::new("git")
.args(args)
.current_dir(&repo_path)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.expect("run git command");
};
init(&["init"]);
init(&["config", "user.email", "test@test.com"]);
init(&["config", "user.name", "Test User"]);
fs::write(repo_path.join("file1.txt"), "hello").expect("write file1");
init(&["add", "."]);
init(&["commit", "-m", "first commit"]);
fs::write(repo_path.join("file2.txt"), "world").expect("write file2");
init(&["add", "."]);
init(&["commit", "-m", "second commit"]);
(tmp, repo_path)
}
fn create_bare_repo_with_commit() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().expect("create temp dir");
let bare_path = tmp.path().join("bare.git");
let run = |args: &[&str], dir: &Path| {
let _ = std::process::Command::new("git")
.args(args)
.current_dir(dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.expect("run git command");
};
fs::create_dir_all(&bare_path).expect("create bare dir");
run(&["init", "--bare"], &bare_path);
let work_path = tmp.path().join("work");
let _ = std::process::Command::new("git")
.args(["clone", bare_path.to_str().unwrap(), work_path.to_str().unwrap()])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.unwrap();
run(&["config", "user.email", "test@test.com"], &work_path);
run(&["config", "user.name", "Test User"], &work_path);
fs::write(work_path.join("file.txt"), "content").expect("write file");
run(&["add", "."], &work_path);
run(&["commit", "-m", "initial"], &work_path);
run(&["push"], &work_path);
(tmp, bare_path)
}
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_run_git_with_timeout_success() {
let output = run_git_with_timeout(&["--version"]).await.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("git version"));
}
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_run_git_with_timeout_failure() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().to_str().unwrap();
let output = run_git_with_timeout(&["-C", path, "log"]).await.unwrap();
assert!(!output.status.success());
}
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_count_contributors() {
let (_tmp, repo_path) = create_test_repo();
let count = count_contributors(&repo_path).await.unwrap();
assert_eq!(count, 1); }
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_count_contributors_failure() {
let tmp = tempfile::tempdir().unwrap();
let result = count_contributors(tmp.path()).await;
let _ = result.unwrap_err();
}
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_get_commit_stats_basic() {
let (_tmp, repo_path) = create_test_repo();
let stats = get_commit_stats(&repo_path, &[30, 365]).await.unwrap();
assert_eq!(stats.commit_count, 2);
assert!(stats.first_commit_at <= stats.last_commit_at);
assert_eq!(stats.commits_per_window.len(), 2);
assert_eq!(stats.commits_per_window[0], 2); assert_eq!(stats.commits_per_window[1], 2); }
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_get_commit_stats_empty_windows() {
let (_tmp, repo_path) = create_test_repo();
let stats = get_commit_stats(&repo_path, &[]).await.unwrap();
assert_eq!(stats.commit_count, 2);
assert!(stats.commits_per_window.is_empty());
}
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_get_commit_stats_failure() {
let tmp = tempfile::tempdir().unwrap();
let result = get_commit_stats(tmp.path(), &[30]).await;
assert!(result.is_err());
}
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_get_repo_clone_from_bare() {
let (tmp, bare_path) = create_bare_repo_with_commit();
let clone_path = tmp.path().join("clone");
let bare_url = Url::from_file_path(&bare_path).unwrap();
let status = get_repo(&clone_path, &bare_url).await.unwrap();
assert!(matches!(status, RepoStatus::Ok));
assert!(clone_path.join(".git").exists());
let status = get_repo(&clone_path, &bare_url).await.unwrap();
assert!(matches!(status, RepoStatus::Ok));
}
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_get_repo_reclones_when_git_dir_missing() {
let (tmp, bare_path) = create_bare_repo_with_commit();
let clone_path = tmp.path().join("clone");
let bare_url = Url::from_file_path(&bare_path).unwrap();
let status = get_repo(&clone_path, &bare_url).await.unwrap();
assert!(matches!(status, RepoStatus::Ok));
fs::remove_dir_all(clone_path.join(".git")).unwrap();
let status = get_repo(&clone_path, &bare_url).await.unwrap();
assert!(matches!(status, RepoStatus::Ok));
assert!(clone_path.join(".git").exists());
}
#[tokio::test]
#[cfg_attr(miri, ignore = "Miri cannot run external commands")]
async fn test_get_repo_nonexistent_remote() {
let tmp = tempfile::tempdir().unwrap();
let clone_path = tmp.path().join("clone");
let bad_url = Url::from_file_path(tmp.path().join("nonexistent.git")).unwrap();
if let Ok(status) = get_repo(&clone_path, &bad_url).await {
assert!(matches!(status, RepoStatus::NotFound));
}
}
}