use std::path::Path;
use std::process::Command;
use chrono::{Duration, Local};
use crate::domain::repo::{GitInfo, PATH_NOT_FOUND};
use crate::domain::stats::{GitStats, RECENT_DAYS};
use crate::storage::git_client::{
GitClient, git_info_from_counts, parse_github_name, parse_refs,
parse_shortlog, parse_timestamps,
};
pub struct SubprocessGitClient {
github_username: Option<String>,
}
impl SubprocessGitClient {
pub fn new(github_username: Option<String>) -> Self {
SubprocessGitClient { github_username }
}
}
impl GitClient for SubprocessGitClient {
fn collect(&self, path: &Path) -> GitInfo {
if !path.exists() {
return GitInfo {
valid: false,
error: Some(PATH_NOT_FOUND.to_string()),
..GitInfo::default()
};
}
if git_output(path, &["rev-parse", "--is-inside-work-tree"]).as_deref()
!= Some("true")
{
return GitInfo {
valid: false,
error: Some("not a git repository".to_string()),
..GitInfo::default()
};
}
let branch = git_output(path, &["rev-parse", "--abbrev-ref", "HEAD"]);
let changes = git_output(path, &["status", "--porcelain"])
.map(|out| out.lines().filter(|line| !line.is_empty()).count())
.unwrap_or(0) as u32;
let (ahead, behind) = ahead_behind(path);
let github = git_output(path, &["remote", "get-url", "origin"])
.and_then(|url| {
parse_github_name(&url, self.github_username.as_deref())
});
git_info_from_counts(branch, changes, ahead, behind, github)
}
fn fetch(&self, path: &Path) {
if !path.exists() {
return;
}
let _ = Command::new("git")
.arg("-C")
.arg(path)
.args(["fetch", "--quiet"])
.output();
}
fn log(&self, path: &Path, max: usize) -> Vec<String> {
if !path.exists() {
return Vec::new();
}
let arg = format!("-{max}");
git_output(path, &["log", "--oneline", "--no-color", &arg])
.map(|out| out.lines().map(str::to_string).collect())
.unwrap_or_default()
}
fn stats(&self, path: &Path) -> GitStats {
if !path.exists() {
return GitStats::default();
}
let (commits, contributors) =
git_output(path, &["shortlog", "-sne", "HEAD"])
.map(|out| parse_shortlog(&out))
.unwrap_or_default();
let (branches, tags) = git_output(
path,
&[
"for-each-ref",
"--format=%(refname)",
"refs/heads",
"refs/tags",
],
)
.map(|out| parse_refs(&out))
.unwrap_or_default();
GitStats {
commits,
contributors,
branches,
tags,
first_commit: git_output(
path,
&["log", "--max-parents=0", "--format=%ct", "HEAD"],
)
.and_then(|out| parse_timestamps(&out)),
last_commit: git_output(
path,
&["log", "-1", "--format=%ct", "HEAD"],
)
.and_then(|out| parse_timestamps(&out)),
commits_recent: recent_commits(path),
}
}
}
fn recent_commits(path: &Path) -> u64 {
let cutoff = Local::now() - Duration::days(RECENT_DAYS);
let since = format!("--since={}", cutoff.format("%Y-%m-%dT%H:%M:%S%z"));
git_output(path, &["rev-list", "--count", &since, "HEAD"])
.and_then(|out| out.trim().parse::<u64>().ok())
.unwrap_or(0)
}
fn git_output(path: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn ahead_behind(path: &Path) -> (Option<u32>, Option<u32>) {
let Some(out) = git_output(
path,
&["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
) else {
return (None, None);
};
let mut parts = out.split_whitespace();
let ahead = parts.next().and_then(|n| n.parse().ok());
let behind = parts.next().and_then(|n| n.parse().ok());
(ahead, behind)
}