use chrono::Utc;
use regex::Regex;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tokio::process::Command;
const OPENROUTER_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
const DEFAULT_MODEL: &str = "openai/gpt-4o-mini";
#[derive(Debug)]
struct Commit {
hash: String,
date: String,
subject: String,
insertions: u32,
deletions: u32,
}
#[derive(Debug)]
struct RepoResult {
repo_name: String,
commits: Vec<Commit>,
summary: Option<String>,
}
pub async fn run_done(
root: Option<PathBuf>,
since: String,
output: Option<PathBuf>,
no_ai: bool,
recursive: bool,
exclude: Vec<String>,
) -> Result<(), String> {
let root = root
.unwrap_or_else(|| {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if cwd.join(".git").is_dir() {
cwd.parent().unwrap_or(&cwd).to_path_buf()
} else {
cwd
}
})
.canonicalize()
.map_err(|e| format!("Invalid root path: {}", e))?;
if !root.is_dir() {
return Err(format!("Root is not a directory: {}", root.display()));
}
let repos = find_git_repos(&root, recursive);
let excluded: HashSet<_> = exclude.into_iter().collect();
let repos: Vec<_> = repos
.into_iter()
.filter(|p| {
!excluded.contains(
&p.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
)
})
.collect();
if repos.is_empty() {
return Err("No git repos found under root".to_string());
}
let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default();
let use_ai = !no_ai && !api_key.is_empty();
let mut author_email: Option<String> = None;
let mut results = Vec::new();
for repo_path in &repos {
if author_email.is_none() {
author_email = get_git_author_email(repo_path).await;
}
let commits = get_commits(repo_path, &since, author_email.as_deref()).await?;
let repo_name = repo_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let summary = if use_ai && !commits.is_empty() {
summarize_with_openrouter(&repo_name, &commits, &api_key, DEFAULT_MODEL).await
} else {
None
};
results.push(RepoResult {
repo_name,
commits,
summary,
});
}
let results_with_commits: Vec<_> = results
.into_iter()
.filter(|r| !r.commits.is_empty())
.collect();
if results_with_commits.is_empty() {
println!("No commits in the given time window for any repo.");
return Ok(());
}
let total_commits: usize = results_with_commits.iter().map(|r| r.commits.len()).sum();
let total_ins: u32 = results_with_commits
.iter()
.flat_map(|r| r.commits.iter().map(|c| c.insertions))
.sum();
let total_del: u32 = results_with_commits
.iter()
.flat_map(|r| r.commits.iter().map(|c| c.deletions))
.sum();
let date_range = format!("{} (since: {})", Utc::now().format("%Y-%m-%d"), since);
let md = render_md(
&results_with_commits,
&since,
&date_range,
use_ai,
total_commits,
total_ins,
total_del,
);
let out_path = output.unwrap_or_else(|| {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(format!(
"what_did_i_get_done_{}.md",
Utc::now().format("%Y-%m-%d_%H%M")
))
});
std::fs::write(&out_path, md)
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
println!(
"Wrote {} repo(s) to {}",
results_with_commits.len(),
out_path.display()
);
Ok(())
}
fn find_git_repos(root: &Path, recursive: bool) -> Vec<PathBuf> {
let mut repos = Vec::new();
if recursive {
for entry in walkdir::WalkDir::new(root)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
name != "node_modules" && name != "target" && name != ".next"
})
.flatten()
{
let path = entry.path();
if path.is_dir() && path.file_name().map(|n| n == ".git").unwrap_or(false) {
if let Some(parent) = path.parent() {
repos.push(parent.to_path_buf());
}
}
}
} else if let Ok(entries) = std::fs::read_dir(root) {
for e in entries.flatten() {
let path = e.path();
if path.is_dir() && path.join(".git").is_dir() {
repos.push(path);
}
}
}
repos.sort();
repos.dedup();
repos
}
async fn get_git_author_email(repo_path: &Path) -> Option<String> {
let output = Command::new("git")
.args(["config", "user.email"])
.current_dir(repo_path)
.output()
.await
.ok()?;
if output.status.success() {
let email = String::from_utf8_lossy(&output.stdout).trim().to_string();
if email.is_empty() {
None
} else {
Some(email)
}
} else {
None
}
}
async fn get_commits(
repo_path: &Path,
since: &str,
author_email: Option<&str>,
) -> Result<Vec<Commit>, String> {
let mut cmd = Command::new("git");
cmd.args([
"log",
&format!("--since={}", since),
"--no-merges",
"--format=%h %ad %s",
"--date=short",
"--shortstat",
])
.current_dir(repo_path);
if let Some(email) = author_email {
cmd.args(["--author", email]);
}
let output = cmd
.output()
.await
.map_err(|e| format!("git log failed: {}", e))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).to_string());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let lines: Vec<&str> = stdout.lines().collect();
let mut commits = Vec::new();
let mut i = 0;
let commit_re = Regex::new(r"^([0-9a-f]{6,}) (\d{4}-\d{2}-\d{2}) (.+)$").unwrap();
let shortstat_re = Regex::new(r"(\d+)\s+insertion").unwrap();
let shortstat_del = Regex::new(r"(\d+)\s+deletion").unwrap();
while i < lines.len() {
let line = lines[i].trim();
i += 1;
if line.is_empty() {
continue;
}
let Some(caps) = commit_re.captures(line) else {
continue;
};
let hash = caps
.get(1)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
let date = caps
.get(2)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
let subject = caps
.get(3)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
let (insertions, deletions) = {
let mut ins = 0u32;
let mut del = 0u32;
while i < lines.len() && lines[i].trim().is_empty() {
i += 1;
}
if i < lines.len() {
let stat = lines[i];
if stat.contains("insertion")
|| stat.contains("deletion")
|| stat.contains("file changed")
{
if let Some(m) = shortstat_re.captures(stat) {
ins = m.get(1).and_then(|g| g.as_str().parse().ok()).unwrap_or(0);
}
if let Some(m) = shortstat_del.captures(stat) {
del = m.get(1).and_then(|g| g.as_str().parse().ok()).unwrap_or(0);
}
i += 1;
}
}
(ins, del)
};
commits.push(Commit {
hash,
date,
subject,
insertions,
deletions,
});
}
Ok(commits)
}
async fn summarize_with_openrouter(
repo_name: &str,
commits: &[Commit],
api_key: &str,
model: &str,
) -> Option<String> {
let lines: Vec<String> = commits
.iter()
.map(|c| format!("- {} {} {}", c.hash, c.date, c.subject))
.collect();
let commit_list = lines.join("\n");
let body = serde_json::json!({
"model": model,
"messages": [{
"role": "user",
"content": format!(
"Summarize what was done in this repo in the given time window in 2–3 concise, information-dense sentences. Focus on substantial behavior or architecture changes. Do not list every commit.\n\nRepo: {}\n\nCommits:\n{}\n\nSummary:",
repo_name,
commit_list
)
}]
});
let client = reqwest::Client::new();
let resp = client
.post(OPENROUTER_URL)
.header("Authorization", format!("Bearer {}", api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let data: serde_json::Value = resp.json().await.ok()?;
let content = data
.get("choices")?
.as_array()?
.first()?
.get("message")?
.get("content")?
.as_str()?;
Some(content.trim().to_string())
}
fn render_md(
results: &[RepoResult],
since: &str,
date_range: &str,
use_ai: bool,
total_commits: usize,
total_ins: u32,
total_del: u32,
) -> String {
let mut lines: Vec<String> = vec![
"# What did I get done".into(),
"".into(),
format!("**Time window:** {} ", since),
format!("**Date range:** {} ", date_range),
format!("**Total commits:** {}", total_commits),
"".into(),
"---".into(),
"".into(),
];
let mut cumulative = 0;
let mut table_rows: Vec<(String, usize, u32, u32, u32)> = Vec::new();
for r in results {
let n = r.commits.len();
let repo_ins: u32 = r.commits.iter().map(|c| c.insertions).sum();
let repo_del: u32 = r.commits.iter().map(|c| c.deletions).sum();
let repo_lines = repo_ins + repo_del;
table_rows.push((r.repo_name.clone(), n, repo_ins, repo_del, repo_lines));
lines.push(format!(
"## {} ({} commit{}, +{} −{} lines)",
r.repo_name,
n,
if n == 1 { "" } else { "s" },
repo_ins,
repo_del
));
lines.push("".into());
if use_ai {
if let Some(summary) = &r.summary {
lines.push(summary.clone());
lines.push("".into());
}
}
lines.push("**Commits:**".into());
lines.push("".into());
for c in &r.commits {
cumulative += 1;
let line_info = if c.insertions > 0 || c.deletions > 0 {
format!(" (+{} −{})", c.insertions, c.deletions)
} else {
String::new()
};
lines.push(format!(
"- {}. `{}` {} — {}{}",
cumulative, c.hash, c.date, c.subject, line_info
));
}
lines.push("".into());
}
lines.push("---".into());
lines.push("".into());
lines.push("## Summary".into());
lines.push("".into());
lines.push("| Repo | Commits | +Lines | −Lines | Total lines |".into());
lines.push("|------|---------|--------|--------|-------------|".into());
for (repo_name, n, ins, del, total) in &table_rows {
lines.push(format!(
"| {} | {} | {} | {} | {} |",
repo_name, n, ins, del, total
));
}
lines.push(format!(
"| **Total** | **{}** | **{}** | **{}** | **{}** |",
total_commits,
total_ins,
total_del,
total_ins + total_del
));
lines.push("".into());
lines.join("\n")
}