From 06c2d55b8e165a47a59133e5d2ea05842bfa50a0 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Wed, 29 Jul 2026 21:36:45 +0000 Subject: [PATCH] Hydrate notes before transport rewrites --- src/daemon.rs | 121 +++++++++++-- src/git/notes_api.rs | 34 +++- tests/integration/cherry_pick.rs | 47 ++--- tests/integration/pull_rebase_ff.rs | 119 +++++++++++- tests/notes_sync_regression.rs | 268 +++++++++++++++++++++++++++- 5 files changed, 526 insertions(+), 63 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 361d1e4e38..6d688ea85b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1330,28 +1330,92 @@ fn transcript_sweep_triggers_for_events( triggers } -fn apply_pull_notes_sync_side_effect( +fn incoming_transport_revision_oids( + command: &crate::daemon::domain::NormalizedCommand, + remote: &str, +) -> Vec { + let remote_tracking_prefix = format!("refs/remotes/{remote}/"); + let is_fetch = command.primary_command.as_deref() == Some("fetch"); + let mut seen = HashSet::new(); + + let mut revisions: Vec = command + .ref_changes + .iter() + .filter(|change| { + is_valid_oid(&change.new) + && !is_zero_oid(&change.new) + && change.old != change.new + && !change.reference.starts_with("refs/notes/") + && change.reference.starts_with(&remote_tracking_prefix) + }) + .filter_map(|change| { + seen.insert(change.new.clone()) + .then_some(change.new.clone()) + }) + .collect(); + + // A pull from a URL or path may update no remote-tracking ref. Its + // trace2-derived HEAD transitions are still immutable candidates, and + // batching all of them preserves the one-rev-list/one-API-request bound. + if revisions.is_empty() && !is_fetch { + revisions.extend( + command + .ref_changes + .iter() + .filter(|change| { + change.reference == "HEAD" + && is_valid_oid(&change.new) + && !is_zero_oid(&change.new) + && change.old != change.new + }) + .filter_map(|change| { + seen.insert(change.new.clone()) + .then_some(change.new.clone()) + }), + ); + } + + revisions +} + +fn apply_transport_notes_sync_side_effect( worktree: &str, - command: Option<&str>, - args: &[String], + command: &crate::daemon::domain::NormalizedCommand, ) -> Result<(), GitAiError> { use crate::config::NotesBackendKind; + use crate::git::cli_parser::is_dry_run; + + let parsed = parsed_invocation_for_normalized_command(command); + if is_dry_run(&parsed.command_args) { + return Ok(()); + } let repo = find_repository_in_path(worktree)?; - let parsed = parsed_invocation_for_side_effect(command, args); let remote = fetch_remote_from_args(&repo, &parsed)?; let notes_backend = crate::config::Config::fresh().notes_backend_kind(); + let primary = command.primary_command.as_deref().unwrap_or("fetch"); - tracing::info!( - command = command.unwrap_or("pull"), - remote = %remote, - backend = %notes_backend, - worktree = %worktree, - "handling pull notes sync" - ); + if primary == "pull" { + tracing::info!( + command = primary, + remote = %remote, + backend = %notes_backend, + worktree = %worktree, + "handling pull notes sync" + ); + } else { + tracing::info!( + command = primary, + remote = %remote, + backend = %notes_backend, + worktree = %worktree, + "handling fetch notes sync" + ); + } if notes_backend == NotesBackendKind::Http { - return crate::git::notes_api::warm_cache_for_remote(&repo, &remote); + let revisions = incoming_transport_revision_oids(command, &remote); + return crate::git::notes_api::warm_cache_for_revisions(&repo, &revisions); } fetch_authorship_notes(&repo, &remote)?; @@ -5662,6 +5726,28 @@ impl ActorDaemonCoordinator { "side-effect trace" ); } + let should_sync_transport_notes = cmd.exit_code == 0 + && events.iter().any(|event| { + matches!( + event, + crate::daemon::domain::SemanticEvent::FetchCompleted { .. } + | crate::daemon::domain::SemanticEvent::PullCompleted { .. } + ) + }); + let mut transport_notes_sync_error = None; + if should_sync_transport_notes + && let Some(worktree) = cmd.worktree.as_ref() + && let Err(error) = + apply_transport_notes_sync_side_effect(&worktree.to_string_lossy(), cmd) + { + tracing::debug!( + %error, + command = cmd.primary_command.as_deref().unwrap_or("unknown"), + "transport notes sync failed; deferring error until rewrite side effects finish" + ); + transport_notes_sync_error = Some(error); + } + // Non-FF rewrite detection: fires for commands that rewrite history via ref moves. // Skip for: checkout/switch/branch (no rewriting), cherry-pick (handled separately), // and plain commit/amend (CommitCreated/CommitAmended events handle those). @@ -5884,13 +5970,6 @@ impl ActorDaemonCoordinator { crate::daemon::domain::SemanticEvent::CloneCompleted { .. } => { apply_clone_notes_sync_side_effect(&worktree)?; } - crate::daemon::domain::SemanticEvent::PullCompleted { .. } => { - apply_pull_notes_sync_side_effect( - &worktree, - cmd.invoked_command.as_deref(), - &cmd.invoked_args, - )?; - } crate::daemon::domain::SemanticEvent::PushCompleted { .. } => { apply_push_side_effect( &worktree, @@ -6374,6 +6453,10 @@ impl ActorDaemonCoordinator { self.trigger_transcript_sweep(trigger); } + if let Some(error) = transport_notes_sync_error { + return Err(error); + } + Ok(()) } diff --git a/src/git/notes_api.rs b/src/git/notes_api.rs index 7a394b152b..3d0be253d2 100644 --- a/src/git/notes_api.rs +++ b/src/git/notes_api.rs @@ -406,17 +406,38 @@ pub fn warm_cache_for_remote(repo: &Repository, remote: &str) -> Result<(), GitA } }; + warm_cache_for_revisions(repo, &[rev_target]) +} + +/// Pre-warm the local notes cache for recent commits reachable from immutable +/// revision OIDs captured by trace2. +/// +/// All revisions are traversed by one bounded `rev-list` invocation and all +/// cache misses are fetched in batches, so work does not scale in git process +/// spawns with the number of updated refs or commits. +pub fn warm_cache_for_revisions(repo: &Repository, revisions: &[String]) -> Result<(), GitAiError> { + use crate::git::repository::exec_git_with_stdin_writer; + + if revisions.is_empty() { + return Ok(()); + } + let rev_list_args: Vec = repo .global_args_for_exec() .into_iter() .chain([ "rev-list".to_string(), "--max-count=500".to_string(), - rev_target, + "--stdin".to_string(), ]) .collect(); - - let output = exec_git(&rev_list_args)?; + let output = exec_git_with_stdin_writer(&rev_list_args, |writer| { + for revision in revisions { + writer.write_all(revision.as_bytes())?; + writer.write_all(b"\n")?; + } + Ok(()) + })?; let stdout = String::from_utf8_lossy(&output.stdout); let all_shas: Vec = stdout .lines() @@ -425,7 +446,7 @@ pub fn warm_cache_for_remote(repo: &Repository, remote: &str) -> Result<(), GitA .collect(); if all_shas.is_empty() { - tracing::debug!("warm_cache_for_remote: no commits in HEAD history; skipping"); + tracing::debug!("warm_cache_for_revisions: no reachable commits; skipping"); return Ok(()); } @@ -438,18 +459,17 @@ pub fn warm_cache_for_remote(repo: &Repository, remote: &str) -> Result<(), GitA .collect(); if uncached.is_empty() { - tracing::debug!("warm_cache_for_remote: all commits already cached; skipping"); + tracing::debug!("warm_cache_for_revisions: all commits already cached; skipping"); return Ok(()); } tracing::info!( - remote = %remote, backend = %"http", uncached_commits = uncached.len(), "fetching authorship notes" ); tracing::debug!( - "warm_cache_for_remote: fetching notes for {} uncached commits", + "warm_cache_for_revisions: fetching notes for {} uncached commits", uncached.len() ); diff --git a/tests/integration/cherry_pick.rs b/tests/integration/cherry_pick.rs index 81c2fa3878..6c515b3f15 100644 --- a/tests/integration/cherry_pick.rs +++ b/tests/integration/cherry_pick.rs @@ -9,6 +9,12 @@ use std::collections::HashMap; use std::fs; use std::path::PathBuf; +const TRACE2_DISABLED_ENV: [(&str, &str); 3] = [ + ("GIT_TRACE2", "0"), + ("GIT_TRACE2_EVENT", "0"), + ("GIT_TRACE2_PERF", "0"), +]; + /// Test cherry-picking a single AI-authored commit #[test] fn test_single_commit_cherry_pick() { @@ -813,7 +819,10 @@ fn test_cherry_pick_from_remote_without_prefetched_notes() { .unwrap(); // Fetch only the branch objects, explicitly excluding notes. target_repo - .git(&["fetch", "source", "refs/heads/*:refs/remotes/source/*"]) + .git_og_with_env( + &["fetch", "source", "refs/heads/*:refs/remotes/source/*"], + &TRACE2_DISABLED_ENV, + ) .unwrap(); // Confirm notes are absent (the fix relies on detecting this absence). @@ -830,9 +839,8 @@ fn test_cherry_pick_from_remote_without_prefetched_notes() { } #[test] -#[ignore = "temporarily restored by the stacked transport-aware notes sync follow-up"] -fn test_cherry_pick_preserves_authoritative_remote_target_note() { - let (repo, upstream) = TestRepo::new_with_remote(); +fn test_cherry_pick_merges_existing_authoritative_target_note() { + let repo = TestRepo::new(); let file_path = repo.path().join("file.txt"); fs::write(&file_path, "base\n").unwrap(); @@ -883,12 +891,6 @@ fn test_cherry_pick_preserves_authoritative_remote_target_note() { write_note(&git_ai_repo, &source_commit.commit_sha, &source_note) .expect("write stale source note"); - repo.git_og(&["push", "origin", "feature"]).unwrap(); - repo.git_og(&["push", "origin", "refs/notes/ai:refs/notes/ai"]) - .unwrap(); - let source_notes_ref = repo.git_og(&["rev-parse", "refs/notes/ai"]).unwrap(); - let source_notes_ref = source_notes_ref.trim(); - repo.git(&["checkout", &main_branch]).unwrap(); let deterministic_date = "2030-01-03T00:00:00Z"; repo.git_og_with_env( @@ -918,15 +920,9 @@ fn test_cherry_pick_preserves_authoritative_remote_target_note() { .serialize_to_string() .expect("serialize authoritative target note"); write_note(&git_ai_repo, target_commit, &target_note).expect("write authoritative target note"); - repo.git_og(&["push", "--force", "origin", "refs/notes/ai:refs/notes/ai"]) - .unwrap(); repo.git_og(&["reset", "--hard", &base_commit.commit_sha]) .unwrap(); - repo.git_og(&["update-ref", "refs/notes/ai", source_notes_ref]) - .unwrap(); - repo.git_og(&["update-ref", "-d", "refs/notes/ai-remote/origin"]) - .unwrap(); assert!( repo.read_authorship_note(&source_commit.commit_sha) @@ -934,12 +930,8 @@ fn test_cherry_pick_preserves_authoritative_remote_target_note() { "precondition: stale source note should already exist locally" ); assert!( - upstream.read_authorship_note(target_commit).is_some(), - "precondition: authoritative target note should exist remotely" - ); - assert!( - repo.read_authorship_note(target_commit).is_none(), - "precondition: target note should not exist locally" + repo.read_authorship_note(target_commit).is_some(), + "precondition: authoritative target note should already exist locally" ); repo.git_with_env( @@ -1420,7 +1412,10 @@ fn test_cherry_pick_from_remote_continues_when_notes_import_fails() { ]) .unwrap(); target_repo - .git(&["fetch", "source", "refs/heads/*:refs/remotes/source/*"]) + .git_og_with_env( + &["fetch", "source", "refs/heads/*:refs/remotes/source/*"], + &TRACE2_DISABLED_ENV, + ) .unwrap(); let _ = target_repo.git(&["update-ref", "-d", "refs/notes/ai"]); let _ = target_repo.git(&["update-ref", "-d", "refs/notes/ai-remote/source"]); @@ -1484,6 +1479,7 @@ crate::reuse_tests_in_worktree!( test_cherry_pick_bad_args_dont_corrupt_subsequent_attribution, test_cherry_pick_skip_preserves_subsequent_attribution, test_cherry_pick_from_remote_without_prefetched_notes, + test_cherry_pick_merges_existing_authoritative_target_note, test_local_cherry_pick_does_not_fetch_notes_for_fresh_destination, test_cherry_pick_from_remote_continues_when_notes_import_fails, test_cherry_pick_no_commit_defers_to_final_commit_tree, @@ -1491,8 +1487,3 @@ crate::reuse_tests_in_worktree!( test_cherry_pick_skip_then_continue_applies_remaining_commits, test_cherry_pick_skip_failed_next_conflict_does_not_double_skip_refcursor_sources, ); - -crate::reuse_tests_in_worktree_with_attrs!( - (#[ignore = "temporarily restored by the stacked transport-aware notes sync follow-up"]) - test_cherry_pick_preserves_authoritative_remote_target_note, -); diff --git a/tests/integration/pull_rebase_ff.rs b/tests/integration/pull_rebase_ff.rs index 2a6a7873a6..2a500746a9 100644 --- a/tests/integration/pull_rebase_ff.rs +++ b/tests/integration/pull_rebase_ff.rs @@ -539,7 +539,6 @@ fn test_pull_rebase_preserves_committed_ai_authorship() { } #[test] -#[ignore = "temporarily restored by the stacked transport-aware notes sync follow-up"] fn test_pull_rebase_force_pushed_target_preserves_remote_authorship_note() { // Model a clone that still has an old PR head while another clone force-pushes // a rewritten head with its own complete authorship note. Pull processes the @@ -638,6 +637,117 @@ fn test_pull_rebase_force_pushed_target_preserves_remote_authorship_note() { ]); } +#[test] +fn test_pull_rebase_after_collaborator_restack_preserves_both_users_notes() { + // User B has A(old) + B locally. User A restacks and force-pushes A(new) + // with a new note. B's pull --rebase must hydrate A(new)'s note before + // replaying B, while still shifting B's locally available source note. + let (local, upstream) = TestRepo::new_with_remote(); + let file_path = local.path().join("stack.txt"); + + std::fs::write(&file_path, "base\n").unwrap(); + local.stage_all_and_commit("initial").unwrap(); + let mut local_file = local.filename("stack.txt"); + local_file.assert_committed_lines(crate::lines!["base".unattributed_human()]); + + local.git_ai(&["checkpoint", "human", "stack.txt"]).unwrap(); + std::fs::write(&file_path, "base\nA old AI line\n").unwrap(); + local + .git_ai(&["checkpoint", "mock_ai", "stack.txt"]) + .unwrap(); + let old_a = local.stage_all_and_commit("A before restack").unwrap(); + local_file.assert_committed_lines(crate::lines![ + "base".unattributed_human(), + "A old AI line".ai(), + ]); + local.git(&["push", "-u", "origin", "main"]).unwrap(); + + let b_path = local.path().join("b.txt"); + local.git_ai(&["checkpoint", "human", "b.txt"]).unwrap(); + std::fs::write(&b_path, "B AI line\n").unwrap(); + local.git_ai(&["checkpoint", "mock_ai", "b.txt"]).unwrap(); + let old_b = local.stage_all_and_commit("B local work").unwrap(); + local_file.assert_committed_lines(crate::lines![ + "base".unattributed_human(), + "A old AI line".ai(), + ]); + let mut b_file = local.filename("b.txt"); + b_file.assert_committed_lines(crate::lines!["B AI line".ai()]); + + let contributor_parent = tempfile::tempdir().expect("contributor temp dir"); + let contributor_path = contributor_parent.path().join("contributor"); + local + .git_og(&[ + "clone", + upstream.path().to_str().unwrap(), + contributor_path.to_str().unwrap(), + ]) + .unwrap(); + let contributor = + TestRepo::new_at_path_with_daemon_scope(&contributor_path, DaemonTestScope::Shared); + contributor + .git(&["reset", "--hard", &format!("{}^", old_a.commit_sha)]) + .unwrap(); + + let contributor_file_path = contributor.path().join("stack.txt"); + contributor + .git_ai(&["checkpoint", "human", "stack.txt"]) + .unwrap(); + std::fs::write( + &contributor_file_path, + "base\nA old AI line\nA restack AI line\n", + ) + .unwrap(); + contributor + .git_ai(&["checkpoint", "mock_ai", "stack.txt"]) + .unwrap(); + let new_a = contributor.stage_all_and_commit("A after restack").unwrap(); + let mut contributor_file = contributor.filename("stack.txt"); + contributor_file.assert_committed_lines(crate::lines![ + "base".unattributed_human(), + "A old AI line".ai(), + "A restack AI line".ai(), + ]); + contributor + .git(&["push", "--force", "origin", "HEAD:main"]) + .unwrap(); + contributor.sync_daemon_force(); + contributor + .git_og(&["push", "--force", "origin", "refs/notes/ai:refs/notes/ai"]) + .unwrap(); + + assert!( + local.read_authorship_note(&new_a.commit_sha).is_none(), + "precondition: B must be missing A's restacked note" + ); + local.git(&["pull", "--rebase"]).unwrap(); + + let new_b = local + .git(&["rev-parse", "HEAD"]) + .unwrap() + .trim() + .to_string(); + assert_ne!( + new_b, old_b.commit_sha, + "B's local commit should be replayed" + ); + assert_ne!( + new_b, new_a.commit_sha, + "B's replayed commit should remain on top of A" + ); + assert_eq!( + local.git(&["rev-parse", "HEAD^"]).unwrap().trim(), + new_a.commit_sha, + "B's replayed commit should be based on A's restacked commit" + ); + local_file.assert_committed_lines(crate::lines![ + "base".unattributed_human(), + "A old AI line".ai(), + "A restack AI line".ai(), + ]); + b_file.assert_committed_lines(crate::lines!["B AI line".ai()]); +} + #[test] fn test_local_rebase_does_not_fetch_notes_for_fresh_destinations() { let (repo, _upstream) = TestRepo::new_with_remote(); @@ -2135,6 +2245,8 @@ crate::reuse_tests_in_worktree!( test_fast_forward_pull_preserves_ai_attribution, test_fast_forward_pull_without_local_changes, test_pull_rebase_preserves_committed_ai_authorship, + test_pull_rebase_force_pushed_target_preserves_remote_authorship_note, + test_pull_rebase_after_collaborator_restack_preserves_both_users_notes, test_local_rebase_does_not_fetch_notes_for_fresh_destinations, test_pull_rebase_via_git_config_preserves_committed_ai_authorship, test_pull_rebase_via_zero_arg_alias_and_git_config_preserves_committed_ai_authorship, @@ -2154,8 +2266,3 @@ crate::reuse_tests_in_worktree!( test_regular_rebase_conflict_keep_main_side_preserves_main_attribution, test_regular_rebase_with_conflict_abort_preserves_original_notes, ); - -crate::reuse_tests_in_worktree_with_attrs!( - (#[ignore = "temporarily restored by the stacked transport-aware notes sync follow-up"]) - test_pull_rebase_force_pushed_target_preserves_remote_authorship_note, -); diff --git a/tests/notes_sync_regression.rs b/tests/notes_sync_regression.rs index 94e2919f04..0bb6598b8a 100644 --- a/tests/notes_sync_regression.rs +++ b/tests/notes_sync_regression.rs @@ -2,8 +2,14 @@ #[path = "integration/repos/mod.rs"] mod repos; +use git_ai::authorship::authorship_log::LineRange; +use git_ai::authorship::authorship_log_serialization::AuthorshipLog; +use git_ai::config::{NotesBackendConfig, NotesBackendKind}; +use git_ai::git::notes_api::warm_cache_for_revisions; +use git_ai::git::repository::find_repository_in_path; use git_ai::notes::db::NotesDatabase; use git_ai::notes::reference_server::ReferenceServer; +use repos::test_file::ExpectedLineExt; use repos::test_repo::{DaemonTestScope, TestRepo, real_git_executable}; use std::fs; use std::path::{Path, PathBuf}; @@ -553,7 +559,7 @@ fn notes_sync_http_backend_clone_warms_notes_cache() { } worktree_test_wrappers! { - fn notes_sync_fetch_does_not_import_authorship_notes() { + fn notes_sync_fetch_imports_authorship_notes() { let (local, _upstream) = TestRepo::new_with_remote(); fs::write(local.path().join("fetch-seed.txt"), "seed\n") @@ -600,13 +606,103 @@ worktree_test_wrappers! { let fetched_note = local.read_authorship_note(&seed_sha); assert!( - fetched_note.is_none(), - "plain git fetch should not import authorship note for commit {}", + fetched_note.is_some(), + "plain git fetch should import authorship note for commit {}", seed_sha ); } } +worktree_test_wrappers! { + fn notes_sync_fetch_dry_run_does_not_import_authorship_notes() { + let (local, _upstream) = TestRepo::new_with_remote(); + + fs::write(local.path().join("fetch-dry-run-seed.txt"), "seed\n") + .expect("failed to write fetch dry-run seed file"); + local + .git_og(&["add", "fetch-dry-run-seed.txt"]) + .expect("add should succeed"); + local + .git_og(&["commit", "-m", "fetch dry-run seed commit"]) + .expect("seed commit should succeed"); + + let seed_sha = local + .git_og(&["rev-parse", "HEAD"]) + .expect("rev-parse should succeed") + .trim() + .to_string(); + + local + .git_og(&[ + "notes", + "--ref=ai", + "add", + "-m", + "fetch-dry-run-seed-note", + seed_sha.as_str(), + ]) + .expect("adding notes should succeed"); + local + .git_og(&["push", "-u", "origin", "HEAD"]) + .expect("pushing branch should succeed"); + local + .git_og(&["push", "origin", "refs/notes/ai"]) + .expect("pushing notes should succeed"); + + local + .git_og(&["update-ref", "-d", "refs/notes/ai"]) + .expect("deleting the local note ref should succeed"); + assert!( + local.read_authorship_note(&seed_sha).is_none(), + "precondition: local note should be absent before fetch --dry-run" + ); + + local + .git(&["fetch", "--dry-run", "origin"]) + .expect("fetch --dry-run should succeed"); + + assert!( + local.read_authorship_note(&seed_sha).is_none(), + "fetch --dry-run must not fetch or import authorship notes for {}", + seed_sha + ); + } +} + +#[test] +#[serial_test::serial(notes_db_env)] +fn notes_sync_http_warm_cache_accepts_many_revision_tips_without_large_process_args() { + let local = TestRepo::new_with_daemon_scope(DaemonTestScope::NoDaemon); + fs::write(local.path().join("many-revisions.txt"), "seed\n").expect("write seed file"); + local + .git_og(&["add", "many-revisions.txt"]) + .expect("add seed file"); + local + .git_og(&["commit", "-m", "many revisions seed"]) + .expect("commit seed file"); + let head = local + .git_og(&["rev-parse", "HEAD"]) + .expect("read HEAD") + .trim() + .to_string(); + + let notes_db_path = unique_temp_path("notes-sync-many-revisions-db"); + // Safety: this integration test is serialized with other notes-db env users. + unsafe { + std::env::set_var("GIT_AI_TEST_NOTES_DB_PATH", ¬es_db_path); + } + + let repo = find_repository_in_path(&local.path().to_string_lossy()) + .expect("discover TestRepo repository"); + let revisions = vec![head; 100_000]; + warm_cache_for_revisions(&repo, &revisions) + .expect("revision tips should be supplied to one git process via stdin"); + + unsafe { + std::env::remove_var("GIT_AI_TEST_NOTES_DB_PATH"); + } +} + worktree_test_wrappers! { fn notes_sync_pull_fast_forward_imports_authorship_notes() { let (local, upstream) = TestRepo::new_with_remote(); @@ -1294,6 +1390,172 @@ fn notes_sync_http_backend_plain_pull_warms_notes_cache() { ); } +#[test] +fn notes_sync_http_backend_pull_rebase_preserves_force_pushed_target_note() { + let server = ReferenceServer::start("127.0.0.1:0").expect("start notes reference server"); + let backend_url = server.base_url(); + let api_key = "notes-sync-http-rebase-test-key"; + let mut local = TestRepo::new_with_daemon_env(&[ + ("GIT_AI_NOTES_BACKEND_KIND", "http"), + ("GIT_AI_NOTES_BACKEND_URL", backend_url.as_str()), + ("GIT_AI_API_KEY", api_key), + ]); + local.patch_git_ai_config(|patch| { + patch.notes_backend = Some(NotesBackendConfig { + kind: NotesBackendKind::Http, + backend_url: Some(backend_url.clone()), + }); + }); + let notes_db_path = local + .test_home_path() + .join(".git-ai") + .join("internal") + .join("notes-db"); + let upstream = TestRepo::new_bare_with_daemon_scope(DaemonTestScope::NoDaemon); + let upstream_str = upstream.path().to_string_lossy().to_string(); + + local + .git_og(&["remote", "add", "origin", upstream_str.as_str()]) + .expect("add origin"); + let feature_path = local.path().join("feature.txt"); + fs::write(&feature_path, "base\n").expect("write base"); + local.git_og(&["add", "feature.txt"]).expect("add base"); + local + .git_og(&["commit", "-m", "base commit"]) + .expect("commit base"); + let mut local_file = local.filename("feature.txt"); + local_file.assert_committed_lines(crate::lines!["base".unattributed_human()]); + + local + .git_ai(&["checkpoint", "human", "feature.txt"]) + .expect("checkpoint before AI edit"); + fs::write(&feature_path, "base\nold AI line\n").expect("write old feature"); + local + .git_ai(&["checkpoint", "mock_ai", "feature.txt"]) + .expect("checkpoint AI edit"); + local.git(&["add", "feature.txt"]).expect("add old feature"); + local + .git(&["commit", "-m", "old feature version"]) + .expect("commit old feature"); + local.sync_daemon_force(); + local_file.assert_committed_lines(crate::lines![ + "base".unattributed_human(), + "old AI line".ai(), + ]); + let old_commit = local + .git_og(&["rev-parse", "HEAD"]) + .expect("read old feature commit") + .trim() + .to_string(); + let old_note = NotesDatabase::open_at_path(¬es_db_path) + .expect("open notes db") + .get_note(&old_commit) + .expect("read old note") + .expect("local rewrite source should have an HTTP-backed note"); + local + .git_og(&["push", "-u", "origin", "HEAD"]) + .expect("push old feature"); + + let remote_clone = unique_temp_path("notes-sync-http-rebase-remote"); + let remote_clone_str = remote_clone.to_string_lossy().to_string(); + run_git(&["clone", upstream_str.as_str(), remote_clone_str.as_str()]); + run_git(&[ + "-C", + remote_clone_str.as_str(), + "config", + "user.name", + "Test User", + ]); + run_git(&[ + "-C", + remote_clone_str.as_str(), + "config", + "user.email", + "test@example.com", + ]); + run_git(&[ + "-C", + remote_clone_str.as_str(), + "reset", + "--hard", + &format!("{old_commit}^"), + ]); + fs::write( + remote_clone.join("feature.txt"), + "base\nold AI line\nremote AI line\n", + ) + .expect("write force-pushed feature"); + run_git(&["-C", remote_clone_str.as_str(), "add", "feature.txt"]); + run_git(&[ + "-C", + remote_clone_str.as_str(), + "commit", + "-m", + "force-pushed feature version", + ]); + let remote_sha = run_git(&["-C", remote_clone_str.as_str(), "rev-parse", "HEAD"]); + run_git(&[ + "-C", + remote_clone_str.as_str(), + "push", + "--force", + "origin", + "HEAD", + ]); + + let mut remote_log = + AuthorshipLog::deserialize_from_string(&old_note).expect("parse old authorship note"); + remote_log.metadata.base_commit_sha = "authoritative-remote-target".to_string(); + let remote_entry = remote_log + .attestations + .iter_mut() + .find(|file_attestation| file_attestation.file_path == "feature.txt") + .and_then(|file_attestation| { + file_attestation + .entries + .iter_mut() + .find(|entry| entry.line_ranges.iter().any(|range| range.contains(2))) + }) + .expect("old note should attribute the existing AI line"); + remote_entry.line_ranges.push(LineRange::Single(3)); + let remote_note = remote_log + .serialize_to_string() + .expect("serialize remote authorship note"); + server.store().put(remote_sha.clone(), remote_note.clone()); + assert_eq!( + NotesDatabase::open_at_path(¬es_db_path) + .expect("open notes db before pull") + .get_note(&remote_sha) + .expect("read target note before pull"), + None, + "precondition: local cache must be missing User A's rewritten note" + ); + + local + .git(&["pull", "--rebase"]) + .expect("pull --rebase should succeed"); + local.sync_daemon_force(); + + assert_eq!( + local.git_og(&["rev-parse", "HEAD"]).unwrap().trim(), + remote_sha, + "Git should recognize the local patch in the force-pushed target" + ); + assert_eq!( + NotesDatabase::open_at_path(¬es_db_path) + .expect("open notes db after pull") + .get_note(&remote_sha) + .expect("read target note after pull"), + Some(remote_note), + "transport hydration must cache User A's target note before rewrite shifting" + ); + local_file.assert_committed_lines(crate::lines![ + "base".unattributed_human(), + "old AI line".ai(), + "remote AI line".ai(), + ]); +} + worktree_test_wrappers! { fn notes_sync_push_propagates_authorship_notes_to_remote() { let (local, upstream) = TestRepo::new_with_remote();