From 329fd76384dc89a8c35394e3aa875f7653d658eb Mon Sep 17 00:00:00 2001 From: John Wiegley Date: Thu, 19 Feb 2026 16:16:15 -0800 Subject: [PATCH 1/4] feat(synopsis): add AI-generated commit narrative synopses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git commits capture *what* changed (the diff) and a brief *why* (the commit message), but they lose the rich context of *how the developer got there*. When AI-assisted tools like Claude Code are used, there is often a conversational trail — hypotheses explored, approaches debated, dead ends encountered, design tradeoffs weighed — that evaporates the moment the commit is made. This feature adds `git ai synopsis`, an opt-in command that generates a narrative, blog-article-style document for any commit. Future readers of the code get the full story: not just the diff, but the thinking behind it. Three input sources are collected and sent to the Anthropic Claude API: 1. **AI conversation context** — the Claude Code JSONL session file for the repository is located automatically under `~/.claude/projects//`, parsed, and filtered to exchanges within a configurable time window (default: 60 min). Conversation loading is non-fatal; if it fails the synopsis is generated from the diff and commit message alone. 2. **The diff** — `git show --stat` and `git show -U` are run against the target commit to produce a stat summary and a unified diff with expanded context (default: 10 lines). Large diffs are truncated to stay within the model's context window. 3. **The commit message** — retrieved via `git log -1 --format=%B`. A structured prompt instructs Claude to write a technical blog post with six sections: TL;DR, Background and Motivation, The Journey, The Solution, Key Files Changed, and Reflections. Target length is configurable (brief / standard / detailed). The generated synopsis is stored as a git note under `refs/notes/ai-synopsis`, using the same stdin-piped `git notes add` pattern already used by the authorship tracking system. Notes can be pushed and pulled alongside the repository. ``` git ai synopsis generate [--commit ] [--model ] [--api-key ] [--length brief|standard|detailed] [--conversation ] [--no-conversation] [--notes-ref ] [--dry-run] git ai synopsis show [] # default: HEAD git ai synopsis list ``` - `ANTHROPIC_API_KEY` or `GIT_AI_SYNOPSIS_API_KEY` — API key - `GIT_AI_SYNOPSIS_MODEL` — model override (default: claude-opus-4-6) - `GIT_AI_SYNOPSIS=1` — enable auto-generation on every commit ``` src/synopsis/ types.rs — Synopsis, SynopsisMetadata, ConversationLog, DiffBundle, ... config.rs — SynopsisConfig with env-var defaults conversation.rs — Claude Code JSONL parser and time-window filter collector.rs — diff, commit message, and conversation collection generator.rs — Anthropic Messages API call and prompt construction storage.rs — git notes read/write under refs/notes/ai-synopsis commands.rs — generate, show, list subcommand handlers ``` Co-Authored-By: Claude Sonnet 4.6 --- src/commands/git_ai_handlers.rs | 7 + src/commands/mod.rs | 1 + src/commands/synopsis.rs | 2 + src/lib.rs | 1 + src/main.rs | 1 + src/synopsis/collector.rs | 259 ++++++++++++++++++++++ src/synopsis/commands.rs | 378 ++++++++++++++++++++++++++++++++ src/synopsis/config.rs | 81 +++++++ src/synopsis/conversation.rs | 355 ++++++++++++++++++++++++++++++ src/synopsis/generator.rs | 240 ++++++++++++++++++++ src/synopsis/mod.rs | 7 + src/synopsis/storage.rs | 88 ++++++++ src/synopsis/types.rs | 71 ++++++ 13 files changed, 1491 insertions(+) create mode 100644 src/commands/synopsis.rs create mode 100644 src/synopsis/collector.rs create mode 100644 src/synopsis/commands.rs create mode 100644 src/synopsis/config.rs create mode 100644 src/synopsis/conversation.rs create mode 100644 src/synopsis/generator.rs create mode 100644 src/synopsis/mod.rs create mode 100644 src/synopsis/storage.rs create mode 100644 src/synopsis/types.rs diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 99c153e94..a1c9cc14e 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -160,6 +160,9 @@ pub fn handle_git_ai(args: &[String]) { "continue" => { commands::continue_session::handle_continue(&args[1..]); } + "synopsis" => { + commands::synopsis::handle_synopsis(&args[1..]); + } #[cfg(debug_assertions)] "show-transcript" => { handle_show_transcript(&args[1..]); @@ -260,6 +263,10 @@ fn print_help() { eprintln!(" --launch Launch agent CLI with restored context"); eprintln!(" --clipboard Copy context to system clipboard"); eprintln!(" --json Output context as structured JSON"); + eprintln!(" synopsis Generate AI-powered narrative synopses for commits"); + eprintln!(" generate Generate a synopsis for a commit (default: HEAD)"); + eprintln!(" show [] Show the stored synopsis for a commit"); + eprintln!(" list List all commits with synopses"); eprintln!(" login Authenticate with Git AI"); eprintln!(" logout Clear stored credentials"); eprintln!(" version, -v, --version Print the git-ai version"); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f9f94cc6f..b2cff4ed8 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -27,4 +27,5 @@ pub mod show_prompt; pub mod squash_authorship; pub mod status; pub mod sync_prompts; +pub mod synopsis; pub mod upgrade; diff --git a/src/commands/synopsis.rs b/src/commands/synopsis.rs new file mode 100644 index 000000000..148c51c73 --- /dev/null +++ b/src/commands/synopsis.rs @@ -0,0 +1,2 @@ +/// Re-export the synopsis command handler from the synopsis module. +pub use crate::synopsis::commands::handle_synopsis; diff --git a/src/lib.rs b/src/lib.rs index 757a94694..5e6e7a62b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,4 +11,5 @@ pub mod mdm; pub mod metrics; pub mod observability; pub mod repo_url; +pub mod synopsis; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 8abc7a0ff..913c1b8d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod mdm; mod metrics; mod observability; mod repo_url; +mod synopsis; mod utils; use clap::Parser; diff --git a/src/synopsis/collector.rs b/src/synopsis/collector.rs new file mode 100644 index 000000000..795f1ba89 --- /dev/null +++ b/src/synopsis/collector.rs @@ -0,0 +1,259 @@ +use crate::error::GitAiError; +use crate::git::repository::{Repository, exec_git}; +use crate::synopsis::config::{ConversationSourceKind, SynopsisConfig}; +use crate::synopsis::conversation::{ + filter_by_time_window, find_claude_code_conversation, parse_claude_code_jsonl, +}; +use crate::synopsis::types::{DiffBundle, SynopsisInput}; +use std::path::Path; + +/// Collect the diff for the given commit SHA against its parent(s). +/// +/// If `commit_sha` is `HEAD` or a bare SHA, we run `git show --stat` and +/// `git show -U` against that commit. For the staged index use-case +/// (before a commit exists) callers should pass the literal string `"--cached"`. +pub fn collect_diff( + repo: &Repository, + commit_sha: &str, + context_lines: usize, +) -> Result { + // Stat summary + let stat_summary = { + let mut args = repo.global_args_for_exec(); + if commit_sha == "--cached" { + args.extend(["diff".into(), "--cached".into(), "--stat".into()]); + } else { + args.extend([ + "show".into(), + "--stat".into(), + "--format=".into(), + commit_sha.to_string(), + ]); + } + let output = exec_git(&args)?; + String::from_utf8(output.stdout) + .map_err(GitAiError::FromUtf8Error)? + .trim() + .to_string() + }; + + // Unified diff + let unified_diff = { + let mut args = repo.global_args_for_exec(); + let context_flag = format!("-U{}", context_lines); + if commit_sha == "--cached" { + args.extend(["diff".into(), "--cached".into(), context_flag]); + } else { + args.extend([ + "show".into(), + "--format=".into(), + context_flag, + commit_sha.to_string(), + ]); + } + let output = exec_git(&args)?; + String::from_utf8(output.stdout) + .map_err(GitAiError::FromUtf8Error)? + .trim() + .to_string() + }; + + // Parse files_changed, insertions, deletions from the stat summary + let (files_changed, insertions, deletions) = parse_stat_summary(&stat_summary); + + Ok(DiffBundle { + stat_summary, + unified_diff, + files_changed, + insertions, + deletions, + }) +} + +/// Parse the trailing summary line of `git diff --stat` / `git show --stat`. +/// +/// The line looks like: +/// ` 3 files changed, 45 insertions(+), 2 deletions(-)` +fn parse_stat_summary(stat: &str) -> (usize, usize, usize) { + let mut files = 0usize; + let mut ins = 0usize; + let mut del = 0usize; + + for line in stat.lines().rev() { + let line = line.trim(); + if !line.contains("changed") { + continue; + } + // Extract numbers preceding known keywords + for token in line.split(',') { + let token = token.trim(); + let digits: String = token.chars().take_while(|c| c.is_ascii_digit()).collect(); + let n: usize = digits.parse().unwrap_or(0); + if token.contains("file") { + files = n; + } else if token.contains("insertion") { + ins = n; + } else if token.contains("deletion") { + del = n; + } + } + break; + } + + (files, ins, del) +} + +/// Retrieve the commit message for a given commit SHA. +pub fn collect_commit_message(commit_sha: &str, repo: &Repository) -> Result { + let mut args = repo.global_args_for_exec(); + args.extend([ + "log".into(), + "-1".into(), + "--format=%B".into(), + commit_sha.to_string(), + ]); + let output = exec_git(&args)?; + let msg = String::from_utf8(output.stdout) + .map_err(GitAiError::FromUtf8Error)? + .trim() + .to_string(); + Ok(msg) +} + +/// Retrieve the commit author as `"Name "` for a given commit SHA. +fn collect_commit_author(commit_sha: &str, repo: &Repository) -> Result { + let mut args = repo.global_args_for_exec(); + args.extend([ + "log".into(), + "-1".into(), + "--format=%an <%ae>".into(), + commit_sha.to_string(), + ]); + let output = exec_git(&args)?; + let author = String::from_utf8(output.stdout) + .map_err(GitAiError::FromUtf8Error)? + .trim() + .to_string(); + Ok(author) +} + +/// Resolve the working directory of the repository to a `Path`. +fn repo_work_dir(repo: &Repository) -> Option { + repo.workdir().ok() +} + +/// Collect all inputs required to generate a synopsis. +/// +/// If conversation loading fails, a warning is printed and the synopsis is +/// generated without conversation context (non-fatal). +pub fn collect_input( + repo: &Repository, + commit_sha: &str, + config: &SynopsisConfig, + conversation_path_override: Option<&str>, +) -> Result { + let diff = collect_diff(repo, commit_sha, config.diff_context_lines)?; + let commit_message = collect_commit_message(commit_sha, repo)?; + let author = collect_commit_author(commit_sha, repo).unwrap_or_else(|_| "Unknown".to_string()); + + let conversation = load_conversation(repo, config, conversation_path_override); + + Ok(SynopsisInput { + conversation, + diff, + commit_message, + commit_sha: commit_sha.to_string(), + author, + }) +} + +/// Attempt to load a conversation log, returning `None` on any failure. +fn load_conversation( + repo: &Repository, + config: &SynopsisConfig, + conversation_path_override: Option<&str>, +) -> Option { + if config.conversation_source == ConversationSourceKind::None { + return None; + } + + // Determine the JSONL file path + let jsonl_path: std::path::PathBuf = if let Some(override_path) = conversation_path_override { + std::path::PathBuf::from(override_path) + } else if let Some(explicit) = &config.conversation_path { + std::path::PathBuf::from(explicit) + } else if config.conversation_source == ConversationSourceKind::Auto + || config.conversation_source == ConversationSourceKind::ClaudeCode + { + let workdir = repo_work_dir(repo)?; + match find_claude_code_conversation(&workdir) { + Some(p) => p, + None => { + eprintln!( + "[synopsis] No Claude Code conversation found in ~/.claude/projects/. \ + Generating synopsis without conversation context." + ); + return None; + } + } + } else { + return None; + }; + + let path: &Path = &jsonl_path; + match parse_claude_code_jsonl(path) { + Ok(log) => { + let filtered = filter_by_time_window(&log, config.conversation_window_minutes); + if filtered.exchanges.is_empty() { + eprintln!( + "[synopsis] Conversation loaded but no exchanges fall within the \ + {}-minute window. Generating synopsis without conversation context.", + config.conversation_window_minutes + ); + None + } else { + Some(filtered) + } + } + Err(e) => { + eprintln!( + "[synopsis] Warning: Failed to parse conversation file {}: {}. \ + Generating synopsis without conversation context.", + path.display(), + e + ); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_stat_summary_all_fields() { + let stat = " 3 files changed, 45 insertions(+), 2 deletions(-)"; + let (f, i, d) = parse_stat_summary(stat); + assert_eq!(f, 3); + assert_eq!(i, 45); + assert_eq!(d, 2); + } + + #[test] + fn test_parse_stat_summary_no_deletions() { + let stat = " 1 file changed, 10 insertions(+)"; + let (f, i, d) = parse_stat_summary(stat); + assert_eq!(f, 1); + assert_eq!(i, 10); + assert_eq!(d, 0); + } + + #[test] + fn test_parse_stat_summary_empty() { + let (f, i, d) = parse_stat_summary(""); + assert_eq!(f, 0); + assert_eq!(i, 0); + assert_eq!(d, 0); + } +} diff --git a/src/synopsis/commands.rs b/src/synopsis/commands.rs new file mode 100644 index 000000000..8fd209f69 --- /dev/null +++ b/src/synopsis/commands.rs @@ -0,0 +1,378 @@ +use crate::git::find_repository; +use crate::synopsis::collector::collect_input; +use crate::synopsis::config::{ConversationSourceKind, SynopsisConfig, TargetLength}; +use crate::synopsis::generator::{build_synopsis_prompt, estimate_input_tokens, generate_synopsis}; +use crate::synopsis::storage::{list_synopses, retrieve_synopsis, store_synopsis}; +use crate::synopsis::types::{Synopsis, SynopsisMetadata}; +use chrono::Utc; + +/// Main entry point for the `git-ai synopsis` subcommand. +pub fn handle_synopsis(args: &[String]) { + if args.is_empty() { + print_synopsis_help(); + return; + } + + match args[0].as_str() { + "generate" => handle_generate(&args[1..]), + "show" => handle_show(&args[1..]), + "list" => handle_list(&args[1..]), + "help" | "--help" | "-h" => print_synopsis_help(), + unknown => { + eprintln!("Unknown synopsis subcommand: {}", unknown); + print_synopsis_help(); + std::process::exit(1); + } + } +} + +/// Parse common flags shared between subcommands. +struct CommonFlags { + commit: Option, + model: Option, + api_key: Option, + notes_ref: Option, + conversation: Option, + no_conversation: bool, + length: Option, + dry_run: bool, +} + +impl CommonFlags { + fn parse(args: &[String]) -> (Self, Vec) { + let mut commit = None; + let mut model = None; + let mut api_key = None; + let mut notes_ref = None; + let mut conversation = None; + let mut no_conversation = false; + let mut length = None; + let mut dry_run = false; + let mut remaining = Vec::new(); + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--commit" | "-c" if i + 1 < args.len() => { + commit = Some(args[i + 1].clone()); + i += 2; + } + "--model" | "-m" if i + 1 < args.len() => { + model = Some(args[i + 1].clone()); + i += 2; + } + "--api-key" if i + 1 < args.len() => { + api_key = Some(args[i + 1].clone()); + i += 2; + } + "--notes-ref" if i + 1 < args.len() => { + notes_ref = Some(args[i + 1].clone()); + i += 2; + } + "--conversation" if i + 1 < args.len() => { + conversation = Some(args[i + 1].clone()); + i += 2; + } + "--no-conversation" => { + no_conversation = true; + i += 1; + } + "--length" if i + 1 < args.len() => { + length = Some(args[i + 1].clone()); + i += 2; + } + "--dry-run" => { + dry_run = true; + i += 1; + } + _ => { + remaining.push(args[i].clone()); + i += 1; + } + } + } + + ( + CommonFlags { + commit, + model, + api_key, + notes_ref, + conversation, + no_conversation, + length, + dry_run, + }, + remaining, + ) + } +} + +fn handle_generate(args: &[String]) { + let (flags, _remaining) = CommonFlags::parse(args); + + // Build configuration + let mut config = SynopsisConfig::default(); + + if let Some(model) = flags.model { + config.model = model; + } + if let Some(key) = flags.api_key { + config.api_key = Some(key); + } + if let Some(ref_name) = flags.notes_ref { + config.notes_ref = ref_name; + } + if flags.no_conversation { + config.conversation_source = ConversationSourceKind::None; + } + if let Some(ref conv_path) = flags.conversation { + config.conversation_path = Some(conv_path.clone()); + } + if let Some(ref length_str) = flags.length { + config.target_length = match length_str.as_str() { + "brief" => TargetLength::Brief, + "detailed" => TargetLength::Detailed, + _ => TargetLength::Standard, + }; + } + + // Check API key before doing any expensive work (but allow dry-run without a key) + if config.api_key.is_none() && !flags.dry_run { + eprintln!("Error: No API key found. Set ANTHROPIC_API_KEY or use --api-key ."); + std::process::exit(1); + } + + // Find the repository + let repo = match find_repository(&Vec::::new()) { + Ok(r) => r, + Err(e) => { + eprintln!("Failed to find git repository: {}", e); + std::process::exit(1); + } + }; + + // Resolve commit SHA + let commit_sha = resolve_commit_sha(flags.commit.as_deref(), &repo); + + eprintln!( + "[synopsis] Collecting inputs for commit {}...", + &commit_sha[..8.min(commit_sha.len())] + ); + + let input = match collect_input(&repo, &commit_sha, &config, flags.conversation.as_deref()) { + Ok(i) => i, + Err(e) => { + eprintln!("Failed to collect synopsis inputs: {}", e); + std::process::exit(1); + } + }; + + if flags.dry_run { + let prompt = build_synopsis_prompt(&input, &config); + let tokens = estimate_input_tokens(&prompt); + println!( + "--- Dry run: synopsis prompt ({} estimated tokens) ---", + tokens + ); + println!("{}", prompt); + return; + } + + eprintln!( + "[synopsis] Generating synopsis using model {}...", + config.model + ); + + let content = match generate_synopsis(&input, &config) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to generate synopsis: {}", e); + std::process::exit(1); + } + }; + + let word_count = content.split_whitespace().count(); + let prompt = build_synopsis_prompt(&input, &config); + let input_tokens_estimate = estimate_input_tokens(&prompt); + + let metadata = SynopsisMetadata { + commit_sha: commit_sha.clone(), + date: Utc::now(), + author: input.author.clone(), + model: config.model.clone(), + version: 1, + word_count, + input_tokens_estimate, + conversation_source: input.conversation.as_ref().map(|c| c.source_kind.clone()), + conversation_window_secs: input + .conversation + .as_ref() + .map(|_| config.conversation_window_minutes * 60), + files_changed: input.diff.files_changed, + }; + + let synopsis = Synopsis { + metadata, + content: content.clone(), + }; + + // Store as a git note + match store_synopsis(&repo, &synopsis, &config.notes_ref) { + Ok(()) => { + eprintln!( + "[synopsis] Stored under refs/notes/{} for commit {}.", + config.notes_ref, + &commit_sha[..8.min(commit_sha.len())] + ); + } + Err(e) => { + eprintln!( + "[synopsis] Warning: Failed to store synopsis as git note: {}", + e + ); + } + } + + // Print the synopsis to stdout + println!("{}", content); + eprintln!( + "\n[synopsis] {} words, ~{} input tokens.", + word_count, input_tokens_estimate + ); +} + +fn handle_show(args: &[String]) { + let (flags, remaining) = CommonFlags::parse(args); + + // Commit can be given positionally or via --commit + let commit_spec = flags + .commit + .or_else(|| remaining.first().cloned()) + .unwrap_or_else(|| "HEAD".to_string()); + + let notes_ref = flags.notes_ref.unwrap_or_else(|| "ai-synopsis".to_string()); + + let repo = match find_repository(&Vec::::new()) { + Ok(r) => r, + Err(e) => { + eprintln!("Failed to find git repository: {}", e); + std::process::exit(1); + } + }; + + let commit_sha = resolve_commit_sha(Some(&commit_spec), &repo); + + match retrieve_synopsis(&repo, &commit_sha, ¬es_ref) { + Ok(Some(synopsis)) => { + println!("{}", synopsis.content); + eprintln!( + "\n[synopsis] Generated {} | model: {} | {} words", + synopsis.metadata.date.format("%Y-%m-%d %H:%M UTC"), + synopsis.metadata.model, + synopsis.metadata.word_count + ); + } + Ok(None) => { + eprintln!( + "No synopsis found for commit {}. Run `git-ai synopsis generate` to create one.", + &commit_sha[..8.min(commit_sha.len())] + ); + std::process::exit(1); + } + Err(e) => { + eprintln!("Failed to retrieve synopsis: {}", e); + std::process::exit(1); + } + } +} + +fn handle_list(args: &[String]) { + let (flags, _remaining) = CommonFlags::parse(args); + + let notes_ref = flags.notes_ref.unwrap_or_else(|| "ai-synopsis".to_string()); + + let repo = match find_repository(&Vec::::new()) { + Ok(r) => r, + Err(e) => { + eprintln!("Failed to find git repository: {}", e); + std::process::exit(1); + } + }; + + match list_synopses(&repo, ¬es_ref) { + Ok(shas) if shas.is_empty() => { + eprintln!("No synopses found. Run `git-ai synopsis generate` to create one."); + } + Ok(shas) => { + println!( + "Synopses stored under refs/notes/{} ({} total):", + notes_ref, + shas.len() + ); + for sha in &shas { + println!(" {}", sha); + } + } + Err(e) => { + eprintln!("Failed to list synopses: {}", e); + std::process::exit(1); + } + } +} + +/// Resolve a commit specifier (e.g. "HEAD", a branch name, a partial SHA) to a +/// full SHA. Exits the process on failure. +fn resolve_commit_sha( + commit_spec: Option<&str>, + repo: &crate::git::repository::Repository, +) -> String { + let spec = commit_spec.unwrap_or("HEAD"); + let mut args = repo.global_args_for_exec(); + args.push("rev-parse".to_string()); + args.push("--verify".to_string()); + args.push(spec.to_string()); + + match crate::git::repository::exec_git(&args) { + Ok(output) => String::from_utf8(output.stdout) + .unwrap_or_default() + .trim() + .to_string(), + Err(e) => { + eprintln!("Failed to resolve commit '{}': {}", spec, e); + std::process::exit(1); + } + } +} + +fn print_synopsis_help() { + eprintln!("git-ai synopsis - Generate AI-powered narrative synopses for commits"); + eprintln!(); + eprintln!("Usage: git-ai synopsis [options]"); + eprintln!(); + eprintln!("Subcommands:"); + eprintln!(" generate Generate a synopsis for a commit"); + eprintln!(" --commit Commit to generate synopsis for (default: HEAD)"); + eprintln!(" --model Claude model to use (default: claude-opus-4-6)"); + eprintln!(" --api-key Anthropic API key (default: ANTHROPIC_API_KEY env)"); + eprintln!( + " --length Target length: brief, standard, detailed (default: standard)" + ); + eprintln!(" --conversation Path to a Claude Code JSONL conversation file"); + eprintln!(" --no-conversation Do not include conversation context"); + eprintln!(" --notes-ref Git notes ref (default: ai-synopsis)"); + eprintln!(" --dry-run Print the prompt without calling the API"); + eprintln!(); + eprintln!(" show [] Show the synopsis for a commit (default: HEAD)"); + eprintln!(" --commit Commit to show (alternative to positional argument)"); + eprintln!(" --notes-ref Git notes ref (default: ai-synopsis)"); + eprintln!(); + eprintln!(" list List all commits that have synopses"); + eprintln!(" --notes-ref Git notes ref (default: ai-synopsis)"); + eprintln!(); + eprintln!("Environment variables:"); + eprintln!(" ANTHROPIC_API_KEY Anthropic API key"); + eprintln!(" GIT_AI_SYNOPSIS_API_KEY Alternative API key variable"); + eprintln!(" GIT_AI_SYNOPSIS_MODEL Default model override"); + eprintln!(" GIT_AI_SYNOPSIS Set to '1' or 'true' to enable auto-generation"); +} diff --git a/src/synopsis/config.rs b/src/synopsis/config.rs new file mode 100644 index 000000000..e34596c3a --- /dev/null +++ b/src/synopsis/config.rs @@ -0,0 +1,81 @@ +use std::env; + +/// How long the synopsis should be. +#[derive(Debug, Clone, PartialEq)] +pub enum TargetLength { + /// ~300-500 words + Brief, + /// ~800-1500 words + Standard, + /// ~1500-3000 words + Detailed, +} + +impl TargetLength { + pub fn word_range(&self) -> (usize, usize) { + match self { + TargetLength::Brief => (300, 500), + TargetLength::Standard => (800, 1500), + TargetLength::Detailed => (1500, 3000), + } + } +} + +/// Which kind of conversation source to use. +#[derive(Debug, Clone, PartialEq)] +pub enum ConversationSourceKind { + /// Automatically detect from known locations. + Auto, + /// Look specifically in Claude Code project directories. + ClaudeCode, + /// Do not attempt to load any conversation. + None, +} + +/// Runtime configuration for synopsis generation. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SynopsisConfig { + pub enabled: bool, + pub model: String, + pub target_length: TargetLength, + pub conversation_source: ConversationSourceKind, + /// Explicit override path for the conversation JSONL file. + pub conversation_path: Option, + /// How many minutes before the commit time to include in the conversation window. + pub conversation_window_minutes: u64, + /// Maximum number of characters (~4 chars/token) to include from a conversation. + pub max_conversation_tokens: usize, + pub diff_context_lines: usize, + /// Git notes ref name (not the full ref, just the short name after `refs/notes/`). + pub notes_ref: String, + pub interactive: bool, + pub api_key: Option, + pub api_base_url: String, +} + +impl Default for SynopsisConfig { + fn default() -> Self { + let api_key = env::var("ANTHROPIC_API_KEY") + .ok() + .or_else(|| env::var("GIT_AI_SYNOPSIS_API_KEY").ok()); + + Self { + enabled: env::var("GIT_AI_SYNOPSIS") + .map(|v| v == "1" || v.to_lowercase() == "true") + .unwrap_or(false), + model: env::var("GIT_AI_SYNOPSIS_MODEL") + .unwrap_or_else(|_| "claude-opus-4-6".to_string()), + target_length: TargetLength::Standard, + conversation_source: ConversationSourceKind::Auto, + conversation_path: None, + conversation_window_minutes: 60, + max_conversation_tokens: 80_000, + diff_context_lines: 10, + notes_ref: "ai-synopsis".to_string(), + interactive: true, + api_key, + api_base_url: "https://api.anthropic.com".to_string(), + } + } +} diff --git a/src/synopsis/conversation.rs b/src/synopsis/conversation.rs new file mode 100644 index 000000000..62796a4ce --- /dev/null +++ b/src/synopsis/conversation.rs @@ -0,0 +1,355 @@ +use crate::error::GitAiError; +use crate::synopsis::types::{ConversationExchange, ConversationLog, Speaker}; +use chrono::{DateTime, Utc}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Derive the Claude Code project hash from a repository path. +/// +/// Claude Code encodes the project directory as a path with `/` replaced by `-` +/// and the leading `-` stripped. +/// +/// Example: `/Users/foo/myrepo` -> `Users-foo-myrepo` +fn claude_project_hash(repo_path: &Path) -> String { + let path_str = repo_path.to_string_lossy(); + // Replace path separators with `-` + #[cfg(windows)] + let hash = path_str.replace('\\', "-").replace('/', "-"); + #[cfg(not(windows))] + let hash = path_str.replace('/', "-"); + + // Strip leading `-` + hash.trim_start_matches('-').to_string() +} + +/// Find the most recently modified Claude Code conversation JSONL file for the +/// given repository. Returns the path to the file, or `None` if nothing is found. +pub fn find_claude_code_conversation(repo_path: &Path) -> Option { + let home = dirs::home_dir()?; + let hash = claude_project_hash(repo_path); + let projects_dir = home.join(".claude").join("projects").join(&hash); + + if !projects_dir.exists() { + return None; + } + + let read_dir = fs::read_dir(&projects_dir).ok()?; + + let mut candidates: Vec<(PathBuf, std::time::SystemTime)> = read_dir + .flatten() + .filter_map(|entry| { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + let modified = entry.metadata().ok()?.modified().ok()?; + Some((path, modified)) + } else { + None + } + }) + .collect(); + + candidates.sort_by(|a, b| b.1.cmp(&a.1)); + candidates.into_iter().next().map(|(path, _)| path) +} + +/// Parse a Claude Code JSONL file into a `ConversationLog`. +/// +/// Lines that cannot be parsed are silently skipped so that partial/corrupted +/// files do not abort the entire operation. +pub fn parse_claude_code_jsonl(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(GitAiError::IoError)?; + let mut exchanges: Vec = Vec::new(); + + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let Ok(entry): Result = serde_json::from_str(trimmed) else { + continue; + }; + + let timestamp: Option> = entry["timestamp"] + .as_str() + .and_then(|s| s.parse::>().ok()); + + let entry_type = entry["type"].as_str().unwrap_or(""); + + match entry_type { + "user" => { + let text = extract_text_from_content(&entry["message"]["content"]); + if !text.is_empty() { + exchanges.push(ConversationExchange { + speaker: Speaker::User, + text, + timestamp, + }); + } + } + "assistant" => { + // Collect text blocks and tool_use blocks separately + let content_val = &entry["message"]["content"]; + if let Some(blocks) = content_val.as_array() { + for block in blocks { + let block_type = block["type"].as_str().unwrap_or(""); + match block_type { + "text" => { + let text = block["text"].as_str().unwrap_or("").to_string(); + if !text.is_empty() { + exchanges.push(ConversationExchange { + speaker: Speaker::Assistant, + text, + timestamp, + }); + } + } + "tool_use" => { + let tool_name = + block["name"].as_str().unwrap_or("unknown_tool").to_string(); + // Represent tool use as a compact summary + let input_summary = + summarise_tool_input(&tool_name, &block["input"]); + exchanges.push(ConversationExchange { + speaker: Speaker::ToolUse(tool_name), + text: input_summary, + timestamp, + }); + } + _ => {} + } + } + } else { + // Scalar string content + let text = extract_text_from_content(content_val); + if !text.is_empty() { + exchanges.push(ConversationExchange { + speaker: Speaker::Assistant, + text, + timestamp, + }); + } + } + } + _ => { + // Skip summary, tool_result, system, etc. + } + } + } + + Ok(ConversationLog { + source_kind: "claude-code".to_string(), + exchanges, + source_path: path.to_string_lossy().to_string(), + }) +} + +/// Extract a plain-text string from a Claude content field, which may be: +/// - a plain JSON string, or +/// - an array of content blocks with `{"type": "text", "text": "..."}`. +fn extract_text_from_content(content: &serde_json::Value) -> String { + if let Some(s) = content.as_str() { + return s.to_string(); + } + if let Some(blocks) = content.as_array() { + return blocks + .iter() + .filter_map(|b| { + if b["type"].as_str() == Some("text") { + b["text"].as_str().map(|s| s.to_string()) + } else { + None + } + }) + .collect::>() + .join("\n"); + } + String::new() +} + +/// Produce a brief human-readable description of a tool invocation. +fn summarise_tool_input(tool_name: &str, input: &serde_json::Value) -> String { + match tool_name { + "Write" | "create_file" => { + let path = input["path"] + .as_str() + .or_else(|| input["file_path"].as_str()) + .unwrap_or("(unknown path)"); + format!("[Write {}]", path) + } + "Edit" | "str_replace_editor" => { + let path = input["path"] + .as_str() + .or_else(|| input["file_path"].as_str()) + .unwrap_or("(unknown path)"); + format!("[Edit {}]", path) + } + "Read" | "view" => { + let path = input["path"] + .as_str() + .or_else(|| input["file_path"].as_str()) + .unwrap_or("(unknown path)"); + format!("[Read {}]", path) + } + "Bash" | "execute_bash" => { + let cmd = input["command"] + .as_str() + .unwrap_or("(command)") + .chars() + .take(120) + .collect::(); + format!("[Bash: {}]", cmd) + } + "Glob" | "list_files" => { + let pattern = input["pattern"].as_str().unwrap_or("(pattern)"); + format!("[Glob: {}]", pattern) + } + "Grep" | "search_files" => { + let pattern = input["pattern"].as_str().unwrap_or("(pattern)"); + format!("[Grep: {}]", pattern) + } + _ => { + // Generic fallback: show tool name and first string field if any + if let Some(obj) = input.as_object() { + if let Some(first_val) = obj.values().find_map(|v| v.as_str()) { + let preview: String = first_val.chars().take(80).collect(); + return format!("[{}: {}]", tool_name, preview); + } + } + format!("[{}]", tool_name) + } + } +} + +/// Filter a `ConversationLog` to exchanges that occurred within `window_minutes` +/// minutes before the most recent timestamp in the log. +/// +/// If no timestamps are present the full log is returned unchanged. +pub fn filter_by_time_window(log: &ConversationLog, window_minutes: u64) -> ConversationLog { + let timestamps: Vec> = log.exchanges.iter().filter_map(|e| e.timestamp).collect(); + + let Some(end_time) = timestamps.iter().copied().max() else { + // No timestamps — return everything + return log.clone(); + }; + + let window = chrono::Duration::minutes(window_minutes as i64); + let start_time = end_time - window; + + let filtered = log + .exchanges + .iter() + .filter(|e| { + // Keep exchanges that either have no timestamp or fall within the window + e.timestamp + .map_or(true, |ts| ts >= start_time && ts <= end_time) + }) + .cloned() + .collect(); + + ConversationLog { + source_kind: log.source_kind.clone(), + exchanges: filtered, + source_path: log.source_path.clone(), + } +} + +/// Render a `ConversationLog` to a human-readable string for inclusion in a +/// synopsis prompt, truncating to `max_chars` to avoid exceeding context limits. +pub fn render_conversation(log: &ConversationLog, max_chars: usize) -> String { + let mut out = String::new(); + + for exchange in &log.exchanges { + let prefix = match &exchange.speaker { + Speaker::User => "**User**: ".to_string(), + Speaker::Assistant => "**Assistant**: ".to_string(), + Speaker::ToolUse(name) => format!("**Tool ({})**: ", name), + }; + + let line = format!("{}{}\n\n", prefix, exchange.text.trim()); + + if out.len() + line.len() > max_chars { + // Truncate to fit within budget + let remaining = max_chars.saturating_sub(out.len()); + if remaining > 64 { + out.push_str(&line[..remaining]); + out.push_str("\n\n[... conversation truncated for length ...]\n"); + } + break; + } + + out.push_str(&line); + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_claude_project_hash_unix() { + let path = Path::new("/Users/foo/myrepo"); + assert_eq!(claude_project_hash(path), "Users-foo-myrepo"); + } + + #[test] + fn test_claude_project_hash_nested() { + let path = Path::new("/home/user/projects/git-ai"); + assert_eq!(claude_project_hash(path), "home-user-projects-git-ai"); + } + + #[test] + fn test_extract_text_string() { + let val = serde_json::json!("hello world"); + assert_eq!(extract_text_from_content(&val), "hello world"); + } + + #[test] + fn test_extract_text_blocks() { + let val = serde_json::json!([ + {"type": "text", "text": "first"}, + {"type": "image", "source": {}}, + {"type": "text", "text": "second"} + ]); + assert_eq!(extract_text_from_content(&val), "first\nsecond"); + } + + #[test] + fn test_filter_by_time_window_no_timestamps() { + let log = ConversationLog { + source_kind: "claude-code".to_string(), + exchanges: vec![ConversationExchange { + speaker: Speaker::User, + text: "hello".to_string(), + timestamp: None, + }], + source_path: "/tmp/test.jsonl".to_string(), + }; + let filtered = filter_by_time_window(&log, 60); + assert_eq!(filtered.exchanges.len(), 1); + } + + #[test] + fn test_render_conversation_truncates() { + let log = ConversationLog { + source_kind: "claude-code".to_string(), + exchanges: vec![ + ConversationExchange { + speaker: Speaker::User, + text: "x".repeat(200), + timestamp: None, + }, + ConversationExchange { + speaker: Speaker::Assistant, + text: "y".repeat(200), + timestamp: None, + }, + ], + source_path: "/tmp/test.jsonl".to_string(), + }; + let rendered = render_conversation(&log, 100); + assert!(rendered.len() <= 200); // Should be truncated + } +} diff --git a/src/synopsis/generator.rs b/src/synopsis/generator.rs new file mode 100644 index 000000000..a9081234f --- /dev/null +++ b/src/synopsis/generator.rs @@ -0,0 +1,240 @@ +use crate::error::GitAiError; +use crate::synopsis::config::SynopsisConfig; +use crate::synopsis::conversation::render_conversation; +use crate::synopsis::types::SynopsisInput; + +/// Call the Anthropic Messages API and return the generated synopsis as a +/// Markdown string. +pub fn generate_synopsis( + input: &SynopsisInput, + config: &SynopsisConfig, +) -> Result { + let api_key = config.api_key.as_deref().ok_or_else(|| { + GitAiError::Generic( + "No API key found. Set ANTHROPIC_API_KEY or GIT_AI_SYNOPSIS_API_KEY.".to_string(), + ) + })?; + + let prompt = build_prompt(input, config); + let request_body = build_request_body(&config.model, &prompt); + let request_json = serde_json::to_string(&request_body).map_err(GitAiError::JsonError)?; + + let url = format!("{}/v1/messages", config.api_base_url); + + let response = minreq::post(&url) + .with_header("x-api-key", api_key) + .with_header("anthropic-version", "2023-06-01") + .with_header("content-type", "application/json") + .with_body(request_json) + .with_timeout(300) // Synopsis generation can take a while + .send() + .map_err(|e| GitAiError::Generic(format!("HTTP request to Anthropic API failed: {}", e)))?; + + let status = response.status_code; + let body = response.as_str().map_err(|e| { + GitAiError::Generic(format!("Failed to read Anthropic API response: {}", e)) + })?; + + if status < 200 || status >= 300 { + return Err(GitAiError::Generic(format!( + "Anthropic API returned HTTP {}: {}", + status, body + ))); + } + + parse_response(body) +} + +/// Construct the rich system + user prompt for synopsis generation. +fn build_prompt(input: &SynopsisInput, config: &SynopsisConfig) -> String { + let (min_words, max_words) = config.target_length.word_range(); + + let mut prompt = String::new(); + + prompt.push_str(&format!( + "You are a technical writer specialising in AI-assisted software development. \ +Your task is to write a detailed, engaging blog-article-style narrative synopsis of a \ +single git commit. The synopsis should be readable by other developers and give them \ +deep insight into what was built and why.\n\n\ +**Length target**: {}-{} words.\n\n", + min_words, max_words + )); + + prompt.push_str( + "**Required sections** (use Markdown headings):\n\ +1. `## TL;DR` — One or two sentences summarising what was accomplished.\n\ +2. `## Background and Motivation` — Why was this work needed? What problem does it solve?\n\ +3. `## The Journey` — Describe the development process: approaches explored, \ + decisions made, dead ends encountered, pivots taken. \ + If conversation context is provided, ground this section in the actual dialogue.\n\ +4. `## The Solution` — What was actually implemented? Describe the architecture, \ + key algorithms, or design decisions at an appropriate level of detail.\n\ +5. `## Key Files Changed` — A brief description of each significant file changed.\n\ +6. `## Reflections` — What was learned? What trade-offs were made? What might be done differently?\n\n", + ); + + prompt.push_str( + "Write with the voice of a thoughtful senior engineer reflecting on their work. \ +Be specific — reference actual function names, file names, and design choices where \ +relevant. Avoid generic filler phrases.\n\n", + ); + + prompt.push_str("---\n\n"); + + // Commit metadata + prompt.push_str(&format!("**Commit SHA**: `{}`\n", input.commit_sha)); + prompt.push_str(&format!("**Author**: {}\n", input.author)); + prompt.push_str("\n"); + + // Commit message + prompt.push_str("## Commit Message\n\n```\n"); + prompt.push_str(&input.commit_message); + prompt.push_str("\n```\n\n"); + + // Diff stat + prompt.push_str("## Diff Statistics\n\n```\n"); + prompt.push_str(&input.diff.stat_summary); + prompt.push_str("\n```\n\n"); + + // Unified diff (may be large; truncate to ~200 kB to avoid context overruns) + let diff_text = truncate_to_chars(&input.diff.unified_diff, 200_000); + if !diff_text.is_empty() { + prompt.push_str("## Unified Diff\n\n```diff\n"); + prompt.push_str(&diff_text); + prompt.push_str("\n```\n\n"); + } + + // Conversation context (optional) + if let Some(conv) = &input.conversation { + // ~4 chars per token as a rough estimate + let max_chars = config.max_conversation_tokens * 4; + let rendered = render_conversation(conv, max_chars); + if !rendered.is_empty() { + prompt.push_str("## AI Conversation Context\n\n"); + prompt.push_str(&format!( + "_Source: {} ({})_\n\n", + conv.source_kind, conv.source_path + )); + prompt.push_str(&rendered); + prompt.push('\n'); + } + } else { + prompt.push_str("_No conversation context was available for this commit._\n\n"); + } + + prompt.push_str("---\n\n"); + prompt.push_str( + "Please write the synopsis now, following all the required sections above. \ +Start directly with a suitable title on the first line (no preamble).\n", + ); + + prompt +} + +/// Build the Anthropic Messages API request body as a JSON value. +fn build_request_body(model: &str, prompt: &str) -> serde_json::Value { + serde_json::json!({ + "model": model, + "max_tokens": 4096, + "messages": [ + { + "role": "user", + "content": prompt + } + ] + }) +} + +/// Parse the Anthropic Messages API JSON response and extract the text content. +fn parse_response(body: &str) -> Result { + let parsed: serde_json::Value = serde_json::from_str(body).map_err(GitAiError::JsonError)?; + + // Check for API-level error + if let Some(error) = parsed.get("error") { + let msg = error["message"].as_str().unwrap_or("Unknown API error"); + return Err(GitAiError::Generic(format!("Anthropic API error: {}", msg))); + } + + // Navigate: content[0].text + let text = parsed["content"] + .as_array() + .and_then(|arr| arr.first()) + .and_then(|block| block["text"].as_str()) + .ok_or_else(|| { + GitAiError::Generic(format!("Unexpected Anthropic API response shape: {}", body)) + })?; + + Ok(text.trim().to_string()) +} + +/// Estimate input token count as a rough approximation (4 chars per token). +pub fn estimate_input_tokens(prompt: &str) -> usize { + (prompt.len() + 3) / 4 +} + +fn truncate_to_chars(s: &str, max_chars: usize) -> String { + if s.len() <= max_chars { + s.to_string() + } else { + let mut truncated: String = s.chars().take(max_chars).collect(); + truncated.push_str("\n\n[... diff truncated for length ...]"); + truncated + } +} + +/// Build the synopsis prompt string without calling the API (used for token estimation). +pub fn build_synopsis_prompt(input: &SynopsisInput, config: &SynopsisConfig) -> String { + build_prompt(input, config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_response_valid() { + let body = serde_json::json!({ + "content": [{"type": "text", "text": " Hello synopsis "}], + "model": "claude-opus-4-6", + "stop_reason": "end_turn" + }) + .to_string(); + + let result = parse_response(&body).unwrap(); + assert_eq!(result, "Hello synopsis"); + } + + #[test] + fn test_parse_response_api_error() { + let body = serde_json::json!({ + "error": {"type": "authentication_error", "message": "invalid x-api-key"} + }) + .to_string(); + + let err = parse_response(&body).unwrap_err(); + assert!(matches!(err, GitAiError::Generic(_))); + if let GitAiError::Generic(msg) = err { + assert!(msg.contains("invalid x-api-key")); + } + } + + #[test] + fn test_truncate_to_chars_short() { + let s = "short"; + assert_eq!(truncate_to_chars(s, 100), "short"); + } + + #[test] + fn test_truncate_to_chars_long() { + let s = "a".repeat(200); + let result = truncate_to_chars(&s, 100); + assert!(result.len() > 100); // has truncation notice + assert!(result.contains("truncated")); + } + + #[test] + fn test_estimate_input_tokens() { + assert_eq!(estimate_input_tokens("1234"), 1); + assert_eq!(estimate_input_tokens("12345678"), 2); + } +} diff --git a/src/synopsis/mod.rs b/src/synopsis/mod.rs new file mode 100644 index 000000000..7ac27b06f --- /dev/null +++ b/src/synopsis/mod.rs @@ -0,0 +1,7 @@ +pub mod collector; +pub mod commands; +pub mod config; +pub mod conversation; +pub mod generator; +pub mod storage; +pub mod types; diff --git a/src/synopsis/storage.rs b/src/synopsis/storage.rs new file mode 100644 index 000000000..ac9eb824c --- /dev/null +++ b/src/synopsis/storage.rs @@ -0,0 +1,88 @@ +use crate::error::GitAiError; +use crate::git::repository::{Repository, exec_git, exec_git_stdin}; +use crate::synopsis::types::Synopsis; + +/// Store a synopsis as a git note on the commit referenced by +/// `synopsis.metadata.commit_sha`, under the given `notes_ref`. +/// +/// The note content is a JSON object with `metadata` and `content` fields. +pub fn store_synopsis( + repo: &Repository, + synopsis: &Synopsis, + notes_ref: &str, +) -> Result<(), GitAiError> { + let json = serde_json::to_string_pretty(synopsis).map_err(GitAiError::JsonError)?; + + let mut args = repo.global_args_for_exec(); + args.push("notes".to_string()); + args.push(format!("--ref={}", notes_ref)); + args.push("add".to_string()); + args.push("-f".to_string()); // Force overwrite if a note already exists + args.push("-F".to_string()); + args.push("-".to_string()); // Read content from stdin + args.push(synopsis.metadata.commit_sha.clone()); + + exec_git_stdin(&args, json.as_bytes())?; + Ok(()) +} + +/// Retrieve the synopsis for a specific commit, or `None` if no note exists. +pub fn retrieve_synopsis( + repo: &Repository, + commit_sha: &str, + notes_ref: &str, +) -> Result, GitAiError> { + let mut args = repo.global_args_for_exec(); + args.push("notes".to_string()); + args.push(format!("--ref={}", notes_ref)); + args.push("show".to_string()); + args.push(commit_sha.to_string()); + + match exec_git(&args) { + Ok(output) => { + let raw = String::from_utf8(output.stdout).map_err(GitAiError::FromUtf8Error)?; + let raw = raw.trim(); + if raw.is_empty() { + return Ok(None); + } + let synopsis: Synopsis = serde_json::from_str(raw).map_err(GitAiError::JsonError)?; + Ok(Some(synopsis)) + } + Err(GitAiError::GitCliError { code: Some(1), .. }) => Ok(None), + Err(GitAiError::GitCliError { + code: Some(128), .. + }) => Ok(None), + Err(e) => Err(e), + } +} + +/// List all commit SHAs that have a synopsis note under `notes_ref`. +/// +/// The output of `git notes --ref= list` is lines of the form: +/// ` ` +pub fn list_synopses(repo: &Repository, notes_ref: &str) -> Result, GitAiError> { + let mut args = repo.global_args_for_exec(); + args.push("notes".to_string()); + args.push(format!("--ref={}", notes_ref)); + args.push("list".to_string()); + + match exec_git(&args) { + Ok(output) => { + let stdout = String::from_utf8(output.stdout).map_err(GitAiError::FromUtf8Error)?; + let shas = stdout + .lines() + .filter_map(|line| { + let parts: Vec<&str> = line.split_whitespace().collect(); + parts.get(1).map(|s| s.to_string()) + }) + .collect(); + Ok(shas) + } + // refs/notes/ doesn't exist yet — not an error + Err(GitAiError::GitCliError { code: Some(1), .. }) => Ok(Vec::new()), + Err(GitAiError::GitCliError { + code: Some(128), .. + }) => Ok(Vec::new()), + Err(e) => Err(e), + } +} diff --git a/src/synopsis/types.rs b/src/synopsis/types.rs new file mode 100644 index 000000000..1295cedb1 --- /dev/null +++ b/src/synopsis/types.rs @@ -0,0 +1,71 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Metadata attached to a generated synopsis. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SynopsisMetadata { + pub commit_sha: String, + pub date: DateTime, + pub author: String, + pub model: String, + pub version: u32, + pub word_count: usize, + pub input_tokens_estimate: usize, + pub conversation_source: Option, + pub conversation_window_secs: Option, + pub files_changed: usize, +} + +/// A generated AI synopsis for a commit. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Synopsis { + pub metadata: SynopsisMetadata, + /// Markdown body of the synopsis. + pub content: String, +} + +/// A single exchange in a conversation log. +#[derive(Debug, Clone)] +pub struct ConversationExchange { + pub speaker: Speaker, + pub text: String, + pub timestamp: Option>, +} + +/// Who spoke in a conversation exchange. +#[derive(Debug, Clone, PartialEq)] +pub enum Speaker { + User, + Assistant, + ToolUse(String), +} + +/// A parsed conversation log from an AI coding session. +#[derive(Debug, Clone)] +pub struct ConversationLog { + /// The kind of source, e.g. `"claude-code"`. + pub source_kind: String, + pub exchanges: Vec, + pub source_path: String, +} + +/// The diff between two commits, pre-computed for injection into prompts. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct DiffBundle { + pub stat_summary: String, + pub unified_diff: String, + pub files_changed: usize, + pub insertions: usize, + pub deletions: usize, +} + +/// All inputs required to generate a synopsis. +#[derive(Debug, Clone)] +pub struct SynopsisInput { + pub conversation: Option, + pub diff: DiffBundle, + pub commit_message: String, + pub commit_sha: String, + pub author: String, +} From a51bb389ba68f1a75a5c1fd575f55fc7aea8c993 Mon Sep 17 00:00:00 2001 From: John Wiegley Date: Thu, 19 Feb 2026 16:25:19 -0800 Subject: [PATCH 2/4] fix(synopsis): preserve leading dash in Claude project hash Claude Code stores conversation files at: ~/.claude/projects/-Users-foo-myrepo/.jsonl The project hash is derived by replacing `/` with `-`, which produces a leading `-` for absolute Unix paths. The original implementation stripped this leading dash, so `find_claude_code_conversation` would look for `Users-foo-myrepo` instead of `-Users-foo-myrepo` and always come up empty. Co-Authored-By: Claude Sonnet 4.6 --- src/synopsis/conversation.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/synopsis/conversation.rs b/src/synopsis/conversation.rs index 62796a4ce..2c9fe29a4 100644 --- a/src/synopsis/conversation.rs +++ b/src/synopsis/conversation.rs @@ -6,20 +6,14 @@ use std::path::{Path, PathBuf}; /// Derive the Claude Code project hash from a repository path. /// -/// Claude Code encodes the project directory as a path with `/` replaced by `-` -/// and the leading `-` stripped. -/// -/// Example: `/Users/foo/myrepo` -> `Users-foo-myrepo` +/// Claude Code encodes the project directory by replacing `/` with `-`. +/// The leading `-` is preserved (e.g. `/Users/foo/myrepo` → `-Users-foo-myrepo`). fn claude_project_hash(repo_path: &Path) -> String { let path_str = repo_path.to_string_lossy(); - // Replace path separators with `-` #[cfg(windows)] - let hash = path_str.replace('\\', "-").replace('/', "-"); + return path_str.replace('\\', "-").replace('/', "-"); #[cfg(not(windows))] - let hash = path_str.replace('/', "-"); - - // Strip leading `-` - hash.trim_start_matches('-').to_string() + path_str.replace('/', "-") } /// Find the most recently modified Claude Code conversation JSONL file for the @@ -291,13 +285,13 @@ mod tests { #[test] fn test_claude_project_hash_unix() { let path = Path::new("/Users/foo/myrepo"); - assert_eq!(claude_project_hash(path), "Users-foo-myrepo"); + assert_eq!(claude_project_hash(path), "-Users-foo-myrepo"); } #[test] fn test_claude_project_hash_nested() { let path = Path::new("/home/user/projects/git-ai"); - assert_eq!(claude_project_hash(path), "home-user-projects-git-ai"); + assert_eq!(claude_project_hash(path), "-home-user-projects-git-ai"); } #[test] From f3ba8ee41d2f69a4fd8b7eeacffef4414b53d111 Mon Sep 17 00:00:00 2001 From: John Wiegley Date: Thu, 19 Feb 2026 16:34:15 -0800 Subject: [PATCH 3/4] feat(synopsis): add automation, base URL override, and claude CLI backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related improvements: **Automation via GIT_AI_SYNOPSIS=1** The post-commit hook now checks GIT_AI_SYNOPSIS. When set, it spawns `git-ai synopsis generate` as a detached background process immediately after the commit lands, so the terminal is not blocked. On Unix the child is moved into its own process group to avoid receiving signals meant for the parent session. **ANTHROPIC_BASE_URL support** The SynopsisConfig now reads the standard ANTHROPIC_BASE_URL env var (the same variable used by the Anthropic SDK and most proxies) as the API base URL override. Previously the only way to change the base URL was to edit source code. **`claude` CLI backend (--via-claude)** A new GenerationBackend::ClaudeCli variant pipes the prompt directly to `claude --print` instead of calling the Anthropic API. This uses Claude Code's existing authentication — no separate API key is needed. Select it with --via-claude on the command line, or set: GIT_AI_SYNOPSIS_BACKEND=claude Usage examples: # Use the claude CLI (no API key required) git ai synopsis generate --via-claude # Use a corporate API gateway ANTHROPIC_BASE_URL=https://my-proxy/anthropic git ai synopsis generate # Auto-generate for every commit (background) GIT_AI_SYNOPSIS=1 GIT_AI_SYNOPSIS_BACKEND=claude git commit -m "..." Co-Authored-By: Claude Sonnet 4.6 --- src/commands/hooks/commit_hooks.rs | 54 +++++++++++++++++++++++ src/synopsis/commands.rs | 49 +++++++++++++++++---- src/synopsis/config.rs | 29 +++++++++++- src/synopsis/generator.rs | 71 +++++++++++++++++++++++++++--- 4 files changed, 187 insertions(+), 16 deletions(-) diff --git a/src/commands/hooks/commit_hooks.rs b/src/commands/hooks/commit_hooks.rs index 0fc650c5c..b7d398ac4 100644 --- a/src/commands/hooks/commit_hooks.rs +++ b/src/commands/hooks/commit_hooks.rs @@ -69,6 +69,8 @@ pub fn commit_post_command_hook( } let commit_author = get_commit_default_author(repository, &parsed_args.command_args); + // Save the SHA before it may be moved by unwrap() calls below. + let new_sha_for_synopsis = new_sha.clone(); if parsed_args.has_command_flag("--amend") { if let (Some(orig), Some(sha)) = (original_commit.clone(), new_sha.clone()) { repository.handle_rewrite_log_event( @@ -96,6 +98,58 @@ pub fn commit_post_command_hook( // Flush logs and metrics after commit crate::observability::spawn_background_flush(); + + // Auto-generate a synopsis if GIT_AI_SYNOPSIS=1 (or "true"). + // We spawn a background child so the commit itself returns immediately. + if let Some(sha) = new_sha_for_synopsis { + maybe_spawn_synopsis_background(&sha); + } +} + +/// If `GIT_AI_SYNOPSIS` is set to `1` or `true`, spawn `git-ai synopsis generate` +/// as a detached background process for the newly created commit. +/// +/// The child inherits stdin/stdout/stderr so any output appears in the terminal, +/// but we don't wait for it — the commit completes immediately. +fn maybe_spawn_synopsis_background(commit_sha: &str) { + let enabled = std::env::var("GIT_AI_SYNOPSIS") + .map(|v| v == "1" || v.to_lowercase() == "true") + .unwrap_or(false); + + if !enabled { + return; + } + + // Find this binary's own path so we can re-invoke `git-ai synopsis generate`. + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return, + }; + + // Collect any backend / model / key env vars to forward. We just inherit + // the whole environment, which is simplest and correct. + let mut cmd = std::process::Command::new(&exe); + cmd.args(["synopsis", "generate", "--commit", commit_sha]); + + // Detach: on Unix, double-fork is the cleanest approach, but simply + // spawning without waiting is sufficient for a short-lived helper. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Move the child into its own process group so it doesn't receive + // signals from the terminal session that owns the parent. + cmd.process_group(0); + } + + match cmd.spawn() { + Ok(_child) => { + // Don't call child.wait() — we want background execution. + eprintln!("[synopsis] Generating synopsis for {} in the background...", &commit_sha[..8.min(commit_sha.len())]); + } + Err(e) => { + eprintln!("[synopsis] Warning: failed to launch background synopsis generation: {}", e); + } + } } pub fn get_commit_default_author(repo: &Repository, args: &[String]) -> String { diff --git a/src/synopsis/commands.rs b/src/synopsis/commands.rs index 8fd209f69..35c025720 100644 --- a/src/synopsis/commands.rs +++ b/src/synopsis/commands.rs @@ -1,6 +1,8 @@ use crate::git::find_repository; use crate::synopsis::collector::collect_input; -use crate::synopsis::config::{ConversationSourceKind, SynopsisConfig, TargetLength}; +use crate::synopsis::config::{ + ConversationSourceKind, GenerationBackend, SynopsisConfig, TargetLength, +}; use crate::synopsis::generator::{build_synopsis_prompt, estimate_input_tokens, generate_synopsis}; use crate::synopsis::storage::{list_synopses, retrieve_synopsis, store_synopsis}; use crate::synopsis::types::{Synopsis, SynopsisMetadata}; @@ -36,6 +38,7 @@ struct CommonFlags { no_conversation: bool, length: Option, dry_run: bool, + via_claude: bool, } impl CommonFlags { @@ -48,6 +51,7 @@ impl CommonFlags { let mut no_conversation = false; let mut length = None; let mut dry_run = false; + let mut via_claude = false; let mut remaining = Vec::new(); let mut i = 0; @@ -85,6 +89,10 @@ impl CommonFlags { dry_run = true; i += 1; } + "--via-claude" | "--claude" => { + via_claude = true; + i += 1; + } _ => { remaining.push(args[i].clone()); i += 1; @@ -102,6 +110,7 @@ impl CommonFlags { no_conversation, length, dry_run, + via_claude, }, remaining, ) @@ -136,10 +145,20 @@ fn handle_generate(args: &[String]) { _ => TargetLength::Standard, }; } + if flags.via_claude { + config.backend = GenerationBackend::ClaudeCli; + } - // Check API key before doing any expensive work (but allow dry-run without a key) - if config.api_key.is_none() && !flags.dry_run { - eprintln!("Error: No API key found. Set ANTHROPIC_API_KEY or use --api-key ."); + // Check API key before doing any expensive work — not needed for the claude CLI backend + // or dry-run mode. + if config.api_key.is_none() + && !flags.dry_run + && config.backend == GenerationBackend::AnthropicApi + { + eprintln!( + "Error: No API key found. Set ANTHROPIC_API_KEY, use --api-key , \ + or use --via-claude to generate via the Claude Code CLI." + ); std::process::exit(1); } @@ -179,10 +198,16 @@ fn handle_generate(args: &[String]) { return; } - eprintln!( - "[synopsis] Generating synopsis using model {}...", - config.model - ); + match config.backend { + GenerationBackend::ClaudeCli => eprintln!( + "[synopsis] Generating synopsis via `claude --print` (model: {})...", + config.model + ), + GenerationBackend::AnthropicApi => eprintln!( + "[synopsis] Generating synopsis via Anthropic API (model: {})...", + config.model + ), + } let content = match generate_synopsis(&input, &config) { Ok(c) => c, @@ -355,6 +380,7 @@ fn print_synopsis_help() { eprintln!(" --commit Commit to generate synopsis for (default: HEAD)"); eprintln!(" --model Claude model to use (default: claude-opus-4-6)"); eprintln!(" --api-key Anthropic API key (default: ANTHROPIC_API_KEY env)"); + eprintln!(" --via-claude Use the `claude` CLI instead of the Anthropic API"); eprintln!( " --length Target length: brief, standard, detailed (default: standard)" ); @@ -363,6 +389,13 @@ fn print_synopsis_help() { eprintln!(" --notes-ref Git notes ref (default: ai-synopsis)"); eprintln!(" --dry-run Print the prompt without calling the API"); eprintln!(); + eprintln!(" Environment variables for automation:"); + eprintln!(" ANTHROPIC_API_KEY API key for direct API calls"); + eprintln!(" ANTHROPIC_BASE_URL Override API base URL (proxy, gateway, etc.)"); + eprintln!(" GIT_AI_SYNOPSIS=1 Auto-generate synopsis after every commit"); + eprintln!(" GIT_AI_SYNOPSIS_BACKEND=claude Use the `claude` CLI as the backend"); + eprintln!(" GIT_AI_SYNOPSIS_MODEL Default model override"); + eprintln!(); eprintln!(" show [] Show the synopsis for a commit (default: HEAD)"); eprintln!(" --commit Commit to show (alternative to positional argument)"); eprintln!(" --notes-ref Git notes ref (default: ai-synopsis)"); diff --git a/src/synopsis/config.rs b/src/synopsis/config.rs index e34596c3a..3339c8d12 100644 --- a/src/synopsis/config.rs +++ b/src/synopsis/config.rs @@ -32,6 +32,16 @@ pub enum ConversationSourceKind { None, } +/// Which backend to use for AI generation. +#[derive(Debug, Clone, PartialEq)] +pub enum GenerationBackend { + /// Call the Anthropic Messages API directly (requires ANTHROPIC_API_KEY). + AnthropicApi, + /// Pipe the prompt to the `claude` CLI (`claude --print`). + /// Uses Claude Code's existing authentication — no separate API key needed. + ClaudeCli, +} + /// Runtime configuration for synopsis generation. #[derive(Debug, Clone)] #[allow(dead_code)] @@ -52,6 +62,8 @@ pub struct SynopsisConfig { pub interactive: bool, pub api_key: Option, pub api_base_url: String, + /// Which AI backend to use for generation. + pub backend: GenerationBackend, } impl Default for SynopsisConfig { @@ -60,6 +72,20 @@ impl Default for SynopsisConfig { .ok() .or_else(|| env::var("GIT_AI_SYNOPSIS_API_KEY").ok()); + // Standard Anthropic SDK env var; fall back to hardcoded default. + let api_base_url = env::var("ANTHROPIC_BASE_URL") + .unwrap_or_else(|_| "https://api.anthropic.com".to_string()); + + // "claude" selects the claude-cli backend; anything else (or absent) uses the API. + let backend = match env::var("GIT_AI_SYNOPSIS_BACKEND") + .unwrap_or_default() + .to_lowercase() + .as_str() + { + "claude" | "claude-code" | "claude-cli" => GenerationBackend::ClaudeCli, + _ => GenerationBackend::AnthropicApi, + }; + Self { enabled: env::var("GIT_AI_SYNOPSIS") .map(|v| v == "1" || v.to_lowercase() == "true") @@ -75,7 +101,8 @@ impl Default for SynopsisConfig { notes_ref: "ai-synopsis".to_string(), interactive: true, api_key, - api_base_url: "https://api.anthropic.com".to_string(), + api_base_url, + backend, } } } diff --git a/src/synopsis/generator.rs b/src/synopsis/generator.rs index a9081234f..775c55ee7 100644 --- a/src/synopsis/generator.rs +++ b/src/synopsis/generator.rs @@ -1,22 +1,79 @@ use crate::error::GitAiError; -use crate::synopsis::config::SynopsisConfig; +use crate::synopsis::config::{GenerationBackend, SynopsisConfig}; use crate::synopsis::conversation::render_conversation; use crate::synopsis::types::SynopsisInput; -/// Call the Anthropic Messages API and return the generated synopsis as a -/// Markdown string. +/// Generate a synopsis using whichever backend is configured. pub fn generate_synopsis( input: &SynopsisInput, config: &SynopsisConfig, ) -> Result { + let prompt = build_prompt(input, config); + match config.backend { + GenerationBackend::ClaudeCli => generate_via_claude_cli(&prompt, config), + GenerationBackend::AnthropicApi => generate_via_api(&prompt, config), + } +} + +/// Call the `claude` CLI with `--print` to generate the synopsis. +/// +/// This uses Claude Code's existing authentication — no separate API key is +/// required. The model flag is passed when set; if the `claude` binary is not +/// on PATH the error is surfaced clearly. +fn generate_via_claude_cli(prompt: &str, config: &SynopsisConfig) -> Result { + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut cmd = Command::new("claude"); + cmd.arg("--print"); + // Pass model if it looks like a real model name (non-empty, not the placeholder default + // that users might not have changed). + if !config.model.is_empty() { + cmd.arg("--model").arg(&config.model); + } + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| { + GitAiError::Generic(format!( + "Failed to launch `claude` CLI: {}. Is Claude Code installed and on your PATH?", + e + )) + })?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(prompt.as_bytes()) + .map_err(GitAiError::IoError)?; + } + + let output = child.wait_with_output().map_err(GitAiError::IoError)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(GitAiError::Generic(format!( + "`claude --print` failed (exit {}): {}", + output.status.code().unwrap_or(-1), + stderr.trim() + ))); + } + + let text = String::from_utf8(output.stdout).map_err(GitAiError::FromUtf8Error)?; + Ok(text.trim().to_string()) +} + +/// Call the Anthropic Messages API directly. +fn generate_via_api(prompt: &str, config: &SynopsisConfig) -> Result { let api_key = config.api_key.as_deref().ok_or_else(|| { GitAiError::Generic( - "No API key found. Set ANTHROPIC_API_KEY or GIT_AI_SYNOPSIS_API_KEY.".to_string(), + "No API key found. Set ANTHROPIC_API_KEY, or use --via-claude to generate \ + via the Claude Code CLI instead." + .to_string(), ) })?; - let prompt = build_prompt(input, config); - let request_body = build_request_body(&config.model, &prompt); + let request_body = build_request_body(&config.model, prompt); let request_json = serde_json::to_string(&request_body).map_err(GitAiError::JsonError)?; let url = format!("{}/v1/messages", config.api_base_url); @@ -26,7 +83,7 @@ pub fn generate_synopsis( .with_header("anthropic-version", "2023-06-01") .with_header("content-type", "application/json") .with_body(request_json) - .with_timeout(300) // Synopsis generation can take a while + .with_timeout(300) .send() .map_err(|e| GitAiError::Generic(format!("HTTP request to Anthropic API failed: {}", e)))?; From da07bd40abb08cc40e78e3fc28e7dfb760a06442 Mon Sep 17 00:00:00 2001 From: John Wiegley Date: Fri, 20 Feb 2026 10:26:54 -0800 Subject: [PATCH 4/4] Run cargo fmt --- src/commands/hooks/commit_hooks.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/commands/hooks/commit_hooks.rs b/src/commands/hooks/commit_hooks.rs index b7d398ac4..ede80510b 100644 --- a/src/commands/hooks/commit_hooks.rs +++ b/src/commands/hooks/commit_hooks.rs @@ -144,10 +144,16 @@ fn maybe_spawn_synopsis_background(commit_sha: &str) { match cmd.spawn() { Ok(_child) => { // Don't call child.wait() — we want background execution. - eprintln!("[synopsis] Generating synopsis for {} in the background...", &commit_sha[..8.min(commit_sha.len())]); + eprintln!( + "[synopsis] Generating synopsis for {} in the background...", + &commit_sha[..8.min(commit_sha.len())] + ); } Err(e) => { - eprintln!("[synopsis] Warning: failed to launch background synopsis generation: {}", e); + eprintln!( + "[synopsis] Warning: failed to launch background synopsis generation: {}", + e + ); } } }