Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/commands/git_ai_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions src/commands/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
}
}
Comment on lines +218 to +242

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Identical daemon-sync helper is duplicated across two files instead of being shared

The daemon sync logic is copy-pasted verbatim into two separate private functions (sync_daemon_before_stats_read at src/commands/git_ai_handlers.rs:1062 and sync_daemon_before_status_read at src/commands/status.rs:218) differing only in a debug log string, rather than being extracted into a single reusable helper.

Impact: Violates the repository's mandatory code-reuse rule and increases maintenance burden for any future changes to the sync logic.

AGENTS.md rule #5 violation details

AGENTS.md states: "Reuse existing code. This is a large codebase. For nearly any operation you need, there is almost certainly a helper function already available and tested. Reuse code as much as practical -- it also keeps diffs small. PRs that fail to reuse existing code where applicable will be rejected immediately."

Both functions have identical structure:

  1. Get workdir from repo
  2. Get DaemonConfig from env/default paths
  3. Build a ControlRequest::SyncFamily with the workdir
  4. Call send_control_request and handle Ok/Err

The only difference is the debug log message ("stats" vs "status"). These should be a single shared function, possibly accepting a &str context label for the debug message.

src/commands/git_ai_handlers.rs:1062-1086 is identical to src/commands/status.rs:218-242.

Prompt for agents
The functions sync_daemon_before_stats_read (in src/commands/git_ai_handlers.rs:1062-1086) and sync_daemon_before_status_read (in src/commands/status.rs:218-242) are identical except for the debug log message string. Extract a single shared helper function (e.g., in a common module or as a public utility) that accepts a context label parameter (like "stats" or "status") for the debug messages. Then have both call sites use that shared helper. This satisfies the AGENTS.md rule about reusing existing code and keeping diffs small.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


fn format_time_ago(timestamp: u64) -> String {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand Down
71 changes: 71 additions & 0 deletions tests/integration/cursor.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,6 +15,19 @@ fn parse_cursor(hook_input: &str) -> Result<Vec<ParsedHookEvent>, 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");
Expand Down Expand Up @@ -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();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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;
Expand Down
92 changes: 92 additions & 0 deletions tests/integration/github_copilot.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,6 +13,19 @@ fn parse_copilot(hook_input: &str) -> Result<Vec<ParsedHookEvent>, 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 {
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down
36 changes: 36 additions & 0 deletions tests/integration/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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();
Expand Down
Loading