use std::path::Path;
use crate::domain::repo::GitInfo;
use crate::domain::stats::GitStats;
pub trait GitClient: Send + Sync {
fn collect(&self, path: &Path) -> GitInfo;
fn fetch(&self, path: &Path);
fn log(&self, path: &Path, max: usize) -> Vec<String>;
fn stats(&self, path: &Path) -> GitStats;
}
pub fn parse_shortlog(text: &str) -> (u64, u64) {
let mut commits = 0;
let mut authors = 0;
for line in text.lines() {
let Some((count, _)) = line.trim_start().split_once('\t') else {
continue;
};
let Ok(count) = count.trim().parse::<u64>() else {
continue;
};
commits += count;
authors += 1;
}
(commits, authors)
}
pub fn parse_timestamps(text: &str) -> Option<i64> {
text.lines()
.filter_map(|line| line.trim().parse::<i64>().ok())
.min()
}
pub fn parse_refs(text: &str) -> (u64, u64) {
let mut branches = 0;
let mut tags = 0;
for line in text.lines() {
let line = line.trim();
if line.starts_with("refs/heads/") {
branches += 1;
} else if line.starts_with("refs/tags/") {
tags += 1;
}
}
(branches, tags)
}
pub fn parse_github_name(url: &str, username: Option<&str>) -> Option<String> {
let trimmed = url.trim().trim_end_matches('/');
let tail = remote_tail(trimmed)?;
let tail = tail.strip_suffix(".git").unwrap_or(tail);
let (owner, repo) = tail.split_once('/')?;
if owner.is_empty() || repo.is_empty() {
return None;
}
if username.is_some_and(|name| name.eq_ignore_ascii_case(owner)) {
return Some(repo.to_string());
}
Some(format!("{owner}/{repo}"))
}
fn remote_tail(url: &str) -> Option<&str> {
if let Some((_, tail)) = url.split_once("://") {
return tail.split_once('/').map(|(_, rest)| rest);
}
if let Some((_, tail)) = url.split_once('@') {
return tail.split_once(':').map(|(_, rest)| rest);
}
None
}
pub fn git_info_from_counts(
branch: Option<String>,
changes: u32,
ahead: Option<u32>,
behind: Option<u32>,
github_repo_name: Option<String>,
) -> GitInfo {
GitInfo {
valid: true,
error: None,
current_branch_name: branch,
changes: Some(changes),
ahead,
behind,
github_repo_name,
raw_status: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_shortlog_sums_commits_and_counts_authors() {
let text =
" 42\tAda <ada@example.com>\n 7\tLin <lin@example.com>\n";
assert_eq!(parse_shortlog(text), (49, 2));
assert_eq!(parse_shortlog(""), (0, 0));
assert_eq!(parse_shortlog("no tab here\n"), (0, 0));
}
#[test]
fn parse_timestamps_takes_the_earliest_root_commit() {
assert_eq!(
parse_timestamps("1700000000\n1600000000\n"),
Some(1_600_000_000)
);
assert_eq!(parse_timestamps("1700000000"), Some(1_700_000_000));
assert_eq!(parse_timestamps(""), None);
assert_eq!(parse_timestamps("not a number"), None);
}
#[test]
fn parse_refs_separates_branches_from_tags() {
let text = "refs/heads/main\nrefs/heads/dev\nrefs/tags/v1.0\n";
assert_eq!(parse_refs(text), (2, 1));
assert_eq!(parse_refs(""), (0, 0));
assert_eq!(parse_refs("refs/remotes/origin/main\n"), (0, 0));
}
#[test]
fn parses_ssh_remote() {
assert_eq!(
parse_github_name("git@github.com:owner/repo.git", None),
Some("owner/repo".to_string())
);
}
#[test]
fn parses_https_remote() {
assert_eq!(
parse_github_name("https://github.com/owner/repo.git", None),
Some("owner/repo".to_string())
);
}
#[test]
fn strips_matching_username() {
assert_eq!(
parse_github_name(
"git@github.com:cgroening/hop.git",
Some("cgroening")
),
Some("hop".to_string())
);
}
#[test]
fn keeps_owner_when_username_differs() {
assert_eq!(
parse_github_name("https://github.com/other/hop", Some("me")),
Some("other/hop".to_string())
);
}
#[test]
fn none_for_unparseable() {
assert_eq!(parse_github_name("not-a-url", None), None);
assert_eq!(parse_github_name("", None), None);
}
}