From 566638029129590eafb97723fee96ebbfa10536e Mon Sep 17 00:00:00 2001 From: Alex Buckley Date: Tue, 19 May 2026 22:47:45 -0700 Subject: [PATCH 1/5] feat: add await-stats command implementation Add the core `git ai await-stats` command implementation that resolves a target commit, polls the git-ai notes backend for its authorship note, validates the note payload, and then renders the same stats data exposed by `git ai stats`. - `AwaitStatsOptions::default` defines the command defaults: `HEAD`, `--timeout 5000`, `--interval 100`, human-readable output, and non-quiet mode. - `AwaitStatsError::exit_code` maps timeout, bad invocation, bad commit, and corrupt-note failures onto the public CLI exit-code contract. - `From for AwaitStatsError` keeps shared git-ai failures inside the command-specific error type. - `handle_await_stats` executes the command, suppresses timeout noise for `--quiet`, prints user-facing errors, and exits with the correct status code. - `run` keeps parsing and execution testable by separating command logic from process exit behavior. - `run_with_options` discovers the repository, resolves the commit revision, waits for the note, validates `AuthorshipLog`, computes commit stats, and prints either JSON or the shared terminal renderer output. - `parse_options` handles all supported flags, requires values where needed, and rejects unknown arguments. - `parse_u64_option` provides a single parser for millisecond options with stable bad-argument messages. - `resolve_commit` accepts any rev that peels to a commit and returns the SHA that owns the authorship note. - `wait_for_note` polls `notes_api::read_note` until the note appears or the timeout expires, while capping sleep to the remaining wait budget. - `is_quiet_timeout` detects `--quiet` early enough to suppress timeout output before parsed options are available. - `print_error` formats each failure mode into the command's stderr contract. - `short_sha` trims commit IDs for timeout messages. - `print_help` adds command-local usage output for `await-stats`. - The parser unit tests cover defaults, full option parsing, missing values, zero interval rejection, and unknown arguments. Co-authored-by: OpenAI GPT 5.5 --- src/commands/await_stats.rs | 369 ++++++++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 src/commands/await_stats.rs diff --git a/src/commands/await_stats.rs b/src/commands/await_stats.rs new file mode 100644 index 0000000000..0f084a15af --- /dev/null +++ b/src/commands/await_stats.rs @@ -0,0 +1,369 @@ +use crate::authorship::authorship_log_serialization::AuthorshipLog; +use crate::authorship::ignore::effective_ignore_patterns; +use crate::authorship::stats::{stats_for_commit_stats, write_stats_to_terminal}; +use crate::error::GitAiError; +use crate::git::find_repository; +use crate::git::notes_api::read_note; +use crate::git::repository::Repository; +use std::thread; +use std::time::{Duration, Instant}; + +const DEFAULT_TIMEOUT_MS: u64 = 5_000; +const DEFAULT_INTERVAL_MS: u64 = 100; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AwaitStatsOptions { + // Polling options + timeout_ms: u64, + interval_ms: u64, + + // Output format options + json: bool, + + // Commit selection + commit: String, + + // Suppress expected timeout noise for callers that poll externally + quiet: bool, +} + +impl Default for AwaitStatsOptions { + // Build the default await-stats configuration used when no flags are provided. + fn default() -> Self { + Self { + timeout_ms: DEFAULT_TIMEOUT_MS, + interval_ms: DEFAULT_INTERVAL_MS, + json: false, + commit: "HEAD".to_string(), + quiet: false, + } + } +} + +#[derive(Debug)] +pub enum AwaitStatsError { + // CLI argument errors + BadArgs(String), + // Revision did not resolve to a commit + BadCommit(String), + // Authorship note was not created before the timeout elapsed + Timeout { commit_sha: String, timeout_ms: u64 }, + // Note exists, but cannot be parsed or converted to stats + CorruptNote(String), + // Lower-level git-ai error + GitAi(GitAiError), +} + +impl AwaitStatsError { + // Map await-stats failures to the command's public process exit codes. + fn exit_code(&self) -> i32 { + match self { + Self::Timeout { .. } => 1, + Self::BadArgs(_) | Self::BadCommit(_) => 2, + Self::CorruptNote(_) => 3, + Self::GitAi(_) => 1, + } + } +} + +impl From for AwaitStatsError { + // Wrap shared git-ai errors in the await-stats-specific error type. + fn from(err: GitAiError) -> Self { + Self::GitAi(err) + } +} + +// Run await-stats as a CLI command and terminate the process with the right exit code. +pub fn handle_await_stats(args: &[String]) -> ! { + match run(args) { + Ok(()) => std::process::exit(0), + Err(err) => { + if !matches!(err, AwaitStatsError::Timeout { .. }) || !is_quiet_timeout(args) { + print_error(&err); + } + std::process::exit(err.exit_code()); + } + } +} + +// Parse CLI arguments and execute await-stats without exiting the process. +pub fn run(args: &[String]) -> Result<(), AwaitStatsError> { + let options = parse_options(args)?; + run_with_options(&options) +} + +// Wait for the requested commit's authorship note and print its stats. +fn run_with_options(options: &AwaitStatsOptions) -> Result<(), AwaitStatsError> { + let repo = find_repository(&Vec::::new()).map_err(AwaitStatsError::GitAi)?; + let commit_sha = resolve_commit(&repo, &options.commit)?; + + // The post-commit hook writes authorship stats asynchronously, so wait until + // the note appears before trying to render the final commit stats. + let raw_note = wait_for_note( + &repo, + &commit_sha, + Duration::from_millis(options.timeout_ms), + Duration::from_millis(options.interval_ms), + )? + .ok_or_else(|| AwaitStatsError::Timeout { + commit_sha: commit_sha.clone(), + timeout_ms: options.timeout_ms, + })?; + + // Validate the note separately so corrupt authorship data reports as a note + // problem instead of looking like a stats rendering failure. + AuthorshipLog::deserialize_from_string(&raw_note) + .map_err(|err| AwaitStatsError::CorruptNote(err.to_string()))?; + + let effective_patterns = effective_ignore_patterns(&repo, &[], &[]); + let stats = stats_for_commit_stats(&repo, &commit_sha, &effective_patterns) + .map_err(|err| AwaitStatsError::CorruptNote(err.to_string()))?; + + if options.json { + let json = serde_json::to_string(&stats).map_err(GitAiError::from)?; + println!("{}", json); + } else { + write_stats_to_terminal(&stats, true); + } + + Ok(()) +} + +// Convert raw CLI flags into validated await-stats options. +fn parse_options(args: &[String]) -> Result { + let mut options = AwaitStatsOptions::default(); + let mut i = 0; + + while i < args.len() { + match args[i].as_str() { + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + "--timeout" => { + let value = args + .get(i + 1) + .ok_or_else(|| AwaitStatsError::BadArgs("--timeout requires a value".into()))?; + options.timeout_ms = parse_u64_option("--timeout", value)?; + i += 2; + } + "--interval" => { + let value = args.get(i + 1).ok_or_else(|| { + AwaitStatsError::BadArgs("--interval requires a value".into()) + })?; + options.interval_ms = parse_u64_option("--interval", value)?; + if options.interval_ms == 0 { + return Err(AwaitStatsError::BadArgs( + "--interval must be greater than 0".to_string(), + )); + } + i += 2; + } + "--commit" => { + let value = args + .get(i + 1) + .ok_or_else(|| AwaitStatsError::BadArgs("--commit requires a value".into()))?; + options.commit = value.clone(); + i += 2; + } + "--json" => { + options.json = true; + i += 1; + } + "--quiet" => { + options.quiet = true; + i += 1; + } + other => { + return Err(AwaitStatsError::BadArgs(format!( + "Unknown await-stats argument: {}", + other + ))); + } + } + } + + Ok(options) +} + +// Parse a millisecond option value as an unsigned integer. +fn parse_u64_option(flag: &str, value: &str) -> Result { + value + .parse::() + .map_err(|_| AwaitStatsError::BadArgs(format!("{} requires a non-negative integer", flag))) +} + +// Resolve a revision string to the commit SHA whose authorship note should be read. +fn resolve_commit(repo: &Repository, rev: &str) -> Result { + // Peel tags and other revision objects to the commit that owns the note. + repo.revparse_single(rev) + .and_then(|obj| obj.peel_to_commit()) + .map(|commit| commit.id()) + .map_err(|_| AwaitStatsError::BadCommit(rev.to_string())) +} + +// Poll the git-ai notes namespace until the target commit's authorship note appears. +fn wait_for_note( + repo: &Repository, + commit_sha: &str, + timeout: Duration, + interval: Duration, +) -> Result, AwaitStatsError> { + let start = Instant::now(); + + loop { + // The note may already exist when await-stats is called after a fast hook. + if let Some(note) = read_note(repo, commit_sha) { + return Ok(Some(note)); + } + + let elapsed = start.elapsed(); + if elapsed >= timeout { + return Ok(None); + } + + // Sleep only until the timeout boundary, so short timeouts do not + // overshoot by a full polling interval. + let remaining = timeout.saturating_sub(elapsed); + thread::sleep(interval.min(remaining)); + } +} + +// Detect whether timeout output should be suppressed before parsed options are available. +fn is_quiet_timeout(args: &[String]) -> bool { + args.iter().any(|arg| arg == "--quiet") +} + +// Print the user-facing error message for an await-stats failure. +fn print_error(err: &AwaitStatsError) { + match err { + AwaitStatsError::BadArgs(msg) => { + eprintln!("{}", msg); + eprintln!("Run 'git ai await-stats --help' for usage."); + } + AwaitStatsError::BadCommit(rev) => { + eprintln!("No commit found: {}", rev); + } + AwaitStatsError::Timeout { + commit_sha, + timeout_ms, + } => { + eprintln!( + "[git-ai] timed out waiting for authorship note on commit {} ({}ms)", + short_sha(commit_sha), + timeout_ms + ); + } + AwaitStatsError::CorruptNote(msg) => { + eprintln!("Failed to read authorship note: {}", msg); + } + AwaitStatsError::GitAi(err) => { + eprintln!("await-stats failed: {}", err); + } + } +} + +// Return the short display form of a commit SHA. +fn short_sha(commit_sha: &str) -> &str { + &commit_sha[..commit_sha.len().min(8)] +} + +// Print await-stats usage information to stderr. +fn print_help() { + eprintln!("git ai await-stats - Wait for commit authorship note, then print stats"); + eprintln!(); + eprintln!("Usage: git ai await-stats [options]"); + eprintln!(); + eprintln!("Options:"); + eprintln!(" --timeout Maximum wait time (default: 5000)"); + eprintln!(" --interval Poll interval (default: 100)"); + eprintln!(" --commit Commit to await (default: HEAD)"); + eprintln!(" --json Output stats as JSON"); + eprintln!(" --quiet Suppress timeout output"); + eprintln!(" -h, --help Show this help message"); +} + +#[cfg(test)] +mod tests { + use super::*; + + // Convert string slices into owned CLI argument strings for parser tests. + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + // Verify that omitted flags use the command's documented defaults. + fn parse_defaults() { + assert_eq!( + parse_options(&[]).unwrap(), + AwaitStatsOptions { + timeout_ms: 5_000, + interval_ms: 100, + json: false, + commit: "HEAD".to_string(), + quiet: false, + } + ); + } + + #[test] + // Verify that all supported flags are accepted and stored in options. + fn parse_all_options() { + assert_eq!( + parse_options(&args(&[ + "--timeout", + "8000", + "--interval", + "25", + "--json", + "--commit", + "HEAD~1", + "--quiet", + ])) + .unwrap(), + AwaitStatsOptions { + timeout_ms: 8_000, + interval_ms: 25, + json: true, + commit: "HEAD~1".to_string(), + quiet: true, + } + ); + } + + #[test] + // Verify that flags requiring values reject missing arguments. + fn parse_rejects_missing_values() { + assert!(matches!( + parse_options(&args(&["--timeout"])), + Err(AwaitStatsError::BadArgs(_)) + )); + assert!(matches!( + parse_options(&args(&["--interval"])), + Err(AwaitStatsError::BadArgs(_)) + )); + assert!(matches!( + parse_options(&args(&["--commit"])), + Err(AwaitStatsError::BadArgs(_)) + )); + } + + #[test] + // Verify that the poll interval cannot be zero. + fn parse_rejects_zero_interval() { + assert!(matches!( + parse_options(&args(&["--interval", "0"])), + Err(AwaitStatsError::BadArgs(_)) + )); + } + + #[test] + // Verify that unknown flags fail with a bad-arguments error. + fn parse_rejects_unknown_flags() { + assert!(matches!( + parse_options(&args(&["--wat"])), + Err(AwaitStatsError::BadArgs(_)) + )); + } +} From 878aaf08c7bd9af1b0103cbb9abc336154f5379f Mon Sep 17 00:00:00 2001 From: Alex Buckley Date: Tue, 19 May 2026 22:51:28 -0700 Subject: [PATCH 2/5] feat: register await-stats in git-ai command dispatch Wire the new `await-stats` implementation into the top-level `git ai` CLI so it can be invoked as a first-class subcommand and discovered through built-in help. - `handle_git_ai` dispatches `await-stats` to `commands::await_stats::handle_await_stats`. - `print_help` documents the new subcommand plus its `--timeout`, `--interval`, `--commit`, `--json`, and `--quiet` flags in the shared CLI help output. - `src/commands/mod.rs` exports the new module so the command is compiled into the CLI command set. Co-authored-by: OpenAI GPT 5.5 --- src/commands/git_ai_handlers.rs | 9 +++++++++ src/commands/mod.rs | 1 + 2 files changed, 10 insertions(+) diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 83867c5294..6d16131b1c 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -106,6 +106,9 @@ pub fn handle_git_ai(args: &[String]) { } handle_stats(&args[1..]); } + "await-stats" => { + commands::await_stats::handle_await_stats(&args[1..]); + } "status" => { commands::status::handle_status(&args[1..]); } @@ -333,6 +336,12 @@ fn print_help() { ); eprintln!(" stats [commit] Show AI authorship statistics for a commit"); eprintln!(" --json Output in JSON format"); + eprintln!(" await-stats Wait for commit authorship note, then print stats"); + eprintln!(" --timeout Maximum wait time (default: 5000)"); + eprintln!(" --interval Poll interval (default: 100)"); + eprintln!(" --commit Commit to await (default: HEAD)"); + eprintln!(" --json Output stats as JSON"); + eprintln!(" --quiet Suppress timeout output"); eprintln!(" status Show uncommitted AI authorship status (debug)"); eprintln!(" --json Output in JSON format"); eprintln!(" show Display authorship logs for a revision or range"); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8bf97f32a1..3ba20a4c59 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod await_stats; pub mod blame; pub mod checkpoint_agent; pub mod ci_handlers; From 1f8fa4a320573d685b47899657c6976fd79328dc Mon Sep 17 00:00:00 2001 From: Alex Buckley Date: Tue, 19 May 2026 22:52:46 -0700 Subject: [PATCH 3/5] test: cover note polling and error handling Add integration coverage for the new `await-stats` command and update the test helpers so note-dependent commands wait for daemon output before asserting on results. - `extract_json_object` isolates the JSON payload from command output so the tests can deserialize `CommitStats` directly. - `run_git_ai_raw` invokes the git-ai binary without the higher-level helpers so the tests can assert exact exit codes plus stdout/stderr behavior. - `output_text` combines stdout and stderr into one assertion string for failure cases. - `await_stats_prints_json_when_note_exists` verifies JSON stats output for a commit whose note is already present. - `await_stats_defaults_to_head` verifies the command resolves `HEAD` by default and renders the shared human-readable stats output. - `await_stats_accepts_commit_rev` verifies `--commit` can target a non-`HEAD` revision and report stats for that specific commit. - `await_stats_times_out_when_note_absent` verifies missing notes return exit code `1` with the timeout message. - `await_stats_quiet_suppresses_timeout_output` verifies `--quiet` still exits non-zero on timeout but suppresses stderr output. - `await_stats_bad_commit_exits_2` verifies unresolved revisions return exit code `2` with the expected bad-commit message. - `await_stats_corrupt_note_exits_3` verifies malformed note payloads return exit code `3` with the corrupt-note error message. - `git_ai_command_requires_daemon_sync` now treats `await-stats` as a note-dependent command so the test harness waits for daemon-produced notes before running command assertions. - `tests/integration/main.rs` registers the new integration test module. Co-authored-by: OpenAI GPT 5.5 --- tests/integration/await_stats.rs | 234 +++++++++++++++++++++++++++ tests/integration/main.rs | 1 + tests/integration/repos/test_repo.rs | 2 +- 3 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 tests/integration/await_stats.rs diff --git a/tests/integration/await_stats.rs b/tests/integration/await_stats.rs new file mode 100644 index 0000000000..78ba663739 --- /dev/null +++ b/tests/integration/await_stats.rs @@ -0,0 +1,234 @@ +use crate::repos::test_file::ExpectedLineExt; +use crate::repos::test_repo::{TestRepo, get_binary_path}; +use git_ai::authorship::stats::CommitStats; +use std::fs; +use std::process::{Command, Output}; + +fn extract_json_object(output: &str) -> String { + let start = output.find('{').expect("output should contain JSON"); + let end = output.rfind('}').expect("output should contain JSON"); + output[start..=end].to_string() +} + +fn run_git_ai_raw(repo: &TestRepo, args: &[&str]) -> Output { + let mut command = Command::new(get_binary_path()); + command.args(args).current_dir(repo.path()); + + command.env("HOME", repo.test_home_path()); + command.env( + "GIT_CONFIG_GLOBAL", + repo.test_home_path().join(".gitconfig"), + ); + command.env("XDG_CONFIG_HOME", repo.test_home_path().join(".config")); + command.env("GIT_CONFIG_NOSYSTEM", "1"); + command.env("GIT_AI_DAEMON_HOME", repo.daemon_home_path()); + command.env( + "GIT_AI_DAEMON_CONTROL_SOCKET", + repo.daemon_control_socket_path(), + ); + command.env( + "GIT_AI_DAEMON_TRACE_SOCKET", + repo.daemon_trace_socket_path(), + ); + command.env("GIT_AI_TEST_DB_PATH", repo.test_db_path()); + command.env("GITAI_TEST_DB_PATH", repo.test_db_path()); + if let Some(patch_json) = repo.config_patch_json() { + command.env("GIT_AI_TEST_CONFIG_PATCH", patch_json); + } + + command.output().expect("git-ai command should run") +} + +fn output_text(output: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +#[test] +fn await_stats_prints_json_when_note_exists() { + let repo = TestRepo::new(); + let mut file = repo.filename("await.txt"); + file.set_contents(crate::lines!["AI one".ai(), "AI two".ai()]); + repo.stage_all_and_commit("Add AI lines").unwrap(); + + let raw = repo + .git_ai(&[ + "await-stats", + "--timeout", + "1000", + "--interval", + "10", + "--json", + ]) + .expect("await-stats should succeed"); + let stats: CommitStats = serde_json::from_str(&extract_json_object(&raw)).unwrap(); + + assert_eq!(stats.ai_additions, 2); + assert_eq!(stats.ai_accepted, 2); + assert_eq!(stats.git_diff_added_lines, 2); +} + +#[test] +fn await_stats_defaults_to_head() { + let repo = TestRepo::new(); + let mut file = repo.filename("head.txt"); + file.set_contents(crate::lines!["AI head".ai()]); + repo.stage_all_and_commit("Add HEAD line").unwrap(); + + let output = repo + .git_ai(&["await-stats", "--timeout", "1000", "--interval", "10"]) + .expect("await-stats should succeed"); + + assert!(output.contains("you"), "output was:\n{}", output); + assert!(output.contains("ai"), "output was:\n{}", output); +} + +#[test] +fn await_stats_accepts_commit_rev() { + let repo = TestRepo::new(); + let mut file = repo.filename("revs.txt"); + + file.set_contents(crate::lines!["First AI".ai()]); + let first = repo.stage_all_and_commit("First").unwrap(); + + file.set_contents(crate::lines!["First AI".ai(), "Second AI".ai()]); + repo.stage_all_and_commit("Second").unwrap(); + + let raw = repo + .git_ai(&[ + "await-stats", + "--commit", + &first.commit_sha, + "--timeout", + "1000", + "--interval", + "10", + "--json", + ]) + .expect("await-stats should succeed for explicit commit"); + let stats: CommitStats = serde_json::from_str(&extract_json_object(&raw)).unwrap(); + + assert_eq!(stats.ai_additions, 1); + assert_eq!(stats.git_diff_added_lines, 1); +} + +#[test] +fn await_stats_times_out_when_note_absent() { + let repo = TestRepo::new(); + let mut file = repo.filename("missing-note.txt"); + file.set_contents(crate::lines!["AI line".ai()]); + let commit = repo.stage_all_and_commit("Create note").unwrap(); + + repo.git_og(&["notes", "--ref=ai", "remove", &commit.commit_sha]) + .expect("removing note should succeed"); + + let output = run_git_ai_raw( + &repo, + &[ + "await-stats", + "--timeout", + "0", + "--interval", + "10", + "--commit", + &commit.commit_sha, + ], + ); + + assert_eq!(output.status.code(), Some(1), "{}", output_text(&output)); + assert!( + output_text(&output).contains("timed out waiting for authorship note"), + "{}", + output_text(&output) + ); +} + +#[test] +fn await_stats_quiet_suppresses_timeout_output() { + let repo = TestRepo::new(); + let mut file = repo.filename("quiet-missing-note.txt"); + file.set_contents(crate::lines!["AI line".ai()]); + let commit = repo.stage_all_and_commit("Create quiet note").unwrap(); + + repo.git_og(&["notes", "--ref=ai", "remove", &commit.commit_sha]) + .expect("removing note should succeed"); + + let output = run_git_ai_raw( + &repo, + &[ + "await-stats", + "--timeout", + "0", + "--interval", + "10", + "--commit", + &commit.commit_sha, + "--quiet", + ], + ); + + assert_eq!(output.status.code(), Some(1), "{}", output_text(&output)); + assert_eq!(output_text(&output), ""); +} + +#[test] +fn await_stats_bad_commit_exits_2() { + let repo = TestRepo::new(); + let output = run_git_ai_raw( + &repo, + &["await-stats", "--commit", "definitely-not-a-commit"], + ); + + assert_eq!(output.status.code(), Some(2), "{}", output_text(&output)); + assert!( + output_text(&output).contains("No commit found: definitely-not-a-commit"), + "{}", + output_text(&output) + ); +} + +#[test] +fn await_stats_corrupt_note_exits_3() { + let repo = TestRepo::new(); + let file_path = repo.path().join("corrupt.txt"); + fs::write(&file_path, "AI line\n").unwrap(); + repo.git_ai(&["checkpoint", "mock_ai", "corrupt.txt"]) + .unwrap(); + let commit = repo + .stage_all_and_commit("Create corruptible note") + .unwrap(); + + repo.git_og(&[ + "notes", + "--ref=ai", + "add", + "-f", + "-m", + "not json", + &commit.commit_sha, + ]) + .expect("overwriting note should succeed"); + + let output = run_git_ai_raw( + &repo, + &[ + "await-stats", + "--commit", + &commit.commit_sha, + "--timeout", + "0", + "--interval", + "10", + ], + ); + + assert_eq!(output.status.code(), Some(3), "{}", output_text(&output)); + assert!( + output_text(&output).contains("Failed to read authorship note"), + "{}", + output_text(&output) + ); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index cdcc4dfc02..70942b9e4d 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -14,6 +14,7 @@ mod ai_tab; mod amend; mod amp; mod attribution_tracker_comprehensive; +mod await_stats; mod background_agent_attribution; mod bash_attribution; mod bash_tool_benchmark; diff --git a/tests/integration/repos/test_repo.rs b/tests/integration/repos/test_repo.rs index bb7e9eea82..128228932d 100644 --- a/tests/integration/repos/test_repo.rs +++ b/tests/integration/repos/test_repo.rs @@ -894,7 +894,7 @@ fn parse_checkpoint_request_count(stdout: &str) -> u64 { fn git_ai_command_requires_daemon_sync(args: &[&str]) -> bool { matches!( git_ai_primary_command(args), - Some("blame" | "continue" | "diff" | "prompts" | "search" | "stats") + Some("await-stats" | "blame" | "continue" | "diff" | "prompts" | "search" | "stats") ) } From 96fca345fbdd9bcbf6632870b34e63961cbc60c6 Mon Sep 17 00:00:00 2001 From: Alex Buckley Date: Sat, 23 May 2026 11:04:50 -0700 Subject: [PATCH 4/5] refactor: expose reusable wait and render helper functions --- src/commands/await_stats.rs | 80 +++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/src/commands/await_stats.rs b/src/commands/await_stats.rs index 0f084a15af..b1b786023b 100644 --- a/src/commands/await_stats.rs +++ b/src/commands/await_stats.rs @@ -27,6 +27,16 @@ struct AwaitStatsOptions { quiet: bool, } +#[derive(Debug, Clone)] +pub struct AwaitStatsRuntimeOptions { + pub timeout_ms: u64, + pub interval_ms: u64, + pub json: bool, + pub commit: String, + pub quiet: bool, + pub is_interactive: bool, +} + impl Default for AwaitStatsOptions { // Build the default await-stats configuration used when no flags are provided. fn default() -> Self { @@ -95,12 +105,29 @@ pub fn run(args: &[String]) -> Result<(), AwaitStatsError> { // Wait for the requested commit's authorship note and print its stats. fn run_with_options(options: &AwaitStatsOptions) -> Result<(), AwaitStatsError> { let repo = find_repository(&Vec::::new()).map_err(AwaitStatsError::GitAi)?; - let commit_sha = resolve_commit(&repo, &options.commit)?; + run_with_repo( + &repo, + &AwaitStatsRuntimeOptions { + timeout_ms: options.timeout_ms, + interval_ms: options.interval_ms, + json: options.json, + commit: options.commit.clone(), + quiet: options.quiet, + is_interactive: true, + }, + ) +} + +pub fn run_with_repo( + repo: &Repository, + options: &AwaitStatsRuntimeOptions, +) -> Result<(), AwaitStatsError> { + let commit_sha = resolve_commit(repo, &options.commit)?; // The post-commit hook writes authorship stats asynchronously, so wait until // the note appears before trying to render the final commit stats. - let raw_note = wait_for_note( - &repo, + let raw_note = wait_for_authorship_note( + repo, &commit_sha, Duration::from_millis(options.timeout_ms), Duration::from_millis(options.interval_ms), @@ -112,21 +139,8 @@ fn run_with_options(options: &AwaitStatsOptions) -> Result<(), AwaitStatsError> // Validate the note separately so corrupt authorship data reports as a note // problem instead of looking like a stats rendering failure. - AuthorshipLog::deserialize_from_string(&raw_note) - .map_err(|err| AwaitStatsError::CorruptNote(err.to_string()))?; - - let effective_patterns = effective_ignore_patterns(&repo, &[], &[]); - let stats = stats_for_commit_stats(&repo, &commit_sha, &effective_patterns) - .map_err(|err| AwaitStatsError::CorruptNote(err.to_string()))?; - - if options.json { - let json = serde_json::to_string(&stats).map_err(GitAiError::from)?; - println!("{}", json); - } else { - write_stats_to_terminal(&stats, true); - } - - Ok(()) + validate_authorship_note(&raw_note)?; + render_stats_for_commit(repo, &commit_sha, options.json, options.is_interactive) } // Convert raw CLI flags into validated await-stats options. @@ -194,7 +208,7 @@ fn parse_u64_option(flag: &str, value: &str) -> Result { } // Resolve a revision string to the commit SHA whose authorship note should be read. -fn resolve_commit(repo: &Repository, rev: &str) -> Result { +pub fn resolve_commit(repo: &Repository, rev: &str) -> Result { // Peel tags and other revision objects to the commit that owns the note. repo.revparse_single(rev) .and_then(|obj| obj.peel_to_commit()) @@ -203,7 +217,7 @@ fn resolve_commit(repo: &Repository, rev: &str) -> Result Result<(), AwaitStatsError> { + AuthorshipLog::deserialize_from_string(raw_note) + .map(|_| ()) + .map_err(|err| AwaitStatsError::CorruptNote(err.to_string())) +} + +pub fn render_stats_for_commit( + repo: &Repository, + commit_sha: &str, + json: bool, + is_interactive: bool, +) -> Result<(), AwaitStatsError> { + let effective_patterns = effective_ignore_patterns(repo, &[], &[]); + let stats = stats_for_commit_stats(repo, commit_sha, &effective_patterns) + .map_err(|err| AwaitStatsError::CorruptNote(err.to_string()))?; + + if json { + let json = serde_json::to_string(&stats).map_err(GitAiError::from)?; + println!("{}", json); + } else { + write_stats_to_terminal(&stats, is_interactive); + } + + Ok(()) +} + // Detect whether timeout output should be suppressed before parsed options are available. fn is_quiet_timeout(args: &[String]) -> bool { args.iter().any(|arg| arg == "--quiet") From 457d398bb4a35925decccc9e66a1b57c93f81425 Mon Sep 17 00:00:00 2001 From: Alex Buckley Date: Sat, 23 May 2026 11:06:20 -0700 Subject: [PATCH 5/5] feat: reuse await-stats flow after wrapper commits --- src/commands/git_handlers.rs | 39 ++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/src/commands/git_handlers.rs b/src/commands/git_handlers.rs index 5af1cdabb2..2a24e618ee 100644 --- a/src/commands/git_handlers.rs +++ b/src/commands/git_handlers.rs @@ -255,10 +255,7 @@ fn parse_alias_tokens(value: &str) -> Option> { /// the daemon-produced authorship note and display stats inline when available. /// Mirrors the same skip/display rules as plain wrapper mode in post_commit.rs. fn maybe_show_async_post_commit_stats(parsed: &ParsedGitInvocation, repo: &Repository) { - use crate::authorship::ignore::effective_ignore_patterns; - use crate::authorship::stats::{stats_for_commit_stats, write_stats_to_terminal}; use crate::git::cli_parser::is_dry_run; - use crate::git::notes_api::read_note as show_authorship_note; use std::io::IsTerminal; // Respect the same suppression flags as the synchronous wrapper path. @@ -298,24 +295,25 @@ fn maybe_show_async_post_commit_stats(parsed: &ParsedGitInvocation, repo: &Repos std::time::Duration::from_millis(500) }; - // Poll for the authorship note the daemon should be producing. let poll_interval = std::time::Duration::from_millis(25); - let start = std::time::Instant::now(); - let note_found = loop { - if show_authorship_note(repo, &commit_sha).is_some() { - break true; - } - if start.elapsed() >= timeout { - break false; + let note = match crate::commands::await_stats::wait_for_authorship_note( + repo, + &commit_sha, + timeout, + poll_interval, + ) { + Ok(Some(note)) => note, + Ok(None) => { + eprintln!( + "[git-ai] still processing commit {}... run `git ai stats` to see stats.", + &commit_sha[..std::cmp::min(8, commit_sha.len())] + ); + return; } - std::thread::sleep(poll_interval); + Err(_) => return, }; - if !note_found { - eprintln!( - "[git-ai] still processing commit {}... run `git ai stats` to see stats.", - &commit_sha[..std::cmp::min(8, commit_sha.len())] - ); + if crate::commands::await_stats::validate_authorship_note(¬e).is_err() { return; } @@ -333,7 +331,7 @@ fn maybe_show_async_post_commit_stats(parsed: &ParsedGitInvocation, repo: &Repos } // Run the same cost estimation the sync path uses. - let ignore_patterns = effective_ignore_patterns(repo, &[], &[]); + let ignore_patterns = crate::authorship::ignore::effective_ignore_patterns(repo, &[], &[]); if let Ok(estimate) = crate::authorship::post_commit::estimate_stats_cost_for_head( repo, &commit_sha, @@ -347,10 +345,7 @@ fn maybe_show_async_post_commit_stats(parsed: &ParsedGitInvocation, repo: &Repos return; } - // Compute and display the full stats. - if let Ok(stats) = stats_for_commit_stats(repo, &commit_sha, &ignore_patterns) { - write_stats_to_terminal(&stats, true); - } + let _ = crate::commands::await_stats::render_stats_for_commit(repo, &commit_sha, false, true); } fn head_state_to_repo_context(