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
125 changes: 118 additions & 7 deletions src/commands/git_ai_handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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::<Vec<_>>()
})
})
.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,
Expand All @@ -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]) {
Expand Down
131 changes: 131 additions & 0 deletions tests/multi_repo_workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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"
);
}
Loading