diff --git a/src/ci/ci_context.rs b/src/ci/ci_context.rs index 315fa19e..29ed616e 100644 --- a/src/ci/ci_context.rs +++ b/src/ci/ci_context.rs @@ -3,8 +3,11 @@ use crate::authorship::rebase_authorship::{ rewrite_authorship_after_rebase_v2, rewrite_authorship_after_squash_or_rebase, }; use crate::error::GitAiError; -use crate::git::refs::{get_reference_as_authorship_log_v3, show_authorship_note}; -use crate::git::repository::{CommitRange, Repository}; +use crate::git::refs::{ + copy_ref, get_reference_as_authorship_log_v3, merge_notes_from_ref, ref_exists, + show_authorship_note, +}; +use crate::git::repository::{exec_git, CommitRange, Repository}; use crate::git::sync_authorship::fetch_authorship_notes; use std::fs; use std::path::PathBuf; @@ -18,6 +21,9 @@ pub enum CiEvent { base_ref: String, #[allow(dead_code)] base_sha: String, + /// Clone URL of the fork repository, if this PR came from a fork. + /// When set, notes will be fetched from the fork before processing. + fork_clone_url: Option, }, } @@ -38,6 +44,8 @@ pub enum CiRunResult { #[allow(dead_code)] authorship_log: AuthorshipLog, }, + /// Fork notes were fetched and preserved for a merge commit from a fork + ForkNotesPreserved, /// No AI authorship to track (pre-git-ai commits or human-only code) NoAuthorshipAvailable, } @@ -68,6 +76,7 @@ impl CiContext { head_sha, base_ref, base_sha: _, + fork_clone_url, } => { println!("Working repository is in {}", self.repo.path().display()); @@ -76,6 +85,25 @@ impl CiContext { fetch_authorship_notes(&self.repo, "origin")?; println!("Fetched authorship history"); + // If this is a fork PR, fetch notes from the fork repository + let mut fork_notes_fetched = false; + if let Some(fork_url) = fork_clone_url { + println!("Fetching authorship notes from fork..."); + match Self::fetch_fork_notes(&self.repo, fork_url) { + Ok(true) => { + println!("Fetched authorship notes from fork"); + fork_notes_fetched = true; + } + Ok(false) => println!("No authorship notes found on fork"), + Err(e) => { + println!( + "Warning: Failed to fetch fork notes ({}), continuing without them", + e + ); + } + } + } + // Check if authorship already exists for this commit match get_reference_as_authorship_log_v3(&self.repo, merge_commit_sha) { Ok(existing_log) => { @@ -96,6 +124,33 @@ impl CiContext { let merge_commit = self.repo.find_commit(merge_commit_sha.clone())?; let parent_count = merge_commit.parents().count(); if parent_count > 1 { + // For fork PRs with merge commits, the merged commits from the + // fork have the same SHAs. Now that we've fetched fork notes, + // those notes are attached to the correct commits. Just push them. + if fork_clone_url.is_some() { + if !ref_exists(&self.repo, "refs/notes/ai") { + println!( + "No local authorship notes available after origin/fork fetch; skipping fork note push" + ); + return Ok(CiRunResult::SkippedSimpleMerge); + } + + println!( + "{} has {} parents (merge commit from fork) - preserving fork notes", + merge_commit_sha, parent_count + ); + if fork_notes_fetched { + println!("Fork notes were fetched and merged locally"); + } else { + println!( + "Using existing local authorship notes (no additional fork notes fetched)" + ); + } + println!("Pushing authorship..."); + self.repo.push_authorship("origin")?; + println!("Pushed authorship. Done."); + return Ok(CiRunResult::ForkNotesPreserved); + } println!( "{} has {} parents (simple merge)", merge_commit_sha, parent_count @@ -233,6 +288,64 @@ impl CiContext { Ok(()) } + /// Fetch authorship notes from a fork repository URL and merge them into + /// the local refs/notes/ai. Returns Ok(true) if notes were found and merged, + /// Ok(false) if no notes exist on the fork. + fn fetch_fork_notes(repo: &Repository, fork_url: &str) -> Result { + let tracking_ref = "refs/notes/ai-remote/fork"; + + // Check if the fork has notes + let mut ls_remote_args = repo.global_args_for_exec(); + ls_remote_args.push("ls-remote".to_string()); + ls_remote_args.push(fork_url.to_string()); + ls_remote_args.push("refs/notes/ai".to_string()); + + match exec_git(&ls_remote_args) { + Ok(output) => { + let result = String::from_utf8_lossy(&output.stdout).to_string(); + if result.trim().is_empty() { + return Ok(false); + } + } + Err(e) => { + return Err(GitAiError::Generic(format!( + "Failed to check fork for authorship notes: {}", + e + ))); + } + } + + // Fetch notes from the fork URL into a tracking ref + let fetch_refspec = format!("+refs/notes/ai:{}", tracking_ref); + let mut fetch_args = repo.global_args_for_exec(); + fetch_args.push("-c".to_string()); + fetch_args.push("core.hooksPath=/dev/null".to_string()); + fetch_args.push("fetch".to_string()); + fetch_args.push("--no-tags".to_string()); + fetch_args.push("--recurse-submodules=no".to_string()); + fetch_args.push("--no-write-fetch-head".to_string()); + fetch_args.push("--no-write-commit-graph".to_string()); + fetch_args.push("--no-auto-maintenance".to_string()); + fetch_args.push(fork_url.to_string()); + fetch_args.push(fetch_refspec); + + exec_git(&fetch_args)?; + + // Merge fork notes into local refs/notes/ai + let local_notes_ref = "refs/notes/ai"; + if ref_exists(repo, tracking_ref) { + if ref_exists(repo, local_notes_ref) { + // Both exist - merge (fork notes fill in what origin doesn't have) + merge_notes_from_ref(repo, tracking_ref)?; + } else { + // Only fork notes exist - copy them to local + copy_ref(repo, tracking_ref, local_notes_ref)?; + } + } + + Ok(true) + } + /// Get the rebased commits by walking back from merge_commit_sha. /// For a rebase merge with N original commits, there should be N new commits /// ending at merge_commit_sha. diff --git a/src/ci/github.rs b/src/ci/github.rs index d9391775..22963653 100644 --- a/src/ci/github.rs +++ b/src/ci/github.rs @@ -63,6 +63,18 @@ pub fn get_github_ci_context() -> Result, GitAiError> { let base_ref = pull_request.base.ref_name; let clone_url = pull_request.base.repo.clone_url.clone(); + // Detect fork: if head repo URL differs from base repo URL, this is a fork PR + let fork_clone_url = if pull_request.head.repo.clone_url != pull_request.base.repo.clone_url { + let fork_url = pull_request.head.repo.clone_url.clone(); + println!( + "Detected fork PR: head repo {} differs from base repo {}", + fork_url, clone_url + ); + Some(fork_url) + } else { + None + }; + let clone_dir = "git-ai-ci-clone".to_string(); // Authenticate the clone URL with GITHUB_TOKEN if available @@ -97,6 +109,18 @@ pub fn get_github_ci_context() -> Result, GitAiError> { format!("pull/{}/head:refs/github/pr/{}", pr_number, pr_number), ])?; + // Authenticate the fork clone URL if this is a fork PR + let authenticated_fork_url = fork_clone_url.map(|fork_url| { + if let Ok(token) = std::env::var("GITHUB_TOKEN") { + fork_url.replace( + "https://github.com/", + &format!("https://x-access-token:{}@github.com/", token), + ) + } else { + fork_url + } + }); + let repo = find_repository_in_path(&clone_dir.clone())?; Ok(Some(CiContext { @@ -107,6 +131,7 @@ pub fn get_github_ci_context() -> Result, GitAiError> { head_sha: head_sha.clone(), base_ref: base_ref.clone(), base_sha: pull_request.base.sha.clone(), + fork_clone_url: authenticated_fork_url, }, temp_dir: PathBuf::from(clone_dir), })) diff --git a/src/ci/gitlab.rs b/src/ci/gitlab.rs index 925c20d2..42ea2bdd 100644 --- a/src/ci/gitlab.rs +++ b/src/ci/gitlab.rs @@ -19,6 +19,14 @@ struct GitLabMergeRequest { merge_commit_sha: Option, squash_commit_sha: Option, squash: Option, + source_project_id: u64, + target_project_id: u64, +} + +/// GitLab Project API response (minimal fields for fork detection) +#[derive(Debug, Clone, Deserialize)] +struct GitLabProject { + http_url_to_repo: String, } /// Query GitLab API for recently merged MRs and find one matching the current commit SHA. @@ -175,6 +183,57 @@ pub fn get_gitlab_ci_context() -> Result, GitAiError> { effective_merge_sha ); + // Detect fork: if source_project_id differs from target_project_id, this is a fork MR + let fork_clone_url = if mr.source_project_id != mr.target_project_id { + println!( + "[GitLab CI] Detected fork MR: source project {} differs from target project {}", + mr.source_project_id, mr.target_project_id + ); + // Query the source project API to get its clone URL + let source_project_endpoint = format!("{}/projects/{}", api_url, mr.source_project_id); + match minreq::get(&source_project_endpoint) + .with_header(auth_header_name, &auth_token) + .with_header( + "User-Agent", + format!("git-ai/{}", env!("CARGO_PKG_VERSION")), + ) + .with_timeout(30) + .send() + { + Ok(resp) if resp.status_code == 200 => { + match serde_json::from_str::(resp.as_str().unwrap_or("{}")) { + Ok(project) => { + println!("[GitLab CI] Fork clone URL: {}", project.http_url_to_repo); + Some(project.http_url_to_repo) + } + Err(e) => { + println!( + "[GitLab CI] Warning: Failed to parse source project response: {}", + e + ); + None + } + } + } + Ok(resp) => { + println!( + "[GitLab CI] Warning: Failed to query source project (status {}), fork notes may be lost", + resp.status_code + ); + None + } + Err(e) => { + println!( + "[GitLab CI] Warning: Failed to query source project: {}, fork notes may be lost", + e + ); + None + } + } + } else { + None + }; + // Found a matching MR - clone and fetch let clone_dir = "git-ai-ci-clone".to_string(); let clone_url = format!("{}/{}.git", server_url, project_path); @@ -262,6 +321,18 @@ pub fn get_gitlab_ci_context() -> Result, GitAiError> { effective_merge_sha, mr.sha, mr.source_branch, mr.target_branch ); + // Authenticate the fork clone URL for fetching notes + let authenticated_fork_url = fork_clone_url.map(|fork_url| { + if let Ok(job_token) = std::env::var("CI_JOB_TOKEN") { + fork_url.replace( + &server_url, + &format!("{}://gitlab-ci-token:{}@{}", scheme, job_token, server_host), + ) + } else { + fork_url + } + }); + Ok(Some(CiContext { repo, event: CiEvent::Merge { @@ -270,6 +341,7 @@ pub fn get_gitlab_ci_context() -> Result, GitAiError> { head_sha: mr.sha.clone(), base_ref: mr.target_branch.clone(), base_sha: String::new(), // Not readily available from MR API, but not used in current impl + fork_clone_url: authenticated_fork_url, }, temp_dir: PathBuf::from(clone_dir), })) @@ -296,7 +368,9 @@ mod tests { "sha": "abc123", "merge_commit_sha": "def456", "squash_commit_sha": null, - "squash": false + "squash": false, + "source_project_id": 123, + "target_project_id": 456 }"#; let mr: GitLabMergeRequest = serde_json::from_str(json).unwrap(); assert_eq!(mr.iid, 42); @@ -307,6 +381,8 @@ mod tests { assert_eq!(mr.merge_commit_sha, Some("def456".to_string())); assert!(mr.squash_commit_sha.is_none()); assert_eq!(mr.squash, Some(false)); + assert_eq!(mr.source_project_id, 123); + assert_eq!(mr.target_project_id, 456); } #[test] @@ -319,12 +395,16 @@ mod tests { "sha": "head123", "merge_commit_sha": "merge456", "squash_commit_sha": "squash789", - "squash": true + "squash": true, + "source_project_id": 123, + "target_project_id": 123 }"#; let mr: GitLabMergeRequest = serde_json::from_str(json).unwrap(); assert_eq!(mr.iid, 99); assert_eq!(mr.squash_commit_sha, Some("squash789".to_string())); assert_eq!(mr.squash, Some(true)); + assert_eq!(mr.source_project_id, 123); + assert_eq!(mr.target_project_id, 123); } #[test] @@ -333,7 +413,9 @@ mod tests { "iid": 1, "source_branch": "dev", "target_branch": "main", - "sha": "abc" + "sha": "abc", + "source_project_id": 999, + "target_project_id": 999 }"#; let mr: GitLabMergeRequest = serde_json::from_str(json).unwrap(); assert_eq!(mr.iid, 1); @@ -341,6 +423,8 @@ mod tests { assert!(mr.merge_commit_sha.is_none()); assert!(mr.squash_commit_sha.is_none()); assert!(mr.squash.is_none()); + assert_eq!(mr.source_project_id, 999); + assert_eq!(mr.target_project_id, 999); } #[test] diff --git a/src/commands/ci_handlers.rs b/src/commands/ci_handlers.rs index 11ea9825..eb07bd56 100644 --- a/src/commands/ci_handlers.rs +++ b/src/commands/ci_handlers.rs @@ -16,6 +16,12 @@ fn print_ci_result(result: &CiRunResult, prefix: &str) { CiRunResult::SkippedSimpleMerge => { println!("{}: skipped simple merge (authorship preserved)", prefix); } + CiRunResult::ForkNotesPreserved => { + println!( + "{}: fork notes fetched and pushed (merge commit from fork)", + prefix + ); + } CiRunResult::SkippedFastForward => { println!("{}: skipped fast-forward merge", prefix); } @@ -242,6 +248,8 @@ fn handle_ci_local(args: &[String]) { } }; + let fork_clone_url = flag("--fork-clone-url"); + let ctx = CiContext { repo, event: CiEvent::Merge { @@ -250,6 +258,7 @@ fn handle_ci_local(args: &[String]) { head_sha, base_ref, base_sha, + fork_clone_url, }, // Not used for local runs; teardown not invoked temp_dir: std::path::PathBuf::from("."), @@ -291,7 +300,7 @@ fn print_ci_help_and_exit() -> ! { eprintln!(" Usage: git-ai ci local [flags]"); eprintln!(" Events:"); eprintln!( - " merge --merge-commit-sha --base-ref --head-ref --head-sha --base-sha " + " merge --merge-commit-sha --base-ref --head-ref --head-sha --base-sha [--fork-clone-url ]" ); std::process::exit(1); } diff --git a/tests/ci_fork_notes.rs b/tests/ci_fork_notes.rs new file mode 100644 index 00000000..6eb9f413 --- /dev/null +++ b/tests/ci_fork_notes.rs @@ -0,0 +1,578 @@ +#[macro_use] +mod repos; +use git_ai::ci::ci_context::{CiContext, CiEvent, CiRunResult}; +use git_ai::git::refs::get_reference_as_authorship_log_v3; +use git_ai::git::repository as GitAiRepository; +use repos::test_file::ExpectedLineExt; +use repos::test_repo::TestRepo; + +/// Helper: set up "origin" as a self-referencing remote so fetch_authorship_notes("origin") +/// doesn't fail. In real CI the repo is cloned from origin, so it always exists. +fn add_self_origin(repo: &TestRepo) { + let path = repo.path().to_str().unwrap(); + repo.git_og(&["remote", "add", "origin", path]).ok(); // ok() in case it already exists +} + +/// Test that CI preserves fork notes for a squash merge from a fork. +/// +/// Scenario: +/// 1. Contributor works in a fork with git-ai, creating AI-attributed code +/// 2. Maintainer squash-merges the PR into the upstream repo +/// 3. CI runs and should fetch notes from the fork, then rewrite them +/// onto the squash commit +#[test] +fn test_ci_fork_squash_merge() { + // Setup: create "upstream" repo with initial commit + let upstream = TestRepo::new(); + let mut file = upstream.filename("feature.js"); + + file.set_contents(lines!["// Original code", "function original() {}"]); + let base_commit = upstream.stage_all_and_commit("Initial commit").unwrap(); + upstream.git(&["branch", "-M", "main"]).unwrap(); + add_self_origin(&upstream); + + // Create "fork" repo and give it the upstream's history + let fork = TestRepo::new(); + let upstream_path = upstream.path().to_str().unwrap().to_string(); + fork.git_og(&["remote", "add", "upstream", &upstream_path]) + .unwrap(); + fork.git_og(&["fetch", "upstream"]).unwrap(); + fork.git_og(&["checkout", "-b", "main", "upstream/main"]) + .unwrap(); + + // Fork contributor adds AI code using git-ai + let mut fork_file = fork.filename("feature.js"); + fork_file.set_contents(lines![ + "// Original code", + "function original() {}", + "// AI added function".ai(), + "function aiFeature() {".ai(), + " return 'from fork';".ai(), + "}".ai() + ]); + let fork_commit = fork.stage_all_and_commit("Add AI feature in fork").unwrap(); + let fork_head_sha = fork_commit.commit_sha.clone(); + + // Verify fork has authorship notes + let fork_repo = GitAiRepository::find_repository_in_path(fork.path().to_str().unwrap()) + .expect("Failed to find fork repository"); + assert!( + get_reference_as_authorship_log_v3(&fork_repo, &fork_head_sha).is_ok(), + "Fork commit should have authorship notes" + ); + + // Make the fork's commits accessible from upstream + upstream + .git_og(&["remote", "add", "fork", fork.path().to_str().unwrap()]) + .unwrap(); + upstream + .git_og(&["fetch", "fork", "main:refs/fork/main"]) + .unwrap(); + + // Simulate squash merge in upstream: maintainer creates a squash commit. + // Use git_og (raw git) to avoid git-ai auto-creating notes on this commit, + // which simulates the real CI scenario where the squash commit has no notes. + file.set_contents(lines![ + "// Original code", + "function original() {}", + "// AI added function", + "function aiFeature() {", + " return 'from fork';", + "}" + ]); + upstream.git_og(&["add", "-A"]).unwrap(); + upstream + .git_og(&["commit", "-m", "Merge fork PR via squash (#1)"]) + .unwrap(); + let merge_sha = upstream + .git_og(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + + // Run CI context with fork_clone_url set + let upstream_repo = GitAiRepository::find_repository_in_path(upstream.path().to_str().unwrap()) + .expect("Failed to find upstream repository"); + + let fork_url = fork.path().to_str().unwrap().to_string(); + + let ci_context = CiContext::with_repository( + upstream_repo, + CiEvent::Merge { + merge_commit_sha: merge_sha.clone(), + head_ref: "main".to_string(), + head_sha: fork_head_sha.clone(), + base_ref: "main".to_string(), + base_sha: base_commit.commit_sha.clone(), + fork_clone_url: Some(fork_url), + }, + ); + + let result = ci_context.run().unwrap(); + + // Verify the result is AuthorshipRewritten (squash merge rewrites notes) + assert!( + matches!(result, CiRunResult::AuthorshipRewritten { .. }), + "Expected AuthorshipRewritten for fork squash merge, got {:?}", + result + ); + + // Verify authorship is preserved in the squash commit + file.assert_lines_and_blame(lines![ + "// Original code".human(), + "function original() {}".human(), + "// AI added function".ai(), + "function aiFeature() {".ai(), + " return 'from fork';".ai(), + "}".ai() + ]); +} + +/// Test that CI preserves fork notes for a merge commit (non-squash, non-rebase). +/// +/// For merge commits from forks, the merged commits keep their original SHAs. +/// The CI should fetch notes from the fork and push them to origin. +#[test] +fn test_ci_fork_merge_commit() { + // Setup: create "upstream" repo with initial commit + let upstream = TestRepo::new(); + let mut file = upstream.filename("feature.js"); + + file.set_contents(lines!["// Original code", "function original() {}"]); + let base_commit = upstream.stage_all_and_commit("Initial commit").unwrap(); + upstream.git(&["branch", "-M", "main"]).unwrap(); + add_self_origin(&upstream); + + // Create "fork" as separate repo with shared history + let fork = TestRepo::new(); + let upstream_path = upstream.path().to_str().unwrap().to_string(); + fork.git_og(&["remote", "add", "upstream", &upstream_path]) + .unwrap(); + fork.git_og(&["fetch", "upstream"]).unwrap(); + fork.git_og(&["checkout", "-b", "main", "upstream/main"]) + .unwrap(); + + // Fork contributor adds AI code + let mut fork_file = fork.filename("feature.js"); + fork_file.set_contents(lines![ + "// Original code", + "function original() {}", + "// AI feature from fork".ai(), + "function forkFeature() {".ai(), + " return true;".ai(), + "}".ai() + ]); + let fork_commit = fork.stage_all_and_commit("Add AI feature in fork").unwrap(); + let fork_head_sha = fork_commit.commit_sha.clone(); + + // Verify fork has authorship notes + let fork_repo = GitAiRepository::find_repository_in_path(fork.path().to_str().unwrap()) + .expect("Failed to find fork repository"); + assert!( + get_reference_as_authorship_log_v3(&fork_repo, &fork_head_sha).is_ok(), + "Fork commit should have authorship notes" + ); + + // Fetch fork commits and create merge commit in upstream + upstream + .git_og(&["remote", "add", "fork", fork.path().to_str().unwrap()]) + .unwrap(); + upstream + .git_og(&["fetch", "fork", "main:refs/fork/main"]) + .unwrap(); + + // Create a merge commit (--no-ff ensures a merge commit is created) + upstream + .git_og(&[ + "merge", + "--no-ff", + "refs/fork/main", + "-m", + "Merge fork PR (#1)", + ]) + .unwrap(); + let merge_sha = upstream + .git_og(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + + // Run CI context with fork_clone_url + let upstream_repo = GitAiRepository::find_repository_in_path(upstream.path().to_str().unwrap()) + .expect("Failed to find upstream repository"); + + let fork_url = fork.path().to_str().unwrap().to_string(); + + let ci_context = CiContext::with_repository( + upstream_repo, + CiEvent::Merge { + merge_commit_sha: merge_sha.clone(), + head_ref: "main".to_string(), + head_sha: fork_head_sha.clone(), + base_ref: "main".to_string(), + base_sha: base_commit.commit_sha.clone(), + fork_clone_url: Some(fork_url), + }, + ); + + let result = ci_context.run().unwrap(); + + // For merge commits from forks, notes should be preserved (not rewritten) + assert!( + matches!(result, CiRunResult::ForkNotesPreserved), + "Expected ForkNotesPreserved for fork merge commit, got {:?}", + result + ); + + // Verify the fork commit's authorship is accessible in upstream + let upstream_repo2 = + GitAiRepository::find_repository_in_path(upstream.path().to_str().unwrap()) + .expect("Failed to find upstream repository"); + let authorship_log = get_reference_as_authorship_log_v3(&upstream_repo2, &fork_head_sha); + assert!( + authorship_log.is_ok(), + "Fork commit's authorship should be accessible in upstream after CI run" + ); +} + +/// Test that CI handles fork PRs with no notes gracefully. +/// +/// If a fork contributor doesn't use git-ai, there are no notes to fetch. +/// The CI should handle this gracefully without errors. +#[test] +fn test_ci_fork_no_notes() { + // Setup upstream + let upstream = TestRepo::new(); + let mut file = upstream.filename("feature.js"); + + file.set_contents(lines!["// Original code"]); + let base_commit = upstream.stage_all_and_commit("Initial commit").unwrap(); + upstream.git(&["branch", "-M", "main"]).unwrap(); + add_self_origin(&upstream); + + // Create fork WITHOUT git-ai (using git_og for all operations) + let fork = TestRepo::new(); + let upstream_path = upstream.path().to_str().unwrap().to_string(); + fork.git_og(&["remote", "add", "upstream", &upstream_path]) + .unwrap(); + fork.git_og(&["fetch", "upstream"]).unwrap(); + fork.git_og(&["checkout", "-b", "main", "upstream/main"]) + .unwrap(); + + // Fork contributor adds code without git-ai + let mut fork_file = fork.filename("feature.js"); + fork_file.set_contents(lines!["// Original code", "// Added in fork"]); + fork.git_og(&["add", "-A"]).unwrap(); + fork.git_og(&["commit", "-m", "Add feature in fork"]) + .unwrap(); + let fork_head_sha = fork + .git_og(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + + // Fetch fork commits into upstream + upstream + .git_og(&["remote", "add", "fork", fork.path().to_str().unwrap()]) + .unwrap(); + upstream + .git_og(&["fetch", "fork", "main:refs/fork/main"]) + .unwrap(); + + // Simulate squash merge in upstream (using raw git to avoid auto-notes) + file.set_contents(lines!["// Original code", "// Added in fork"]); + upstream.git_og(&["add", "-A"]).unwrap(); + upstream + .git_og(&["commit", "-m", "Merge fork PR via squash"]) + .unwrap(); + let merge_sha = upstream + .git_og(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + + // Run CI with fork URL + let upstream_repo = GitAiRepository::find_repository_in_path(upstream.path().to_str().unwrap()) + .expect("Failed to find upstream repository"); + + let fork_url = fork.path().to_str().unwrap().to_string(); + + let ci_context = CiContext::with_repository( + upstream_repo, + CiEvent::Merge { + merge_commit_sha: merge_sha.clone(), + head_ref: "main".to_string(), + head_sha: fork_head_sha, + base_ref: "main".to_string(), + base_sha: base_commit.commit_sha.clone(), + fork_clone_url: Some(fork_url), + }, + ); + + // Should complete without errors, even though fork has no notes + let result = ci_context.run().unwrap(); + assert!( + matches!(result, CiRunResult::NoAuthorshipAvailable), + "Expected NoAuthorshipAvailable for fork with no git-ai notes, got {:?}", + result + ); +} + +/// Test merge commit from fork with no notes anywhere. +/// +/// If neither origin nor fork has refs/notes/ai, CI should not attempt to push +/// notes for a fork merge commit and should skip gracefully. +#[test] +fn test_ci_fork_merge_commit_no_notes_skips_without_push_error() { + let upstream = TestRepo::new(); + let mut file = upstream.filename("feature.js"); + + file.set_contents(lines!["// Original code"]); + upstream.git_og(&["add", "-A"]).unwrap(); + upstream.git_og(&["commit", "-m", "Initial commit"]).unwrap(); + let base_sha = upstream + .git_og(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + upstream.git(&["branch", "-M", "main"]).unwrap(); + add_self_origin(&upstream); + + // Create fork WITHOUT git-ai notes + let fork = TestRepo::new(); + let upstream_path = upstream.path().to_str().unwrap().to_string(); + fork.git_og(&["remote", "add", "upstream", &upstream_path]) + .unwrap(); + fork.git_og(&["fetch", "upstream"]).unwrap(); + fork.git_og(&["checkout", "-b", "main", "upstream/main"]) + .unwrap(); + + let mut fork_file = fork.filename("feature.js"); + fork_file.set_contents(lines!["// Original code", "// Added in fork via merge"]); + fork.git_og(&["add", "-A"]).unwrap(); + fork.git_og(&["commit", "-m", "Add feature in fork"]) + .unwrap(); + let fork_head_sha = fork + .git_og(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + + // Fetch fork branch and merge with merge commit + upstream + .git_og(&["remote", "add", "fork", fork.path().to_str().unwrap()]) + .unwrap(); + upstream + .git_og(&["fetch", "fork", "main:refs/fork/main"]) + .unwrap(); + upstream + .git_og(&[ + "merge", + "--no-ff", + "refs/fork/main", + "-m", + "Merge fork PR with no notes", + ]) + .unwrap(); + let merge_sha = upstream + .git_og(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + + let upstream_repo = GitAiRepository::find_repository_in_path(upstream.path().to_str().unwrap()) + .expect("Failed to find upstream repository"); + + let fork_url = fork.path().to_str().unwrap().to_string(); + + let ci_context = CiContext::with_repository( + upstream_repo, + CiEvent::Merge { + merge_commit_sha: merge_sha, + head_ref: "main".to_string(), + head_sha: fork_head_sha, + base_ref: "main".to_string(), + base_sha, + fork_clone_url: Some(fork_url), + }, + ); + + let result = ci_context.run().unwrap(); + assert!( + matches!(result, CiRunResult::SkippedSimpleMerge), + "Expected SkippedSimpleMerge for fork merge commit with no notes, got {:?}", + result + ); +} + +/// Test that non-fork PRs (fork_clone_url = None) still work as before. +/// Merge commits without fork_clone_url should still be skipped. +#[test] +fn test_ci_non_fork_merge_commit_still_skipped() { + let upstream = TestRepo::new(); + let mut file = upstream.filename("feature.js"); + + file.set_contents(lines!["// Original code"]); + let base_commit = upstream.stage_all_and_commit("Initial commit").unwrap(); + upstream.git(&["branch", "-M", "main"]).unwrap(); + add_self_origin(&upstream); + + // Create feature branch (same repo, not a fork) + upstream.git(&["checkout", "-b", "feature"]).unwrap(); + let mut feature_file = upstream.filename("feature.js"); + feature_file.set_contents(lines![ + "// Original code", + "// Feature addition".ai(), + "function feature() {}".ai() + ]); + let feature_commit = upstream.stage_all_and_commit("Add feature").unwrap(); + let feature_sha = feature_commit.commit_sha.clone(); + + // Merge with --no-ff to create merge commit + upstream.git(&["checkout", "main"]).unwrap(); + upstream + .git_og(&["merge", "--no-ff", "feature", "-m", "Merge feature"]) + .unwrap(); + let merge_sha = upstream + .git_og(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + + let upstream_repo = GitAiRepository::find_repository_in_path(upstream.path().to_str().unwrap()) + .expect("Failed to find repository"); + + let ci_context = CiContext::with_repository( + upstream_repo, + CiEvent::Merge { + merge_commit_sha: merge_sha, + head_ref: "feature".to_string(), + head_sha: feature_sha, + base_ref: "main".to_string(), + base_sha: base_commit.commit_sha.clone(), + fork_clone_url: None, // Not a fork + }, + ); + + let result = ci_context.run().unwrap(); + + // Should still be SkippedSimpleMerge for non-fork merge commits + assert!( + matches!(result, CiRunResult::SkippedSimpleMerge), + "Expected SkippedSimpleMerge for non-fork merge commit, got {:?}", + result + ); +} + +/// Test squash merge from fork with multiple commits containing AI code. +/// Verify that authorship is rewritten (fork notes are fetched and processed). +#[test] +fn test_ci_fork_squash_merge_multiple_commits() { + let upstream = TestRepo::new(); + let mut file = upstream.filename("app.js"); + + file.set_contents(lines!["// App v1", ""]); + let base_commit = upstream.stage_all_and_commit("Initial commit").unwrap(); + upstream.git(&["branch", "-M", "main"]).unwrap(); + add_self_origin(&upstream); + + // Create fork with multiple AI commits + let fork = TestRepo::new(); + let upstream_path = upstream.path().to_str().unwrap().to_string(); + fork.git_og(&["remote", "add", "upstream", &upstream_path]) + .unwrap(); + fork.git_og(&["fetch", "upstream"]).unwrap(); + fork.git_og(&["checkout", "-b", "main", "upstream/main"]) + .unwrap(); + + let mut fork_file = fork.filename("app.js"); + + // First commit: AI adds function 1 + fork_file.insert_at( + 1, + lines!["// AI function 1".ai(), "function ai1() { }".ai()], + ); + fork.stage_all_and_commit("Add AI function 1").unwrap(); + + // Second commit: AI adds function 2 + fork_file.insert_at( + 3, + lines!["// AI function 2".ai(), "function ai2() { }".ai()], + ); + fork.stage_all_and_commit("Add AI function 2").unwrap(); + + // Third commit: Human adds function + fork_file.insert_at(5, lines!["// Human function", "function human() { }"]); + let fork_last_commit = fork.stage_all_and_commit("Add human function").unwrap(); + let fork_head_sha = fork_last_commit.commit_sha.clone(); + + // Fetch fork commits into upstream + upstream + .git_og(&["remote", "add", "fork", fork.path().to_str().unwrap()]) + .unwrap(); + upstream + .git_og(&["fetch", "fork", "main:refs/fork/main"]) + .unwrap(); + + // Simulate squash merge in upstream (using raw git to avoid auto-notes) + file.set_contents(lines![ + "// App v1", + "// AI function 1", + "function ai1() { }", + "// AI function 2", + "function ai2() { }", + "// Human function", + "function human() { }" + ]); + upstream.git_og(&["add", "-A"]).unwrap(); + upstream + .git_og(&["commit", "-m", "Merge fork multi-commit PR (#2)"]) + .unwrap(); + let merge_sha = upstream + .git_og(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + + // Run CI with fork URL + let upstream_repo = GitAiRepository::find_repository_in_path(upstream.path().to_str().unwrap()) + .expect("Failed to find upstream repository"); + + let fork_url = fork.path().to_str().unwrap().to_string(); + + let ci_context = CiContext::with_repository( + upstream_repo, + CiEvent::Merge { + merge_commit_sha: merge_sha.clone(), + head_ref: "main".to_string(), + head_sha: fork_head_sha, + base_ref: "main".to_string(), + base_sha: base_commit.commit_sha.clone(), + fork_clone_url: Some(fork_url), + }, + ); + + let result = ci_context.run().unwrap(); + + // Verify fork notes were fetched and authorship was rewritten + assert!( + matches!(result, CiRunResult::AuthorshipRewritten { .. }), + "Expected AuthorshipRewritten for multi-commit fork squash merge, got {:?}", + result + ); + + // Verify authorship log exists on the merge commit + let upstream_repo2 = + GitAiRepository::find_repository_in_path(upstream.path().to_str().unwrap()) + .expect("Failed to find upstream repository"); + let authorship_log = get_reference_as_authorship_log_v3(&upstream_repo2, &merge_sha); + assert!( + authorship_log.is_ok(), + "Squash commit should have authorship log from fork notes" + ); + let log = authorship_log.unwrap(); + assert!( + !log.attestations.is_empty(), + "Authorship log should have attestations from fork's AI code" + ); +}