diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 2c14129a5..2548be642 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -4,7 +4,7 @@ use crate::authorship::range_authorship; use crate::authorship::stats::stats_command; use crate::commands; use crate::config; -use crate::daemon::ControlRequest; +use crate::daemon::{ControlRequest, DaemonConfig, send_control_request}; use crate::git::find_repository; use crate::git::find_repository_in_path; use crate::git::repository::{CommitRange, Repository}; @@ -1020,6 +1020,7 @@ fn handle_stats(args: &[String]) { } let effective_patterns = effective_ignore_patterns(&repo, &ignore_patterns, &[]); + sync_daemon_before_stats_read(&repo); // Handle commit range if detected if let Some(range) = commit_range { @@ -1058,6 +1059,32 @@ fn handle_stats(args: &[String]) { } } +fn sync_daemon_before_stats_read(repo: &Repository) { + let Ok(workdir) = repo.workdir() else { + return; + }; + let Ok(config) = DaemonConfig::from_env_or_default_paths() else { + return; + }; + let request = ControlRequest::SyncFamily { + repo_working_dir: workdir.to_string_lossy().to_string(), + }; + match send_control_request(&config.control_socket_path, &request) { + Ok(response) if response.ok => {} + Ok(response) => { + tracing::debug!( + "daemon sync before stats failed: {}", + response + .error + .unwrap_or_else(|| "unknown error".to_string()) + ); + } + Err(error) => { + tracing::debug!("daemon sync before stats unavailable: {}", error); + } + } +} + /// Normalise a revision token that the user may have typed with a lowercase /// "head" prefix. On case-insensitive file systems (macOS) git accepts both /// "head" and "HEAD", but in a linked worktree "head" can resolve to the diff --git a/src/commands/status.rs b/src/commands/status.rs index 8d9eaa755..2920f1a8a 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -4,6 +4,7 @@ use crate::authorship::ignore::{ use crate::authorship::stats::{CommitStats, stats_from_authorship_log, write_stats_to_terminal}; use crate::authorship::virtual_attribution::VirtualAttributions; use crate::authorship::working_log::CheckpointKind; +use crate::daemon::{ControlRequest, DaemonConfig, send_control_request}; use crate::error::GitAiError; use crate::git::find_repository; use crate::git::repo_storage::InitialAttributions; @@ -54,6 +55,8 @@ pub fn handle_status(args: &[String]) { fn run_status(json: bool, diff_only: bool) -> Result<(), GitAiError> { let repo = find_repository(&[])?; + sync_daemon_before_status_read(&repo); + let ignore_patterns = effective_ignore_patterns(&repo, &[], &[]); let ignore_matcher = build_ignore_matcher(&ignore_patterns); @@ -212,6 +215,32 @@ fn run_status(json: bool, diff_only: bool) -> Result<(), GitAiError> { Ok(()) } +fn sync_daemon_before_status_read(repo: &Repository) { + let Ok(workdir) = repo.workdir() else { + return; + }; + let Ok(config) = DaemonConfig::from_env_or_default_paths() else { + return; + }; + let request = ControlRequest::SyncFamily { + repo_working_dir: workdir.to_string_lossy().to_string(), + }; + match send_control_request(&config.control_socket_path, &request) { + Ok(response) if response.ok => {} + Ok(response) => { + tracing::debug!( + "daemon sync before status failed: {}", + response + .error + .unwrap_or_else(|| "unknown error".to_string()) + ); + } + Err(error) => { + tracing::debug!("daemon sync before status unavailable: {}", error); + } + } +} + fn format_time_ago(timestamp: u64) -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/tests/integration/cursor.rs b/tests/integration/cursor.rs index 5452f4b70..880c1fa48 100644 --- a/tests/integration/cursor.rs +++ b/tests/integration/cursor.rs @@ -1,6 +1,7 @@ use crate::repos::test_file::ExpectedLineExt; use crate::repos::test_repo::{TestRepo, real_git_executable}; use crate::test_utils::fixture_path; +use git_ai::authorship::stats::CommitStats; use git_ai::commands::checkpoint_agent::presets::{ParsedHookEvent, resolve_preset}; use git_ai::error::GitAiError; use git_ai::streams::agent::Agent; @@ -14,6 +15,19 @@ fn parse_cursor(hook_input: &str) -> Result, GitAiError> { resolve_preset("cursor")?.parse(hook_input, "t_test") } +fn extract_json_object(output: &str) -> String { + let start = output.find('{').unwrap_or(0); + let end = output.rfind('}').unwrap_or(output.len().saturating_sub(1)); + output[start..=end].to_string() +} + +fn stats_without_harness_pre_sync(repo: &TestRepo, args: &[&str]) -> CommitStats { + let raw = repo + .git_ai_without_pre_sync_for_test(args) + .expect("git-ai stats should succeed"); + serde_json::from_str(&extract_json_object(&raw)).expect("valid stats json") +} + #[test] fn test_cursor_raw_event_fidelity() { let fixture = fixture_path("cursor-session-simple.jsonl"); @@ -462,6 +476,63 @@ fn test_cursor_e2e_with_resync() { // Note: Messages field has been removed from SessionRecord } +#[test] +fn test_cursor_editor_commit_immediate_stats_waits_for_daemon_note() { + use std::fs; + + let repo = TestRepo::new_with_daemon_env(&[( + "GIT_AI_TEST_DELAY_SIDE_EFFECT_MS_FOR_COMMAND", + "commit=750", + )]); + let jsonl_fixture = fixture_path("cursor-session-simple.jsonl"); + let jsonl_path_str = jsonl_fixture.to_string_lossy().to_string(); + + fs::create_dir_all(repo.path().join("src")).unwrap(); + let file_path = repo.path().join("src/main.rs"); + fs::write(&file_path, "fn main() {\n println!(\"base\");\n}\n").unwrap(); + repo.git(&["add", "src/main.rs"]).unwrap(); + repo.commit("Initial commit").unwrap(); + let mut file = repo.filename("src/main.rs"); + file.assert_committed_lines(crate::lines![ + "fn main() {".unattributed_human(), + " println!(\"base\");".unattributed_human(), + "}".unattributed_human(), + ]); + + fs::write( + &file_path, + "fn main() {\n println!(\"base\");\n println!(\"cursor chat edit\");\n}\n", + ) + .unwrap(); + + let hook_input = serde_json::json!({ + "conversation_id": TEST_CONVERSATION_ID, + "workspace_roots": [repo.canonical_path().to_string_lossy().to_string()], + "hook_event_name": "postToolUse", + "tool_name": "Write", + "tool_input": { "file_path": file_path.to_string_lossy().to_string() }, + "model": "model-name-from-hook-test", + "transcript_path": jsonl_path_str + }) + .to_string(); + repo.git_ai(&["checkpoint", "cursor", "--hook-input", &hook_input]) + .unwrap(); + + let status_raw = repo.git_ai(&["status", "--json"]).unwrap(); + let status: serde_json::Value = + serde_json::from_str(&extract_json_object(&status_raw)).expect("valid status json"); + assert_eq!(status["stats"]["ai_additions"].as_u64(), Some(1)); + + repo.git(&["add", "src/main.rs"]).unwrap(); + repo.git_without_test_sync_for_test(&["commit", "-m", "Cursor chat commit"], &[]) + .unwrap(); + + let stats = stats_without_harness_pre_sync(&repo, &["stats", "HEAD", "--json"]); + assert_eq!(stats.git_diff_added_lines, 1); + assert_eq!(stats.ai_additions, 1); + assert_eq!(stats.unknown_additions, 0); +} + #[test] fn test_cursor_checkpoint_routes_nested_worktree_file_to_worktree_repo() { use git_ai::git::repository::find_repository_in_path; diff --git a/tests/integration/github_copilot.rs b/tests/integration/github_copilot.rs index 00f122feb..dec7059e3 100644 --- a/tests/integration/github_copilot.rs +++ b/tests/integration/github_copilot.rs @@ -1,4 +1,6 @@ +use crate::repos::test_file::ExpectedLineExt; use crate::test_utils::{fixture_path, load_fixture}; +use git_ai::authorship::stats::CommitStats; use git_ai::commands::checkpoint_agent::presets::{ParsedHookEvent, resolve_preset}; use git_ai::error::GitAiError; use git_ai::streams::agent::Agent; @@ -11,6 +13,19 @@ fn parse_copilot(hook_input: &str) -> Result, GitAiError> { resolve_preset("github-copilot")?.parse(hook_input, "t_test") } +fn extract_json_object(output: &str) -> String { + let start = output.find('{').unwrap_or(0); + let end = output.rfind('}').unwrap_or(output.len().saturating_sub(1)); + output[start..=end].to_string() +} + +fn stats_without_harness_pre_sync(repo: &crate::repos::test_repo::TestRepo) -> CommitStats { + let raw = repo + .git_ai_without_pre_sync_for_test(&["stats", "HEAD", "--json"]) + .expect("git-ai stats should succeed"); + serde_json::from_str(&extract_json_object(&raw)).expect("valid stats json") +} + /// Ensure CODESPACES and REMOTE_CONTAINERS are not set (they cause early return in transcript parsing) fn ensure_clean_env() { unsafe { @@ -640,6 +655,83 @@ fn test_copilot_preset_vscode_claude_transcript_path_is_rejected() { ); } +#[test] +#[serial_test::serial(copilot_env)] +fn test_copilot_vscode_commit_immediate_stats_waits_for_daemon_note() { + ensure_clean_env(); + + let repo = crate::repos::test_repo::TestRepo::new_with_daemon_env(&[( + "GIT_AI_TEST_DELAY_SIDE_EFFECT_MS_FOR_COMMAND", + "commit=750", + )]); + + let transcript_dir = tempfile::tempdir().unwrap(); + let transcripts_dir = transcript_dir + .path() + .join("workspaceStorage") + .join("workspace-id") + .join("GitHub.copilot-chat") + .join("transcripts"); + fs::create_dir_all(&transcripts_dir).unwrap(); + let transcript_path = transcripts_dir.join("copilot-session.jsonl"); + fs::write(&transcript_path, r#"{"requests": []}"#).unwrap(); + let transcript_path_str = transcript_path.to_string_lossy().to_string(); + + fs::create_dir_all(repo.path().join("src")).unwrap(); + let file_path = repo.path().join("src/main.ts"); + fs::write(&file_path, "export const base = 1;\n").unwrap(); + repo.git(&["add", "src/main.ts"]).unwrap(); + repo.commit("Initial commit").unwrap(); + let mut file = repo.filename("src/main.ts"); + file.assert_committed_lines(crate::lines!["export const base = 1;".unattributed_human(),]); + + let common_hook = |event_name: &str| { + json!({ + "hookEventName": event_name, + "cwd": repo.canonical_path().to_string_lossy().to_string(), + "toolName": "copilot_replaceString", + "toolInput": { "file_path": file_path.to_string_lossy().to_string() }, + "sessionId": "copilot-vscode-commit-stats", + "transcript_path": transcript_path_str + }) + .to_string() + }; + + repo.git_ai(&[ + "checkpoint", + "github-copilot", + "--hook-input", + &common_hook("PreToolUse"), + ]) + .unwrap(); + fs::write( + &file_path, + "export const base = 1;\nexport const fromCopilot = true;\n", + ) + .unwrap(); + repo.git_ai(&[ + "checkpoint", + "github-copilot", + "--hook-input", + &common_hook("PostToolUse"), + ]) + .unwrap(); + + let status_raw = repo.git_ai(&["status", "--json"]).unwrap(); + let status: serde_json::Value = + serde_json::from_str(&extract_json_object(&status_raw)).expect("valid status json"); + assert_eq!(status["stats"]["ai_additions"].as_u64(), Some(1)); + + repo.git(&["add", "src/main.ts"]).unwrap(); + repo.git_without_test_sync_for_test(&["commit", "-m", "Copilot VS Code commit"], &[]) + .unwrap(); + + let stats = stats_without_harness_pre_sync(&repo); + assert_eq!(stats.git_diff_added_lines, 1); + assert_eq!(stats.ai_additions, 1); + assert_eq!(stats.unknown_additions, 0); +} + // ============================================================================ // VS Code model lookup tests // ============================================================================ diff --git a/tests/integration/stats.rs b/tests/integration/stats.rs index a47b5a758..6847fcda1 100644 --- a/tests/integration/stats.rs +++ b/tests/integration/stats.rs @@ -21,6 +21,14 @@ fn stats_from_args(repo: &TestRepo, args: &[&str]) -> CommitStats { serde_json::from_str(&json).expect("valid stats json") } +fn stats_from_args_without_pre_sync(repo: &TestRepo, args: &[&str]) -> CommitStats { + let raw = repo + .git_ai_without_pre_sync_for_test(args) + .expect("git-ai stats should succeed"); + let json = extract_json_object(&raw); + serde_json::from_str(&json).expect("valid stats json") +} + fn run_git(cwd: &Path, args: &[&str]) { let output = Command::new("git") .args(args) @@ -563,6 +571,34 @@ fn test_stats_keeps_negative_linguist_patterns_counted() { assert_eq!(stats.ai_additions, 1); } +#[test] +fn test_copied_code_without_agent_hook_remains_untracked_after_immediate_stats() { + let repo = TestRepo::new_with_daemon_env(&[( + "GIT_AI_TEST_DELAY_SIDE_EFFECT_MS_FOR_COMMAND", + "commit=750", + )]); + + repo.filename("README.md") + .set_contents(crate::lines!["# Project"]); + repo.stage_all_and_commit("Initial commit").unwrap(); + let mut readme = repo.filename("README.md"); + readme.assert_committed_lines(crate::lines!["# Project".unattributed_human(),]); + + fs::write( + repo.path().join("copied.rs"), + "pub fn copied() {\n println!(\"from clipboard\");\n}\n", + ) + .unwrap(); + repo.git(&["add", "copied.rs"]).unwrap(); + repo.git_without_test_sync_for_test(&["commit", "-m", "Paste copied code"], &[]) + .unwrap(); + + let stats = stats_from_args_without_pre_sync(&repo, &["stats", "HEAD", "--json"]); + assert_eq!(stats.git_diff_added_lines, 3); + assert_eq!(stats.ai_additions, 0); + assert_eq!(stats.unknown_additions, 3); +} + #[test] fn test_stats_in_bare_clone_uses_root_gitattributes_linguist_generated() { let repo = TestRepo::new();