use colored::Colorize;
use comfy_table::{Attribute, Cell, CellAlignment, Color, ContentArrangement, Table};
use regex::Regex;
use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Clone, Default)]
struct Commit {
author: String,
email: String,
date: String,
files: u32,
additions: u32,
deletions: u32,
}
#[derive(Debug, Clone, Default)]
struct AuthorStat {
commits: u32,
additions: u32,
deletions: u32,
lines: u32,
files: u32,
active_days: u32,
pct_commit: f64,
pct_added: f64,
pct_removed: f64,
pct_lines: f64,
pct_files: f64,
}
#[derive(Debug, Default, Clone)]
struct Total {
commits: u32,
additions: u32,
deletions: u32,
lines: u32,
files: u32,
active_days_sum: u32,
}
#[derive(Debug, Default)]
struct Options {
today: bool,
yesterday: bool,
week: bool,
month: bool,
year: bool,
since: Option<String>,
until: Option<String>,
all_branches: bool,
ignore_case: bool,
no_merges: bool,
verbose: bool,
range: Option<String>,
authors: Vec<String>,
}
#[derive(Debug, Default)]
struct ResolvedTime {
since: Option<String>,
until: Option<String>,
}
enum AuthorPattern {
Exact(String),
Email(String),
Glob(String),
Regex(String),
}
pub fn handle_stat(args: &[String]) -> i32 {
if args.iter().any(|a| a == "-h" || a == "--help") {
print_stat_help();
return 0;
}
let opts = match parse_args(args) {
Ok(o) => o,
Err(e) => {
eprintln!("{}: {}", "Error".red().bold(), e);
return 1;
}
};
if let Err(e) = check_conflicts(&opts) {
eprintln!("{}: {}", "Error".red().bold(), e);
return 1;
}
let time = match resolve_time(&opts) {
Ok(t) => t,
Err(e) => {
eprintln!("{}: {}", "Error".red().bold(), e);
return 1;
}
};
let commits = match fetch_commits(&opts, &time) {
Ok(c) => c,
Err(e) => {
eprintln!("{}: {}", "Error".red().bold(), e);
return 1;
}
};
if commits.is_empty() {
let title = format_title(&opts, None);
println!("{}", title);
println!();
println!("{}", "无提交".yellow().bold());
return 0;
}
let patterns = parse_author_patterns(&opts.authors, opts.ignore_case);
let filtered: Vec<&Commit> = if patterns.is_empty() {
commits.iter().collect()
} else {
commits.iter().filter(|c| matches_any(c, &patterns, opts.ignore_case)).collect()
};
if filtered.is_empty() && !opts.authors.is_empty() {
print_empty_for_authors(&opts, &patterns);
return 0;
}
let mut by_author: HashMap<String, AuthorStat> = HashMap::new();
let mut author_dates: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
for c in &filtered {
let entry = by_author.entry(c.author.clone()).or_default();
entry.commits += 1;
entry.additions += c.additions;
entry.deletions += c.deletions;
entry.lines += c.additions + c.deletions;
entry.files += c.files;
author_dates
.entry(c.author.clone())
.or_insert_with(std::collections::HashSet::new)
.insert(c.date.clone());
}
let mut active_days_sum = 0u32;
for (name, s) in &mut by_author {
if let Some(days) = author_dates.get(name) {
s.active_days = days.len() as u32;
active_days_sum += s.active_days;
}
}
let mut total = compute_total(&commits);
total.active_days_sum = active_days_sum;
let mut stats: Vec<(String, AuthorStat)> = by_author.into_iter().collect();
for (_, s) in &mut stats {
s.pct_commit = pct(s.commits, total.commits);
s.pct_added = pct(s.additions, total.additions);
s.pct_removed = pct(s.deletions, total.deletions);
s.pct_lines = pct(s.lines, total.lines);
s.pct_files = pct(s.files, total.files);
}
stats.sort_by(|a, b| b.1.commits.cmp(&a.1.commits));
let date_range = compute_date_range(&filtered);
render_table(&opts, &stats, &total, date_range);
0
}
fn parse_args(args: &[String]) -> Result<Options, String> {
let mut opts = Options::default();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
match arg.as_str() {
"-h" | "--help" => {}
"--today" => opts.today = true,
"--yesterday" => opts.yesterday = true,
"--week" => opts.week = true,
"--month" => opts.month = true,
"--year" => opts.year = true,
"-a" | "--all-branches" => opts.all_branches = true,
"-i" | "--ignore-case" => opts.ignore_case = true,
"--no-merges" => opts.no_merges = true,
"-v" | "--verbose" => opts.verbose = true,
"--since" => {
opts.since =
Some(args.get(i + 1).ok_or_else(|| "--since 需要参数".to_string())?.clone());
i += 1;
}
"--until" => {
opts.until =
Some(args.get(i + 1).ok_or_else(|| "--until 需要参数".to_string())?.clone());
i += 1;
}
s if s.starts_with("--since=") => opts.since = Some(s[8..].to_string()),
s if s.starts_with("--until=") => opts.until = Some(s[8..].to_string()),
s if s.starts_with('-') => return Err(format!("未知选项: {}", s)),
s if s.contains("..") => opts.range = Some(s.to_string()),
_ => opts.authors.push(arg.clone()),
}
i += 1;
}
Ok(opts)
}
fn check_conflicts(opts: &Options) -> Result<(), String> {
if (opts.since.is_some() || opts.until.is_some()) && opts.range.is_some() {
return Err("--since/--until 与 A..B range 互斥,请只使用一种".to_string());
}
let shortcuts = [opts.today, opts.yesterday, opts.week, opts.month, opts.year];
let count = shortcuts.iter().filter(|x| **x).count();
if count > 1 {
return Err("--today/--yesterday/--week/--month/--year 互斥,请只使用其中一个".to_string());
}
let shortcut_set = count > 0;
if shortcut_set && (opts.since.is_some() || opts.until.is_some()) {
return Err(
"--today/--yesterday/--week/--month/--year 不能与 --since/--until 同时使用".to_string()
);
}
Ok(())
}
fn resolve_time(opts: &Options) -> Result<ResolvedTime, String> {
let mut since = opts.since.clone();
let mut until = opts.until.clone();
if opts.today {
since = Some("00:00".to_string());
until = None;
} else if opts.yesterday {
since = Some("yesterday 00:00".to_string());
until = Some("today 00:00".to_string());
} else if opts.week {
since = Some("1 week ago".to_string());
until = None;
} else if opts.month {
since = Some("1 month ago".to_string());
until = None;
} else if opts.year {
since = Some("1 year ago".to_string());
until = None;
}
Ok(ResolvedTime { since, until })
}
fn fetch_commits(opts: &Options, time: &ResolvedTime) -> Result<Vec<Commit>, String> {
let range =
if let Some(r) = &opts.range { Some(resolve_range_with_fallback(r)?) } else { None };
let mut cmd = Command::new("git");
cmd.arg("log");
if let Some(r) = &range {
cmd.arg(r);
}
if opts.all_branches {
cmd.arg("--branches");
}
if opts.no_merges {
cmd.arg("--no-merges");
}
if let Some(since) = &time.since {
cmd.arg(format!("--since={}", since));
}
if let Some(until) = &time.until {
cmd.arg(format!("--until={}", until));
}
cmd.arg("--format=AUTHOR:%an|%ae|%as");
cmd.arg("--shortstat");
let output = cmd.output().map_err(|e| format!("执行 git 失败: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(rewrap_git_error(&stderr));
}
Ok(parse_log_output(&String::from_utf8_lossy(&output.stdout)))
}
fn resolve_range_with_fallback(range: &str) -> Result<String, String> {
let parts: Vec<&str> = range.split("..").collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(format!("无效的 range: {}", range));
}
let original = range.to_string();
let output = Command::new("git")
.args(&["log", "--oneline", range])
.output()
.map_err(|e| format!("执行 git 失败: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(rewrap_git_error(&stderr));
}
if !String::from_utf8_lossy(&output.stdout).trim().is_empty() {
return Ok(original);
}
let base_output = Command::new("git")
.args(&["merge-base", parts[0], parts[1]])
.output()
.map_err(|e| format!("执行 git 失败: {}", e))?;
let base = String::from_utf8_lossy(&base_output.stdout).trim().to_string();
if base.is_empty() {
return Err(format!("未找到提交或 range 无效: {}", range));
}
let new_range = format!("{}..{}", base, parts[1]);
eprintln!(
"{}: 使用 {} 作为分支点,显示 {} 的新提交",
"Hint".yellow().bold(),
base.cyan(),
parts[1].cyan()
);
Ok(new_range)
}
fn rewrap_git_error(stderr: &str) -> String {
if stderr.is_empty() {
return "git 命令失败".to_string();
}
format!("git 错误: {}", stderr)
}
fn parse_log_output(output: &str) -> Vec<Commit> {
let mut commits = Vec::new();
let mut current: Option<(String, String, String)> = None;
for line in output.lines() {
if let Some(rest) = line.strip_prefix("AUTHOR:") {
if let Some((author, email, date)) = current.take() {
commits.push(Commit { author, email, date, files: 0, additions: 0, deletions: 0 });
}
let parts: Vec<&str> = rest.splitn(3, '|').collect();
if parts.len() >= 3 {
current = Some((parts[0].to_string(), parts[1].to_string(), parts[2].to_string()));
}
} else if !line.trim().is_empty() {
if let Some((author, email, date)) = ¤t {
let (files, additions, deletions) = parse_shortstat(line);
commits.push(Commit {
author: author.clone(),
email: email.clone(),
date: date.clone(),
files,
additions,
deletions,
});
current = None;
}
}
}
if let Some((author, email, date)) = current {
commits.push(Commit { author, email, date, files: 0, additions: 0, deletions: 0 });
}
commits
}
fn parse_shortstat(line: &str) -> (u32, u32, u32) {
let mut files = 0;
let mut additions = 0;
let mut deletions = 0;
for part in line.split(',') {
let part = part.trim();
if part.contains("changed") {
files = extract_number(part);
} else if part.contains("insertion") {
additions = extract_number(part);
} else if part.contains("deletion") {
deletions = extract_number(part);
}
}
(files, additions, deletions)
}
fn extract_number(s: &str) -> u32 {
s.split_whitespace().find_map(|w| w.parse::<u32>().ok()).unwrap_or(0)
}
fn parse_author_patterns(args: &[String], _case_insensitive: bool) -> Vec<AuthorPattern> {
let mut patterns = Vec::new();
for arg in args {
if let Some(inner) = arg.strip_prefix("re:") {
if !inner.is_empty() && Regex::new(inner).is_ok() {
patterns.push(AuthorPattern::Regex(inner.to_string()));
continue;
}
}
if arg.contains('@') {
patterns.push(AuthorPattern::Email(arg.clone()));
continue;
}
if arg.contains('*') || arg.contains('?') {
patterns.push(AuthorPattern::Glob(arg.clone()));
continue;
}
patterns.push(AuthorPattern::Exact(arg.clone()));
}
patterns
}
fn matches_any(commit: &Commit, patterns: &[AuthorPattern], case_insensitive: bool) -> bool {
patterns.iter().any(|p| matches_pattern(p, commit, case_insensitive))
}
fn matches_pattern(pattern: &AuthorPattern, commit: &Commit, case_insensitive: bool) -> bool {
match pattern {
AuthorPattern::Exact(s) => {
if case_insensitive {
commit.author.eq_ignore_ascii_case(s)
} else {
commit.author == *s
}
}
AuthorPattern::Email(s) => {
if case_insensitive {
commit.email.eq_ignore_ascii_case(s)
} else {
commit.email == *s
}
}
AuthorPattern::Glob(g) => glob_match(g, &commit.author, case_insensitive),
AuthorPattern::Regex(r) => regex_match(r, &commit.author, case_insensitive),
}
}
fn glob_match(glob: &str, text: &str, case_insensitive: bool) -> bool {
let regex_str = glob_to_regex(glob);
let final_pattern = if case_insensitive { format!("(?i){}", regex_str) } else { regex_str };
match Regex::new(&final_pattern) {
Ok(re) => re.is_match(text),
Err(_) => false,
}
}
fn regex_match(pattern: &str, text: &str, case_insensitive: bool) -> bool {
let final_pattern =
if case_insensitive { format!("(?i){}", pattern) } else { pattern.to_string() };
match Regex::new(&final_pattern) {
Ok(re) => re.is_match(text),
Err(_) => false,
}
}
fn glob_to_regex(glob: &str) -> String {
let mut result = String::from("^");
for c in glob.chars() {
match c {
'*' => result.push_str(".*"),
'?' => result.push('.'),
'.' | '(' | ')' | '[' | ']' | '{' | '}' | '+' | '|' | '^' | '$' | '\\' => {
result.push('\\');
result.push(c);
}
_ => result.push(c),
}
}
result.push('$');
result
}
fn pattern_label(p: &AuthorPattern) -> String {
match p {
AuthorPattern::Exact(s) => s.clone(),
AuthorPattern::Email(s) => s.clone(),
AuthorPattern::Glob(s) => s.clone(),
AuthorPattern::Regex(s) => format!("re:{}", s),
}
}
fn render_table(
opts: &Options,
stats: &[(String, AuthorStat)],
total: &Total,
date_range: Option<(String, String)>,
) {
let title = format_title(opts, date_range);
println!("{}", title);
println!();
let mut table = build_table(opts.verbose);
for (name, s) in stats {
let active_days = if s.active_days == 0 { 1 } else { s.active_days };
let mut row = vec![
Cell::new(name.clone()),
Cell::new(s.commits.to_string()),
Cell::new(format!("{:.1}%", s.pct_commit)),
];
if opts.verbose {
row.push(Cell::new(s.active_days.to_string()));
}
row.extend(vec![
Cell::new(format!("+{}", s.additions)).fg(Color::Green),
Cell::new(format!("{:.1}%", s.pct_added)),
]);
if opts.verbose {
row.push(Cell::new(format!("+{:.0}", s.additions as f64 / active_days as f64)));
}
row.extend(vec![
Cell::new(format!("-{}", s.deletions)).fg(Color::Red),
Cell::new(format!("{:.1}%", s.pct_removed)),
]);
if opts.verbose {
row.push(Cell::new(format!("-{:.0}", s.deletions as f64 / active_days as f64)));
}
row.extend(vec![
Cell::new(s.lines.to_string()).fg(Color::Yellow),
Cell::new(format!("{:.1}%", s.pct_lines)),
]);
if opts.verbose {
row.push(Cell::new(format!("{:.0}", s.lines as f64 / active_days as f64)));
}
row.extend(vec![Cell::new(s.files.to_string()), Cell::new(format!("{:.1}%", s.pct_files))]);
table.add_row(row);
}
let total_days = if total.active_days_sum == 0 { 1 } else { total.active_days_sum };
let mut total_row = vec![
Cell::new("total").add_attribute(Attribute::Bold),
Cell::new(total.commits.to_string()).add_attribute(Attribute::Bold),
Cell::new("100.0%").add_attribute(Attribute::Bold),
];
if opts.verbose {
total_row.push(Cell::new(total.active_days_sum.to_string()).add_attribute(Attribute::Bold));
}
total_row.extend(vec![
Cell::new(format!("+{}", total.additions)).add_attribute(Attribute::Bold),
Cell::new("100.0%").add_attribute(Attribute::Bold),
]);
if opts.verbose {
total_row.push(
Cell::new(format!("+{:.0}", total.additions as f64 / total_days as f64))
.add_attribute(Attribute::Bold),
);
}
total_row.extend(vec![
Cell::new(format!("-{}", total.deletions)).add_attribute(Attribute::Bold),
Cell::new("100.0%").add_attribute(Attribute::Bold),
]);
if opts.verbose {
total_row.push(
Cell::new(format!("-{:.0}", total.deletions as f64 / total_days as f64))
.add_attribute(Attribute::Bold),
);
}
total_row.extend(vec![
Cell::new(total.lines.to_string()).add_attribute(Attribute::Bold),
Cell::new("100.0%").add_attribute(Attribute::Bold),
]);
if opts.verbose {
total_row.push(
Cell::new(format!("{:.0}", total.lines as f64 / total_days as f64))
.add_attribute(Attribute::Bold),
);
}
total_row.extend(vec![
Cell::new(total.files.to_string()).add_attribute(Attribute::Bold),
Cell::new("100.0%").add_attribute(Attribute::Bold),
]);
table.add_row(total_row);
let output = format!("{table}");
println!("{}", double_line_above_total(&output));
}
fn print_empty_for_authors(opts: &Options, patterns: &[AuthorPattern]) {
let title = format_title(opts, None);
println!("{}", title);
println!();
let mut table = build_table(opts.verbose);
for p in patterns {
let mut row = vec![Cell::new(pattern_label(p)), Cell::new("0"), Cell::new("0.0%")];
if opts.verbose {
row.push(Cell::new("0").fg(Color::DarkGrey));
}
row.extend(vec![Cell::new("+0").fg(Color::DarkGrey), Cell::new("0.0%")]);
if opts.verbose {
row.push(Cell::new("+0").fg(Color::DarkGrey));
}
row.extend(vec![Cell::new("-0").fg(Color::DarkGrey), Cell::new("0.0%")]);
if opts.verbose {
row.push(Cell::new("-0").fg(Color::DarkGrey));
}
row.extend(vec![Cell::new("0").fg(Color::DarkGrey), Cell::new("0.0%")]);
if opts.verbose {
row.push(Cell::new("0").fg(Color::DarkGrey));
}
row.extend(vec![Cell::new("0"), Cell::new("0.0%")]);
table.add_row(row);
}
println!("{table}");
}
fn double_line_above_total(table_output: &str) -> String {
let lines: Vec<&str> = table_output.lines().collect();
let mut result = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
let next_is_total = lines.get(i + 1).map(|l| l.contains(" total ")).unwrap_or(false);
if next_is_total && line.starts_with('├') && line.ends_with('┤') {
result.push(line.replace('╌', "═"));
} else {
result.push(line.to_string());
}
}
result.join("\n")
}
fn build_table(verbose: bool) -> Table {
let mut table = Table::new();
let mut headers = vec![
Cell::new("author").set_alignment(CellAlignment::Left),
Cell::new("commits").set_alignment(CellAlignment::Right),
Cell::new("commits%").set_alignment(CellAlignment::Right),
];
if verbose {
headers.push(Cell::new("days").set_alignment(CellAlignment::Right));
}
headers.extend(vec![
Cell::new("added").set_alignment(CellAlignment::Right),
Cell::new("added%").set_alignment(CellAlignment::Right),
]);
if verbose {
headers.push(Cell::new("added/day").set_alignment(CellAlignment::Right));
}
headers.extend(vec![
Cell::new("removed").set_alignment(CellAlignment::Right),
Cell::new("removed%").set_alignment(CellAlignment::Right),
]);
if verbose {
headers.push(Cell::new("removed/day").set_alignment(CellAlignment::Right));
}
headers.extend(vec![
Cell::new("lines").set_alignment(CellAlignment::Right),
Cell::new("lines%").set_alignment(CellAlignment::Right),
]);
if verbose {
headers.push(Cell::new("lines/day").set_alignment(CellAlignment::Right));
}
headers.extend(vec![
Cell::new("files").set_alignment(CellAlignment::Right),
Cell::new("files%").set_alignment(CellAlignment::Right),
]);
table
.load_preset(comfy_table::presets::UTF8_FULL)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(headers);
table
}
fn format_title(opts: &Options, date_range: Option<(String, String)>) -> String {
let scope = if opts.all_branches { "全部本地分支" } else { "当前分支" };
let range_desc = if let Some(r) = &opts.range {
format!("range {}", r)
} else {
let since_desc = if opts.today {
"今日".to_string()
} else if opts.yesterday {
"昨日".to_string()
} else if opts.week {
"过去 7 天".to_string()
} else if opts.month {
"过去 30 天".to_string()
} else if opts.year {
"过去 1 年".to_string()
} else {
match (&opts.since, &opts.until) {
(Some(s), Some(u)) => format!("{} ~ {}", s, u),
(Some(s), None) => format!("{} 至今", s),
(None, Some(u)) => format!("至 {}", u),
(None, None) => "全部历史".to_string(),
}
};
since_desc
};
let base = format!("代码变更统计 ({} · {})", range_desc, scope);
if let Some((first, last)) = date_range {
format!("{} {} ~ {}", base.cyan().bold(), first.dimmed(), last.dimmed())
} else {
base.cyan().bold().to_string()
}
}
fn compute_date_range(commits: &[&Commit]) -> Option<(String, String)> {
let mut dates: Vec<&str> = commits.iter().map(|c| c.date.as_str()).collect();
dates.sort();
if dates.is_empty() {
None
} else {
Some((dates.first().unwrap().to_string(), dates.last().unwrap().to_string()))
}
}
fn compute_total(commits: &[Commit]) -> Total {
let mut total = Total::default();
for c in commits {
total.commits += 1;
total.additions += c.additions;
total.deletions += c.deletions;
total.lines += c.additions + c.deletions;
total.files += c.files;
}
total
}
fn pct(part: u32, whole: u32) -> f64 {
if whole == 0 { 0.0 } else { (part as f64 / whole as f64) * 100.0 }
}
pub fn print_stat_help() {
println!("{}", "Git Stat - 作者代码变更统计".cyan().bold());
println!();
println!("{}", "USAGE:".yellow().bold());
println!(" {} [OPTIONS] [AUTHORS...] [RANGE]", "g stat".green());
println!();
println!("{}", "OPTIONS:".yellow().bold());
println!(" --today 今日 00:00 至今");
println!(" --yesterday 昨日 00:00 ~ 今日 00:00");
println!(" --week 过去 7 天");
println!(" --month 过去 30 天");
println!(" --year 过去 1 年");
println!(" --since <DATE> 起始时间(git 原生语法)");
println!(" --until <DATE> 截止时间(默认 HEAD)");
println!(" -a, --all-branches 所有本地分支(不含 remote)");
println!(" -i, --ignore-case 作者匹配大小写不敏感");
println!(" --no-merges 排除 merge commit");
println!(" -v, --verbose 显示日均新增/删除/总变动行数");
println!(" -h, --help 显示帮助");
println!();
println!("{}", "AUTHOR 匹配语法:".yellow().bold());
println!(" alice 精确匹配 Author name(大小写敏感)");
println!(" alice@x.com 精确匹配 Author email");
println!(" alic* 通配符匹配 name(* 任意序列,? 单字符)");
println!(" re:^alic 正则匹配 name");
println!();
println!("{}", "RANGE:".yellow().bold());
println!(" A..B commit 或分支 range(空时自动 merge-base 回退)");
println!(" 注:与 --since/--until 互斥");
println!();
println!("{}", "EXAMPLES:".yellow().bold());
println!(" {} # 全部历史,当前分支", "g stat".green());
println!(" {} --month # 过去 30 天", "g stat".green());
println!(" {} --year # 过去 1 年", "g stat".green());
println!(" {} alice # 仅 alice", "g stat".green());
println!(" {} alice@x.com # 按邮箱", "g stat".green());
println!(" {} \"alic*\" # 通配", "g stat".green());
println!(" {} -i ALICE # 大小写不敏感", "g stat".green());
println!(" {} -a # 所有本地分支", "g stat".green());
println!(" {} main..HEAD # 分支对比", "g stat".green());
println!(" {} 're:^alic' # 正则", "g stat".green());
}