xbp 10.15.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! "What did I get done" — generate a Markdown report of your git commits across repos.
//!
//! Discovers git repos under a root path, collects commits by the current user in a
//! time range, optionally summarizes per-repo with OpenRouter, and writes a grouped MD file.

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>,
}

/// Run the done command.
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 && r.summary.is_some() {
            lines.push(r.summary.as_ref().unwrap().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")
}