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/hooks/commit_hooks.rs b/src/commands/hooks/commit_hooks.rs index 0fc650c5c..ede80510b 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,64 @@ 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/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..35c025720 --- /dev/null +++ b/src/synopsis/commands.rs @@ -0,0 +1,411 @@ +use crate::git::find_repository; +use crate::synopsis::collector::collect_input; +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}; +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, + via_claude: 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 via_claude = 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; + } + "--via-claude" | "--claude" => { + via_claude = true; + i += 1; + } + _ => { + remaining.push(args[i].clone()); + i += 1; + } + } + } + + ( + CommonFlags { + commit, + model, + api_key, + notes_ref, + conversation, + no_conversation, + length, + dry_run, + via_claude, + }, + 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, + }; + } + if flags.via_claude { + config.backend = GenerationBackend::ClaudeCli; + } + + // 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); + } + + // 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; + } + + 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, + 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!(" --via-claude Use the `claude` CLI instead of the Anthropic API"); + 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!(" 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)"); + 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..3339c8d12 --- /dev/null +++ b/src/synopsis/config.rs @@ -0,0 +1,108 @@ +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, +} + +/// 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)] +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, + /// Which AI backend to use for generation. + pub backend: GenerationBackend, +} + +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()); + + // 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") + .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, + backend, + } + } +} diff --git a/src/synopsis/conversation.rs b/src/synopsis/conversation.rs new file mode 100644 index 000000000..2c9fe29a4 --- /dev/null +++ b/src/synopsis/conversation.rs @@ -0,0 +1,349 @@ +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 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(); + #[cfg(windows)] + return path_str.replace('\\', "-").replace('/', "-"); + #[cfg(not(windows))] + path_str.replace('/', "-") +} + +/// 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..775c55ee7 --- /dev/null +++ b/src/synopsis/generator.rs @@ -0,0 +1,297 @@ +use crate::error::GitAiError; +use crate::synopsis::config::{GenerationBackend, SynopsisConfig}; +use crate::synopsis::conversation::render_conversation; +use crate::synopsis::types::SynopsisInput; + +/// 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 use --via-claude to generate \ + via the Claude Code CLI instead." + .to_string(), + ) + })?; + + 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) + .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, +}