From 0bc87096c16766c5e9e8f38599824a126edede50 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 02:56:06 +0000 Subject: [PATCH 1/5] fix: handle cross-repo checkpoints when CWD is a different git repo When an AI agent working from repo-1 edits files in repo-2, the checkpoint was only written to repo-1's working log. Files in repo-2 were silently filtered out by path_is_in_workdir, causing all changes to show as 'human' when committing in repo-2. After running the local repo checkpoint, detect any edited files outside the current repo's workdir, group them by their containing repository, and run separate checkpoints for each external repo. Adds three E2E tests covering: - Working log creation in the target repo - AI attribution on commit in the target repo - Preservation of local repo checkpoint alongside cross-repo Co-Authored-By: Sasha Varlamov --- src/commands/git_ai_handlers.rs | 122 +++++++++++++++++++++++++++-- tests/multi_repo_workspace.rs | 131 ++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 6 deletions(-) diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 99c153e94..334770e7b 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -782,6 +782,50 @@ 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 { + std::path::Path::new(&repository_working_dir) + .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, @@ -798,18 +842,13 @@ fn handle_checkpoint(args: &[String]) { 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) }); @@ -817,6 +856,77 @@ fn handle_checkpoint(args: &[String]) { std::process::exit(0); } } + + if !external_files.is_empty() { + if 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(); + } } fn handle_ai_blame(args: &[String]) { diff --git a/tests/multi_repo_workspace.rs b/tests/multi_repo_workspace.rs index 0e2f49f51..774c99907 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" + ); +} From de4e7a08ede0c69d17a6a2b35f4e4180b0480a93 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 20 Feb 2026 22:04:20 -0500 Subject: [PATCH 2/5] Update src/commands/git_ai_handlers.rs Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/commands/git_ai_handlers.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 334770e7b..1df331d13 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -807,8 +807,7 @@ fn handle_checkpoint(args: &[String]) { let abs = if std::path::Path::new(path).is_absolute() { path.clone() } else { - std::path::Path::new(&repository_working_dir) - .join(path) + workdir.join(path) .to_string_lossy() .to_string() }; From 1550ac8e8ac5c5408149995cde214026a169e4d2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:10:42 +0000 Subject: [PATCH 3/5] style: fix formatting for Rust 1.93.0 rustfmt Co-Authored-By: Sasha Varlamov --- src/commands/git_ai_handlers.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 1df331d13..547aa5040 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -807,9 +807,7 @@ fn handle_checkpoint(args: &[String]) { let abs = if std::path::Path::new(path).is_absolute() { path.clone() } else { - workdir.join(path) - .to_string_lossy() - .to_string() + workdir.join(path).to_string_lossy().to_string() }; Some(abs) } From efbfafae7d03b288eb08b3ac8d8d5afd4e604723 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 05:18:02 +0000 Subject: [PATCH 4/5] fix: collapse nested if to satisfy clippy collapsible_if lint Co-Authored-By: Sasha Varlamov --- src/commands/git_ai_handlers.rs | 112 ++++++++++++++++---------------- 1 file changed, 56 insertions(+), 56 deletions(-) diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 547aa5040..1bd3bc812 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -854,68 +854,68 @@ fn handle_checkpoint(args: &[String]) { } } - if !external_files.is_empty() { - if let Some(base_result) = external_agent_base { - let (repo_files, orphan_files) = group_files_by_repository(&external_files, None); + 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() - ); + 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; } - 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 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; + } - 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 + ); } - - 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)); - } + 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)); } } } From 80eef2ef3beeeb61bfdb8210ce7f37e280b8b956 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:47:43 +0000 Subject: [PATCH 5/5] fix: allow cross-repo checkpoints to run even when local checkpoint fails Remove std::process::exit(0) from the Err arm of the local checkpoint match so that cross-repo checkpoint processing on lines 857-922 can still execute. Track the failure state and exit(0) after cross-repo processing completes, preserving existing behavior for callers. Co-Authored-By: Sasha Varlamov --- src/commands/git_ai_handlers.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/git_ai_handlers.rs b/src/commands/git_ai_handlers.rs index 1bd3bc812..a62c5e8c3 100644 --- a/src/commands/git_ai_handlers.rs +++ b/src/commands/git_ai_handlers.rs @@ -834,6 +834,7 @@ 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(); @@ -850,7 +851,6 @@ fn handle_checkpoint(args: &[String]) { "checkpoint_kind": format!("{:?}", checkpoint_kind) }); observability::log_error(&e, Some(context)); - std::process::exit(0); } } @@ -924,6 +924,10 @@ fn handle_checkpoint(args: &[String]) { if checkpoint_kind != CheckpointKind::Human { observability::spawn_background_flush(); } + + if local_checkpoint_failed { + std::process::exit(0); + } } fn handle_ai_blame(args: &[String]) {