diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 99c153e9..a62c5e8c 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -782,6 +782,47 @@ fn handle_checkpoint(args: &[String]) { let checkpoint_start = std::time::Instant::now(); let agent_tool = agent_run_result.as_ref().map(|r| r.agent_id.tool.clone()); + + let external_files: Vec = agent_run_result + .as_ref() + .and_then(|r| { + let paths = if r.checkpoint_kind == CheckpointKind::Human { + r.will_edit_filepaths.as_ref() + } else { + r.edited_filepaths.as_ref() + }; + paths.map(|p| { + let repo_workdir = repo.workdir().ok(); + p.iter() + .filter_map(|path| { + let workdir = repo_workdir.as_ref()?; + let path_buf = if std::path::Path::new(path).is_absolute() { + std::path::PathBuf::from(path) + } else { + workdir.join(path) + }; + if repo.path_is_in_workdir(&path_buf) { + None + } else { + let abs = if std::path::Path::new(path).is_absolute() { + path.clone() + } else { + workdir.join(path).to_string_lossy().to_string() + }; + Some(abs) + } + }) + .collect::>() + }) + }) + .unwrap_or_default(); + + let external_agent_base = if !external_files.is_empty() { + agent_run_result.as_ref().cloned() + } else { + None + }; + commands::git_hook_handlers::ensure_repo_level_hooks_for_checkpoint(&repo); let checkpoint_result = commands::checkpoint::run( &repo, @@ -793,30 +834,100 @@ fn handle_checkpoint(args: &[String]) { agent_run_result, false, ); + let local_checkpoint_failed = checkpoint_result.is_err(); match checkpoint_result { Ok((_, files_edited, _)) => { let elapsed = checkpoint_start.elapsed(); log_performance_for_checkpoint(files_edited, elapsed, checkpoint_kind); eprintln!("Checkpoint completed in {:?}", elapsed); - - // Flush logs and metrics after checkpoint (skip for human checkpoints) - if checkpoint_kind != CheckpointKind::Human { - observability::spawn_background_flush(); - } } Err(e) => { let elapsed = checkpoint_start.elapsed(); eprintln!("Checkpoint failed after {:?} with error {}", elapsed, e); let context = serde_json::json!({ "function": "checkpoint", - "agent": agent_tool.unwrap_or_default(), + "agent": agent_tool.clone().unwrap_or_default(), "duration": elapsed.as_millis(), "checkpoint_kind": format!("{:?}", checkpoint_kind) }); observability::log_error(&e, Some(context)); - std::process::exit(0); } } + + if !external_files.is_empty() + && let Some(base_result) = external_agent_base + { + let (repo_files, orphan_files) = group_files_by_repository(&external_files, None); + + if !orphan_files.is_empty() { + eprintln!( + "Warning: {} cross-repo file(s) are not in any git repository and will be skipped", + orphan_files.len() + ); + } + + for (repo_workdir, (ext_repo, repo_file_paths)) in repo_files { + if !config.is_allowed_repository(&Some(ext_repo.clone())) { + continue; + } + + let ext_user_name = match ext_repo.config_get_str("user.name") { + Ok(Some(name)) if !name.trim().is_empty() => name, + _ => "unknown".to_string(), + }; + + let mut modified = base_result.clone(); + modified.repo_working_dir = Some(repo_workdir.to_string_lossy().to_string()); + if base_result.checkpoint_kind == CheckpointKind::Human { + modified.will_edit_filepaths = Some(repo_file_paths); + modified.edited_filepaths = None; + } else { + modified.edited_filepaths = Some(repo_file_paths); + modified.will_edit_filepaths = None; + } + + commands::git_hook_handlers::ensure_repo_level_hooks_for_checkpoint(&ext_repo); + match commands::checkpoint::run( + &ext_repo, + &ext_user_name, + checkpoint_kind, + false, + false, + false, + Some(modified), + false, + ) { + Ok((_, files_edited, _)) => { + eprintln!( + "Cross-repo checkpoint for {} completed ({} files)", + repo_workdir.display(), + files_edited + ); + } + Err(e) => { + eprintln!( + "Cross-repo checkpoint for {} failed: {}", + repo_workdir.display(), + e + ); + let context = serde_json::json!({ + "function": "checkpoint", + "repo": repo_workdir.to_string_lossy(), + "checkpoint_kind": format!("{:?}", checkpoint_kind) + }); + observability::log_error(&e, Some(context)); + } + } + } + } + + if checkpoint_kind != CheckpointKind::Human { + observability::spawn_background_flush(); + } + + if local_checkpoint_failed { + std::process::exit(0); + } } fn handle_ai_blame(args: &[String]) { diff --git a/tests/multi_repo_workspace.rs b/tests/multi_repo_workspace.rs index 0e2f49f5..774c9990 100644 --- a/tests/multi_repo_workspace.rs +++ b/tests/multi_repo_workspace.rs @@ -7,6 +7,12 @@ //! 2. Grouping files by their containing repository //! 3. Handling submodules correctly (should be ignored in favor of parent repo) //! 4. Edge cases with nested git directories +//! 5. Cross-repo checkpoints: AI edits from one repo to files in another repo + +#[macro_use] +mod repos; +use repos::test_file::ExpectedLineExt; +use repos::test_repo::TestRepo; use git_ai::error::GitAiError; use git_ai::git::repository::{ @@ -910,3 +916,128 @@ fn test_repository_isolation() { cleanup_tmp_dir(&workspace); } + +#[test] +fn test_cross_repo_checkpoint_creates_working_log_in_target_repo() { + let repo1 = TestRepo::new(); + let repo2 = TestRepo::new(); + + let mut file = repo2.filename("test.txt"); + file.set_contents(lines!["Line 1", "Line 2", "Line 3"]); + repo2.stage_all_and_commit("Initial commit").unwrap(); + + fs::write( + repo2.path().join("test.txt"), + "Line 1\nLine 2\nLine 3\nAI Line 1\nAI Line 2\n", + ) + .unwrap(); + + let repo2_file_abs = repo2.canonical_path().join("test.txt"); + let abs_path_str = repo2_file_abs.to_str().unwrap(); + + repo2 + .git_ai_from_working_dir( + &repo1.canonical_path(), + &["checkpoint", "mock_ai", abs_path_str], + ) + .unwrap(); + + let working_log = repo2.current_working_logs(); + let ai_files = working_log.all_ai_touched_files().unwrap_or_default(); + assert!( + !ai_files.is_empty(), + "Cross-repo checkpoint should create working log entries in the target repo (repo2), but found none. \ + This means the checkpoint from repo1's working directory failed to write to repo2's working log." + ); +} + +#[test] +fn test_cross_repo_checkpoint_ai_attribution_on_commit() { + let repo1 = TestRepo::new(); + let repo2 = TestRepo::new(); + + let mut file = repo2.filename("test.txt"); + file.set_contents(lines!["Line 1", "Line 2", "Line 3"]); + repo2.stage_all_and_commit("Initial commit").unwrap(); + + fs::write( + repo2.path().join("test.txt"), + "Line 1\nLine 2\nLine 3\nAI Line 1\nAI Line 2\n", + ) + .unwrap(); + + let repo2_file_abs = repo2.canonical_path().join("test.txt"); + let abs_path_str = repo2_file_abs.to_str().unwrap(); + + repo2 + .git_ai_from_working_dir( + &repo1.canonical_path(), + &["checkpoint", "mock_ai", abs_path_str], + ) + .unwrap(); + + let commit = repo2.stage_all_and_commit("AI edits from repo1").unwrap(); + + assert!( + !commit.authorship_log.attestations.is_empty(), + "Cross-repo AI edits should be attributed to AI, not human. \ + The checkpoint was run from repo1's working directory for a file in repo2, \ + but the commit in repo2 shows no AI attestations." + ); +} + +#[test] +fn test_cross_repo_checkpoint_preserves_local_repo_checkpoint() { + let repo1 = TestRepo::new(); + let repo2 = TestRepo::new(); + + let mut file1 = repo1.filename("local.txt"); + file1.set_contents(lines!["Local 1", "Local 2"]); + repo1 + .stage_all_and_commit("Initial commit in repo1") + .unwrap(); + + let mut file2 = repo2.filename("remote.txt"); + file2.set_contents(lines!["Remote 1", "Remote 2"]); + repo2 + .stage_all_and_commit("Initial commit in repo2") + .unwrap(); + + fs::write( + repo1.path().join("local.txt"), + "Local 1\nLocal 2\nAI local line\n", + ) + .unwrap(); + fs::write( + repo2.path().join("remote.txt"), + "Remote 1\nRemote 2\nAI remote line\n", + ) + .unwrap(); + + let repo1_file_abs = repo1.canonical_path().join("local.txt"); + let repo2_file_abs = repo2.canonical_path().join("remote.txt"); + + repo1 + .git_ai_from_working_dir( + &repo1.canonical_path(), + &[ + "checkpoint", + "mock_ai", + repo1_file_abs.to_str().unwrap(), + repo2_file_abs.to_str().unwrap(), + ], + ) + .unwrap(); + + let repo1_commit = repo1.stage_all_and_commit("AI edits in repo1").unwrap(); + assert!( + !repo1_commit.authorship_log.attestations.is_empty(), + "Local repo (repo1) should still have AI attestations when cross-repo files are also checkpointed" + ); + + let repo2_commit = repo2.stage_all_and_commit("AI edits in repo2").unwrap(); + assert!( + !repo2_commit.authorship_log.attestations.is_empty(), + "Cross-repo (repo2) should also have AI attestations" + ); +}