diff --git a/src/daemon.rs b/src/daemon.rs index aef688932f..80d6f4e5b4 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -6352,9 +6352,11 @@ impl ActorDaemonCoordinator { } } - // Handle update-ref: migrate working logs and authorship notes when the ref - // update affects the currently checked-out branch. - if primary == "update-ref" + // Handle direct tip movers: migrate working logs and authorship notes when + // the update affects the currently checked-out branch. A branch create/reset + // (`branch -f`) uses the same rewrite, while rename/copy lifecycle events do not. + if (primary == "update-ref" + || primary == "branch" && crate::daemon::ref_cursor::branch_command_is_tip_update(cmd)) && let Some(worktree) = cmd.worktree.as_ref() { for event in events { diff --git a/src/daemon/ref_cursor.rs b/src/daemon/ref_cursor.rs index d5508c25fd..e244063e9a 100644 --- a/src/daemon/ref_cursor.rs +++ b/src/daemon/ref_cursor.rs @@ -196,7 +196,7 @@ impl RefCursor { "rebase" => self.consume_rebase_transition(cmd, state), "pull" => self.consume_pull_transition(cmd, state), "branch" => self.enrich_branch(cmd, state), - "stash" => self.enrich_stash(cmd, state), + "stash" => self.enrich_stash(cmd, state, &command_start_refs), "update-ref" => self.enrich_update_ref(cmd, state), _ => Ok(()), }?; @@ -809,28 +809,21 @@ impl RefCursor { let spec = parse_update_ref_spec(&args)?; let Some(spec) = spec else { let mut changes = Vec::new(); - if let Some(worktree) = cmd.worktree.as_deref() { - while let Some(entry) = self.find_head_entry_without_hint( - Some(worktree), - &[], - ExpectedTransition::default(), - )? { - self.consume_entry(&entry)?; - changes.push(entry_to_ref_change(&entry)); - } + if let Some(worktree) = cmd.worktree.as_deref() + && let Some(git_dir) = git_dir_for_worktree(worktree) + { + let key = head_key(&git_dir); + let path = git_dir.join("logs").join("HEAD"); + changes.extend(self.consume_unstructured_update_ref_entries(&key, &path, "HEAD")?); } for reference in self.discover_common_refs()? { if reference == "HEAD" || reference == "ORIG_HEAD" { continue; } - while let Some(entry) = self.find_common_ref_entry_without_hint( - &reference, - ExpectedTransition::default(), - &[], - )? { - self.consume_entry(&entry)?; - changes.push(entry_to_ref_change(&entry)); - } + let key = common_key(&reference); + let path = self.common_dir().join("logs").join(&reference); + changes + .extend(self.consume_unstructured_update_ref_entries(&key, &path, &reference)?); } dedup_ref_changes(&mut changes); cmd.ref_changes = changes; @@ -949,12 +942,18 @@ impl RefCursor { &mut self, cmd: &mut NormalizedCommand, state: &FamilyState, + command_start_refs: &HashMap, ) -> Result<(), GitAiError> { let args = command_args(cmd); let stash_args = stash_command_args(&args); let kind = stash_args.first().map(String::as_str).unwrap_or("push"); if matches!(kind, "apply" | "pop" | "drop" | "branch") { + if kind == "apply" { + // Only apply leaves the stash reflog intact, so its captured + // boundary still identifies the pre-command stack tip. + self.reconcile_stash_stack_top(command_start_refs.get("refs/stash")); + } let target = if kind == "branch" { stash_args.get(2) } else { @@ -1162,15 +1161,10 @@ impl RefCursor { Ok(()) } - // DEFERRED (code-review #14): this finder scans the reflog from - // reflog_start_offset and takes the first unconsumed entry matching the - // message/transition; it does not use the command's ingress hint (the - // reflog position at which THIS command began) to bound the search. In - // pathological histories with repeated identical rebase-start messages and - // transitions, it could match an earlier same-shaped entry than the one - // this command produced. Bounding by the per-command ingress offset would - // make the match exact; deferred as it needs the ingress offset threaded - // through to the finder. + // A prior untraced rebase can leave a complete matching span after the + // in-order cursor. Use the command-start hint to select this command's start + // marker while retaining the late-capture fallback shared by other cursor + // lookups. fn find_rebase_start_entry( &mut self, cmd: &NormalizedCommand, @@ -1187,16 +1181,17 @@ impl RefCursor { let key = head_key(&git_dir); let path = git_dir.join("logs").join("HEAD"); let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries(key, &path, "HEAD", start)?; - - Ok(entries.into_iter().find(|entry| { + let entries = read_reflog_entries(key.clone(), &path, "HEAD", start)?; + let candidates = entries.into_iter().filter(|entry| { !self.entry_consumed(entry) && rebase_reflog_action_is(&entry.message, "start") && expected.matches_span_boundary(entry) && target .as_deref() .is_none_or(|target| rebase_start_message_targets(&entry.message, target)) - })) + }); + + Ok(self.select_candidate_with_hint(&key, candidates)) } fn consume_failed_explicit_branch_rebase_start( @@ -1435,13 +1430,14 @@ impl RefCursor { let key = head_key(&git_dir); let path = git_dir.join("logs").join("HEAD"); let start = self.reflog_start_offset(&key, &path)?; - let entries = read_reflog_entries(key, &path, "HEAD", start)?; - - Ok(entries.into_iter().find(|entry| { + let entries = read_reflog_entries(key.clone(), &path, "HEAD", start)?; + let candidates = entries.into_iter().filter(|entry| { !self.entry_consumed(entry) && pull_reflog_action_is(&entry.message, action, "start") && expected.matches_span_boundary(entry) - })) + }); + + Ok(self.select_candidate_with_hint(&key, candidates)) } // Span commands can append several contiguous HEAD rows. If command ingress @@ -1608,6 +1604,36 @@ impl RefCursor { })) } + fn consume_unstructured_update_ref_entries( + &mut self, + key: &str, + path: &Path, + reference: &str, + ) -> Result, GitAiError> { + let start = self.reflog_start_offset(key, path)?; + let mut entries = read_reflog_entries(key.to_string(), path, reference, start)? + .into_iter() + .filter(|entry| !self.entry_consumed(entry)) + .collect::>(); + if let Some(command_boundary) = self.command_start_hints.get(key).copied() { + if let Some(first_current) = entries + .iter() + .position(|entry| entry.start_offset >= command_boundary) + { + entries.drain(..first_current); + } else if let Some(latest_before_boundary) = entries.pop() { + entries.clear(); + entries.push(latest_before_boundary); + } + } + let mut changes = Vec::new(); + for entry in entries { + self.consume_entry(&entry)?; + changes.push(entry_to_ref_change(&entry)); + } + Ok(changes) + } + fn consume_unique_direct_common_ref_matching_timestamp( &mut self, cmd: &NormalizedCommand, @@ -1712,23 +1738,6 @@ impl RefCursor { ) } - fn find_common_ref_entry_without_hint( - &mut self, - reference: &str, - expected: ExpectedTransition, - message_prefixes: &[&str], - ) -> Result, GitAiError> { - let path = self.common_dir().join("logs").join(reference); - self.find_entry_in_log_with_hint( - common_key(reference), - &path, - reference, - expected, - message_prefixes, - false, - ) - } - fn find_stash_push_entry( &mut self, stash_args: &[String], @@ -1968,6 +1977,24 @@ impl RefCursor { Ok(stack.get(index).cloned()) } + fn reconcile_stash_stack_top(&mut self, observed_tip: Option<&String>) { + let Some(observed_tip) = observed_tip.filter(|oid| valid_non_zero_oid(oid)) else { + return; + }; + if self.stash_stack.first() == Some(observed_tip) { + return; + } + if let Some(position) = self.stash_stack.iter().position(|oid| oid == observed_tip) { + self.stash_stack.drain(..position); + } else { + // A missed push can introduce an unknown top. Preserve only the exact + // tip captured at this command's reflog boundary; deeper symbolic + // indices will use the existing cursor-bounded reflog fallback. + self.stash_stack.clear(); + self.stash_stack.push(observed_tip.clone()); + } + } + fn apply_stash_ref_entry(&mut self, kind: &str, entry: &CursorEntry) { match kind { "push" | "save" => { @@ -3534,6 +3561,13 @@ fn parse_branch_command_spec(args: &[String]) -> BranchCommandSpec { .unwrap_or(BranchCommandSpec::None) } +pub(super) fn branch_command_is_tip_update(cmd: &NormalizedCommand) -> bool { + matches!( + parse_branch_command_spec(&command_args(cmd)), + BranchCommandSpec::CreateOrReset { .. } + ) +} + fn branch_command_args(args: &[String]) -> &[String] { if args.first().is_some_and(|arg| arg == "branch") { &args[1..] @@ -4169,6 +4203,141 @@ mod tests { ); } + #[test] + fn ingress_offset_hint_skips_untraced_rebase_span() { + let temp = tempfile::tempdir().unwrap(); + let worktree = temp.path().join("repo"); + let git_dir = create_git_dir(&worktree); + let head_log = git_dir.join("logs/HEAD"); + fs::create_dir_all(head_log.parent().unwrap()).unwrap(); + + let base_line = + format!("{A} {B} Test User 0 +0000\tcommit: traced base\n"); + let missed_start = format!( + "{B} {C} Test User 0 +0000\trebase (start): checkout main\n" + ); + let missed_pick = + format!("{C} {D} Test User 0 +0000\trebase (pick): missed\n"); + let missed_finish = format!( + "{D} {D} Test User 0 +0000\trebase (finish): returning to refs/heads/missed\n" + ); + let checkout = format!( + "{D} {E} Test User 0 +0000\tcheckout: moving from missed to traced\n" + ); + let traced_start = format!( + "{E} {C} Test User 0 +0000\trebase (start): checkout main\n" + ); + let traced_pick = + format!("{C} {F} Test User 0 +0000\trebase (pick): traced\n"); + let traced_finish = format!( + "{F} {F} Test User 0 +0000\trebase (finish): returning to refs/heads/traced\n" + ); + let in_order_offset = base_line.len() as u64; + let command_start_offset = (base_line.len() + + missed_start.len() + + missed_pick.len() + + missed_finish.len() + + checkout.len()) as u64; + fs::write( + &head_log, + format!( + "{base_line}{missed_start}{missed_pick}{missed_finish}{checkout}{traced_start}{traced_pick}{traced_finish}" + ), + ) + .unwrap(); + + let family = FamilyKey::new(git_dir.to_string_lossy().to_string()); + let mut cursor = RefCursor::new(family.clone()); + cursor + .initialize_reflog_cursor(&head_key(&git_dir), in_order_offset) + .unwrap(); + let mut cmd = command_with_worktree(&family, Some(worktree), &["rebase", "main"]); + cmd.reflog_start_offsets + .insert(head_key(&git_dir), command_start_offset); + cursor + .initialize_from_command_reflog_start_offsets(&cmd) + .unwrap(); + + let entry = cursor + .find_rebase_start_entry(&cmd, ExpectedTransition::default()) + .unwrap() + .expect("current rebase start should be found"); + + assert_eq!(entry.start_offset, command_start_offset); + assert_eq!(entry.old, E); + assert_eq!(entry.new, C); + } + + #[test] + fn ingress_offset_hint_skips_untraced_pull_rebase_span() { + let temp = tempfile::tempdir().unwrap(); + let worktree = temp.path().join("repo"); + let git_dir = create_git_dir(&worktree); + let head_log = git_dir.join("logs/HEAD"); + fs::create_dir_all(head_log.parent().unwrap()).unwrap(); + let action = "pull --rebase origin main"; + + let base_line = + format!("{A} {B} Test User 0 +0000\tcommit: traced base\n"); + let missed_start = format!( + "{B} {C} Test User 0 +0000\t{action} (start): checkout main\n" + ); + let missed_pick = + format!("{C} {D} Test User 0 +0000\t{action} (pick): missed\n"); + let missed_finish = format!( + "{D} {D} Test User 0 +0000\t{action} (finish): returning to refs/heads/missed\n" + ); + let checkout = format!( + "{D} {E} Test User 0 +0000\tcheckout: moving from missed to traced\n" + ); + let traced_start = format!( + "{E} {C} Test User 0 +0000\t{action} (start): checkout main\n" + ); + let traced_pick = + format!("{C} {F} Test User 0 +0000\t{action} (pick): traced\n"); + let traced_finish = format!( + "{F} {F} Test User 0 +0000\t{action} (finish): returning to refs/heads/traced\n" + ); + let in_order_offset = base_line.len() as u64; + let command_start_offset = (base_line.len() + + missed_start.len() + + missed_pick.len() + + missed_finish.len() + + checkout.len()) as u64; + fs::write( + &head_log, + format!( + "{base_line}{missed_start}{missed_pick}{missed_finish}{checkout}{traced_start}{traced_pick}{traced_finish}" + ), + ) + .unwrap(); + + let family = FamilyKey::new(git_dir.to_string_lossy().to_string()); + let mut cursor = RefCursor::new(family.clone()); + cursor + .initialize_reflog_cursor(&head_key(&git_dir), in_order_offset) + .unwrap(); + let mut cmd = command_with_worktree( + &family, + Some(worktree), + &["pull", "--rebase", "origin", "main"], + ); + cmd.reflog_start_offsets + .insert(head_key(&git_dir), command_start_offset); + cursor + .initialize_from_command_reflog_start_offsets(&cmd) + .unwrap(); + + let entry = cursor + .find_pull_start_entry(&cmd, ExpectedTransition::default(), action) + .unwrap() + .expect("current pull-rebase start should be found"); + + assert_eq!(entry.start_offset, command_start_offset); + assert_eq!(entry.old, E); + assert_eq!(entry.new, C); + } + #[test] fn late_ingress_offset_skips_untraced_duplicate_message_commit() { // Same duplicate-message shape as above, but the async ingress capture @@ -4510,6 +4679,86 @@ mod tests { ); } + #[test] + fn initialized_common_boundary_skips_untraced_update_ref_transactions() { + let temp = tempfile::tempdir().unwrap(); + let reference = "refs/heads/main"; + let log_path = temp.path().join("logs").join(reference); + fs::create_dir_all(log_path.parent().unwrap()).unwrap(); + + let base_line = format!("{A} {B} Test User 0 +0000\tbase\n"); + let missed_forward = + format!("{B} {C} Test User 0 +0000\tmissed forward\n"); + let missed_back = format!("{C} {B} Test User 0 +0000\tmissed back\n"); + let current_line = + format!("{B} {D} Test User 0 +0000\tcurrent stdin update\n"); + let in_order_offset = base_line.len() as u64; + let command_start_offset = + (base_line.len() + missed_forward.len() + missed_back.len()) as u64; + fs::write( + &log_path, + format!("{base_line}{missed_forward}{missed_back}{current_line}"), + ) + .unwrap(); + + let family = FamilyKey::new(temp.path().to_string_lossy().to_string()); + let state = family_state(&family); + let mut cursor = RefCursor::new(family.clone()); + cursor + .initialize_reflog_cursor(&common_key(reference), in_order_offset) + .unwrap(); + let mut cmd = command(&family, &["update-ref", "--stdin"]); + cmd.reflog_start_offsets + .insert(common_key(reference), command_start_offset); + + cursor.enrich_command(&mut cmd, &state).unwrap(); + + assert_eq!( + cmd.ref_changes, + vec![RefChange { + reference: reference.to_string(), + old: B.to_string(), + new: D.to_string(), + }] + ); + } + + #[test] + fn late_common_boundary_falls_back_to_current_unstructured_update_ref() { + let temp = tempfile::tempdir().unwrap(); + let reference = "refs/heads/main"; + let log_path = temp.path().join("logs").join(reference); + fs::create_dir_all(log_path.parent().unwrap()).unwrap(); + + let base_line = format!("{A} {B} Test User 0 +0000\tbase\n"); + let current_line = + format!("{B} {C} Test User 0 +0000\tcurrent stdin update\n"); + let in_order_offset = base_line.len() as u64; + let late_command_start_offset = (base_line.len() + current_line.len()) as u64; + fs::write(&log_path, format!("{base_line}{current_line}")).unwrap(); + + let family = FamilyKey::new(temp.path().to_string_lossy().to_string()); + let state = family_state(&family); + let mut cursor = RefCursor::new(family.clone()); + cursor + .initialize_reflog_cursor(&common_key(reference), in_order_offset) + .unwrap(); + let mut cmd = command(&family, &["update-ref", "--stdin"]); + cmd.reflog_start_offsets + .insert(common_key(reference), late_command_start_offset); + + cursor.enrich_command(&mut cmd, &state).unwrap(); + + assert_eq!( + cmd.ref_changes, + vec![RefChange { + reference: reference.to_string(), + old: B.to_string(), + new: C.to_string(), + }] + ); + } + #[test] fn direct_branch_update_ref_uses_argv_transition_when_reflog_cursor_starts_too_late() { let temp = tempfile::tempdir().unwrap(); @@ -6073,6 +6322,76 @@ mod tests { assert_eq!(cursor.stash_stack, vec![C.to_string()]); } + #[test] + fn stash_target_reconciles_to_command_start_tip_after_untraced_drop() { + let temp = tempfile::tempdir().unwrap(); + let reference = "refs/stash"; + let log_path = temp.path().join("logs").join(reference); + fs::create_dir_all(log_path.parent().unwrap()).unwrap(); + + let surviving = format!( + "{} {B} Test User 0 +0000\tOn main: surviving\n", + zero_oid() + ); + let dropped = format!("{B} {C} Test User 0 +0000\tOn main: dropped\n"); + fs::write(&log_path, &surviving).unwrap(); + + let family = FamilyKey::new(temp.path().to_string_lossy().to_string()); + let state = family_state(&family); + let mut cursor = RefCursor::new(family.clone()); + cursor.offsets.insert( + common_key(reference), + (surviving.len() + dropped.len()) as u64, + ); + cursor.stash_stack = vec![C.to_string(), B.to_string()]; + let mut cmd = command(&family, &["stash", "apply", "stash@{0}"]); + cmd.reflog_start_offsets + .insert(common_key(reference), surviving.len() as u64); + + cursor.enrich_command(&mut cmd, &state).unwrap(); + + assert_eq!(cmd.stash_target_oid.as_deref(), Some(B)); + assert_eq!(cursor.stash_stack, vec![B.to_string()]); + } + + #[test] + fn destructive_stash_targets_ignore_post_rewrite_boundary_tip() { + for args in [ + vec!["stash", "pop", "stash@{0}"], + vec!["stash", "drop", "stash@{0}"], + vec!["stash", "branch", "restored", "stash@{0}"], + ] { + let temp = tempfile::tempdir().unwrap(); + let reference = "refs/stash"; + let log_path = temp.path().join("logs").join(reference); + fs::create_dir_all(log_path.parent().unwrap()).unwrap(); + + let surviving = format!( + "{} {B} Test User 0 +0000\tOn main: surviving\n", + zero_oid() + ); + let removed = + format!("{B} {C} Test User 0 +0000\tOn main: removed\n"); + fs::write(&log_path, &surviving).unwrap(); + + let family = FamilyKey::new(temp.path().to_string_lossy().to_string()); + let state = family_state(&family); + let mut cursor = RefCursor::new(family.clone()); + cursor.offsets.insert( + common_key(reference), + (surviving.len() + removed.len()) as u64, + ); + cursor.stash_stack = vec![C.to_string(), B.to_string()]; + let mut cmd = command(&family, &args); + cmd.reflog_start_offsets + .insert(common_key(reference), surviving.len() as u64); + + cursor.enrich_command(&mut cmd, &state).unwrap(); + + assert_eq!(cmd.stash_target_oid.as_deref(), Some(C), "args: {args:?}"); + } + } + #[test] fn cold_stash_push_uses_command_reflog_boundary_without_message() { let temp = tempfile::tempdir().unwrap(); diff --git a/tests/commit_tree_update_ref.rs b/tests/commit_tree_update_ref.rs index e7d6f77bb5..477890f49a 100644 --- a/tests/commit_tree_update_ref.rs +++ b/tests/commit_tree_update_ref.rs @@ -135,8 +135,15 @@ fn raw_traced_git(repo: &TestRepo, args: &[&str]) -> String { } fn raw_traced_git_stdin(repo: &TestRepo, args: &[&str], stdin: &str) -> String { + let session = new_daemon_test_sync_session_id(); + let session_arg = format!("git-ai.testSyncSession={session}"); let mut command = Command::new(real_git_executable()); - command.arg("-C").arg(repo.path()).args(args); + command + .arg("-C") + .arg(repo.path()) + .arg("-c") + .arg(&session_arg) + .args(args); command.env("HOME", repo.test_home_path()); command.env( "GIT_CONFIG_GLOBAL", @@ -179,6 +186,46 @@ fn raw_traced_git_stdin(repo: &TestRepo, args: &[&str], stdin: &str) -> String { stdout, stderr ); + repo.sync_daemon_external_completion_sessions(&[session]); + combined_output(stdout, stderr) +} + +fn raw_untraced_git_stdin(repo: &TestRepo, args: &[&str], stdin: &str) -> String { + let mut command = Command::new(real_git_executable()); + command.arg("-C").arg(repo.path()).args(args); + command.env("HOME", repo.test_home_path()); + command.env( + "GIT_CONFIG_GLOBAL", + repo.test_home_path().join(".gitconfig"), + ); + command.env("XDG_CONFIG_HOME", repo.test_home_path().join(".config")); + command.env("GIT_CONFIG_NOSYSTEM", "1"); + command.env("GIT_TRACE2_EVENT", "0"); + command.stdin(Stdio::piped()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let mut child = command + .spawn() + .unwrap_or_else(|error| panic!("failed to run raw untraced git {:?}: {}", args, error)); + child + .stdin + .take() + .expect("stdin should be piped") + .write_all(stdin.as_bytes()) + .expect("write stdin to raw untraced git"); + let output = child.wait_with_output().unwrap_or_else(|error| { + panic!("failed to wait for raw untraced git {:?}: {}", args, error) + }); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + assert!( + output.status.success(), + "raw untraced git {:?} failed\nstdout: {}\nstderr: {}", + args, + stdout, + stderr + ); combined_output(stdout, stderr) } @@ -1777,6 +1824,292 @@ fn test_update_ref_current_branch_with_new_content_preserves_attribution() { feature_file.assert_lines_and_blame(lines!["branch ai".ai()]); } +#[test] +fn test_traced_update_ref_after_untraced_update_refs_preserves_source_ai_attribution() { + let repo = TestRepo::new(); + setup_initial_commit(&repo); + let mut readme = repo.filename("README.md"); + readme.assert_committed_lines(lines!["# Test Repo".human()]); + + repo.git(&["checkout", "-b", "feature"]) + .expect("checkout feature should succeed"); + let mut feature_file = repo.filename("update-ref-gap.txt"); + feature_file.set_contents(lines!["update ref ai".ai()]); + let source = repo + .stage_all_and_commit("update-ref source") + .expect("source commit should succeed") + .commit_sha; + feature_file.assert_committed_lines(lines!["update ref ai".ai()]); + + let parent = raw_untraced_git(&repo, &["rev-parse", &format!("{source}^1")]) + .trim() + .to_string(); + let tree = raw_untraced_git(&repo, &["rev-parse", &format!("{source}^{{tree}}")]) + .trim() + .to_string(); + let missed_destination = raw_untraced_git( + &repo, + &[ + "commit-tree", + &tree, + "-p", + &parent, + "-m", + "missed update-ref destination", + ], + ) + .trim() + .to_string(); + let traced_destination = raw_untraced_git( + &repo, + &[ + "commit-tree", + &tree, + "-p", + &parent, + "-m", + "traced update-ref destination", + ], + ) + .trim() + .to_string(); + let branch_ref = "refs/heads/feature"; + + repo.sync_daemon(); + raw_untraced_git( + &repo, + &["update-ref", branch_ref, &missed_destination, &source], + ); + assert!( + repo.read_authorship_note(&missed_destination).is_none(), + "the trace-disabled destination should not receive attribution" + ); + raw_untraced_git( + &repo, + &["update-ref", branch_ref, &source, &missed_destination], + ); + + repo.git(&["update-ref", branch_ref, &traced_destination, &source]) + .expect("traced update-ref should succeed"); + + assert_note_has_ai_for_file(&repo, &traced_destination, "update-ref-gap.txt"); + feature_file.assert_committed_lines(lines!["update ref ai".ai()]); +} + +#[test] +fn test_traced_branch_force_after_untraced_branch_forces_preserves_source_ai_attribution() { + let repo = TestRepo::new(); + setup_initial_commit(&repo); + let main = repo.current_branch(); + let mut readme = repo.filename("README.md"); + readme.assert_committed_lines(lines!["# Test Repo".human()]); + + repo.git(&["checkout", "-b", "feature"]).unwrap(); + let mut feature_file = repo.filename("branch-force-gap.txt"); + feature_file.set_contents(lines!["branch force ai".ai()]); + let source = repo + .stage_all_and_commit("branch force source") + .unwrap() + .commit_sha; + feature_file.assert_committed_lines(lines!["branch force ai".ai()]); + + let parent = raw_untraced_git(&repo, &["rev-parse", &format!("{source}^1")]) + .trim() + .to_string(); + let tree = raw_untraced_git(&repo, &["rev-parse", &format!("{source}^{{tree}}")]) + .trim() + .to_string(); + let missed_destination = raw_untraced_git( + &repo, + &[ + "commit-tree", + &tree, + "-p", + &parent, + "-m", + "missed branch force destination", + ], + ) + .trim() + .to_string(); + let traced_destination = raw_untraced_git( + &repo, + &[ + "commit-tree", + &tree, + "-p", + &parent, + "-m", + "traced branch force destination", + ], + ) + .trim() + .to_string(); + + repo.git(&["checkout", &main]).unwrap(); + repo.sync_daemon(); + raw_untraced_git(&repo, &["branch", "-f", "feature", &missed_destination]); + assert!( + repo.read_authorship_note(&missed_destination).is_none(), + "the trace-disabled branch destination should not receive attribution" + ); + raw_untraced_git(&repo, &["branch", "-f", "feature", &source]); + + repo.git(&["branch", "-f", "feature", &traced_destination]) + .unwrap(); + repo.sync_daemon(); + + assert_note_has_ai_for_file(&repo, &traced_destination, "branch-force-gap.txt"); + repo.git(&["checkout", "feature"]).unwrap(); + feature_file.assert_committed_lines(lines!["branch force ai".ai()]); +} + +fn assert_branch_relocation_over_existing_does_not_migrate_note(flag: &str) { + let repo = TestRepo::new(); + setup_initial_commit(&repo); + let main = repo.current_branch(); + + repo.git(&["checkout", "-b", "destination"]).unwrap(); + let mut file = repo.filename("branch-relocation.txt"); + file.set_contents(lines!["destination ai".ai()]); + let destination = repo + .stage_all_and_commit("destination ai") + .unwrap() + .commit_sha; + file.assert_committed_lines(lines!["destination ai".ai()]); + assert_note_has_ai_for_file(&repo, &destination, "branch-relocation.txt"); + + let parent = raw_untraced_git(&repo, &["rev-parse", &format!("{destination}^1")]) + .trim() + .to_string(); + let tree = raw_untraced_git(&repo, &["rev-parse", &format!("{destination}^{{tree}}")]) + .trim() + .to_string(); + let source = raw_untraced_git( + &repo, + &[ + "commit-tree", + &tree, + "-p", + &parent, + "-m", + "unattributed relocation source", + ], + ) + .trim() + .to_string(); + assert!(repo.read_authorship_note(&source).is_none()); + + repo.git(&["checkout", &main]).unwrap(); + repo.git(&["branch", "source", &source]).unwrap(); + repo.git(&["checkout", "source"]).unwrap(); + file.assert_committed_lines(lines!["destination ai".human()]); + repo.git(&["checkout", &main]).unwrap(); + repo.git(&["branch", flag, "source", "destination"]) + .unwrap(); + repo.sync_daemon(); + + assert_eq!( + repo.git(&["rev-parse", "destination"]).unwrap().trim(), + source + ); + repo.git(&["checkout", "destination"]).unwrap(); + file.assert_committed_lines(lines!["destination ai".human()]); + assert!( + repo.read_authorship_note(&source).is_none(), + "branch {flag} must not migrate the overwritten destination note" + ); +} + +#[test] +fn test_branch_force_rename_over_existing_does_not_migrate_note() { + assert_branch_relocation_over_existing_does_not_migrate_note("-M"); +} + +#[test] +fn test_branch_force_copy_over_existing_does_not_migrate_note() { + assert_branch_relocation_over_existing_does_not_migrate_note("-C"); +} + +#[test] +fn test_traced_update_ref_stdin_after_untraced_transactions_only_processes_current_update() { + let repo = TestRepo::new(); + setup_initial_commit(&repo); + let main = repo.current_branch(); + let mut readme = repo.filename("README.md"); + readme.assert_committed_lines(lines!["# Test Repo".human()]); + + repo.git(&["checkout", "-b", "feature"]).unwrap(); + let mut feature_file = repo.filename("stdin-gap.txt"); + feature_file.set_contents(lines!["stdin gap ai".ai()]); + let source = repo + .stage_all_and_commit("stdin gap source") + .unwrap() + .commit_sha; + feature_file.assert_committed_lines(lines!["stdin gap ai".ai()]); + + let parent = raw_untraced_git(&repo, &["rev-parse", &format!("{source}^1")]) + .trim() + .to_string(); + let tree = raw_untraced_git(&repo, &["rev-parse", &format!("{source}^{{tree}}")]) + .trim() + .to_string(); + let missed_destination = raw_untraced_git( + &repo, + &[ + "commit-tree", + &tree, + "-p", + &parent, + "-m", + "missed stdin destination", + ], + ) + .trim() + .to_string(); + let traced_destination = raw_untraced_git( + &repo, + &[ + "commit-tree", + &tree, + "-p", + &parent, + "-m", + "traced stdin destination", + ], + ) + .trim() + .to_string(); + let branch_ref = "refs/heads/feature"; + + repo.git(&["checkout", &main]).unwrap(); + repo.sync_daemon(); + raw_untraced_git_stdin( + &repo, + &["update-ref", "--stdin"], + &format!("update {branch_ref} {missed_destination} {source}\n"), + ); + raw_untraced_git_stdin( + &repo, + &["update-ref", "--stdin"], + &format!("update {branch_ref} {source} {missed_destination}\n"), + ); + + raw_traced_git_stdin( + &repo, + &["update-ref", "--stdin"], + &format!("update {branch_ref} {traced_destination} {source}\n"), + ); + + assert!( + repo.read_authorship_note(&missed_destination).is_none(), + "the later traced transaction must not retroactively process missed updates" + ); + assert_note_has_ai_for_file(&repo, &traced_destination, "stdin-gap.txt"); + repo.git(&["checkout", "feature"]).unwrap(); + feature_file.assert_committed_lines(lines!["stdin gap ai".ai()]); +} + #[test] fn test_update_ref_fast_forward_bounds_committed_hunks_to_final_commit() { let repo = TestRepo::new(); @@ -1996,13 +2329,11 @@ fn test_update_ref_stdin_head_with_new_content_preserves_attribution() { .to_string(); repo.sync_daemon(); - let baseline = repo.daemon_total_completion_count(); raw_traced_git_stdin( &repo, &["update-ref", "--stdin"], &format!("update HEAD {} {}\n", commit_sha, parent_sha), ); - repo.wait_for_daemon_total_completion_count(baseline, baseline + 1); assert_note_has_ai_for_file(&repo, &commit_sha, "stdin.txt"); } diff --git a/tests/integration/cold_trace2_repo.rs b/tests/integration/cold_trace2_repo.rs index 0d6edf3170..939413445d 100644 --- a/tests/integration/cold_trace2_repo.rs +++ b/tests/integration/cold_trace2_repo.rs @@ -240,6 +240,98 @@ fn test_cold_repo_first_traced_pull_rebase_preserves_rebased_ai_authorship() { run_cold_repo_first_traced_pull_rebase_preserves_rebased_ai_authorship(); } +fn setup_pull_gap() -> (TestRepo, TestRepo, String, String) { + let upstream = TestRepo::new_bare_with_daemon_scope(DaemonTestScope::NoDaemon); + raw_git(&upstream, &["symbolic-ref", "HEAD", "refs/heads/main"]); + + let repo = TestRepo::new_dedicated_daemon(); + repo.git(&["branch", "-M", "main"]).unwrap(); + repo.git(&["remote", "add", "origin", upstream.path().to_str().unwrap()]) + .unwrap(); + write_file(&repo, "base.txt", "base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "base.txt"]) + .unwrap(); + repo.stage_all_and_commit("base").unwrap(); + let mut base_file = repo.filename("base.txt"); + base_file.assert_committed_lines(crate::lines!["base".human()]); + repo.git(&["push", "-u", "origin", "HEAD:main"]).unwrap(); + + repo.git(&["checkout", "-b", "missed-pull"]).unwrap(); + let missed_source = traced_ai_commit_file( + &repo, + "missed-pull.txt", + "missed pull ai\n", + "missed pull source", + ); + let mut missed_file = repo.filename("missed-pull.txt"); + missed_file.assert_committed_lines(crate::lines!["missed pull ai".ai()]); + + repo.git(&["checkout", "main"]).unwrap(); + repo.git(&["checkout", "-b", "traced-pull"]).unwrap(); + let traced_source = traced_ai_commit_file( + &repo, + "traced-pull.txt", + "traced pull ai\n", + "traced pull source", + ); + let mut traced_file = repo.filename("traced-pull.txt"); + traced_file.assert_committed_lines(crate::lines!["traced pull ai".ai()]); + + let contributor_parent = tempfile::tempdir().expect("contributor temp dir"); + let contributor_path = contributor_parent.path().join("contributor"); + let contributor = raw_clone(&upstream, &contributor_path); + raw_git(&contributor, &["checkout", "main"]); + raw_commit_file( + &contributor, + "upstream.txt", + "upstream human\n", + "upstream advance", + ); + raw_git(&contributor, &["push", "origin", "HEAD:main"]); + + (upstream, repo, missed_source, traced_source) +} + +#[test] +fn test_traced_pull_rebase_skips_prior_untraced_pull_rebase_span() { + let (_upstream, repo, missed_source, traced_source) = setup_pull_gap(); + + repo.git(&["checkout", "missed-pull"]).unwrap(); + repo.sync_daemon_force(); + raw_git(&repo, &["pull", "--rebase", "origin", "main"]); + let missed_destination = raw_head(&repo); + assert_ne!(missed_destination, missed_source); + assert_no_authorship_note(&repo, &missed_destination); + + raw_git(&repo, &["checkout", "traced-pull"]); + run_traced_git(&repo, &["pull", "--rebase", "origin", "main"]); + let traced_destination = raw_head(&repo); + assert_ne!(traced_destination, traced_source); + let mut traced_file = repo.filename("traced-pull.txt"); + traced_file.assert_committed_lines(crate::lines!["traced pull ai".ai()]); +} + +#[test] +fn test_traced_pull_merge_skips_prior_untraced_pull_merge_entry() { + let (_upstream, repo, _missed_source, _traced_source) = setup_pull_gap(); + + repo.git(&["checkout", "missed-pull"]).unwrap(); + repo.sync_daemon_force(); + raw_git( + &repo, + &["pull", "--no-rebase", "--no-edit", "origin", "main"], + ); + assert_no_authorship_note(&repo, &raw_head(&repo)); + + raw_git(&repo, &["checkout", "traced-pull"]); + run_traced_git( + &repo, + &["pull", "--no-rebase", "--no-edit", "origin", "main"], + ); + let mut traced_file = repo.filename("traced-pull.txt"); + traced_file.assert_committed_lines(crate::lines!["traced pull ai".ai()]); +} + #[test] #[ignore = "stress test for nondeterministic cold pull-rebase reflog timing"] fn stress_cold_repo_first_traced_pull_rebase_preserves_rebased_ai_authorship() { @@ -321,6 +413,45 @@ fn test_traced_reset_after_untraced_reset_preserves_recovered_ai_attribution() { recovered_file.assert_committed_lines(crate::lines!["recovered ai".ai()]); } +#[test] +fn test_traced_soft_reset_after_untraced_resets_reconstructs_multiple_commits() { + let repo = TestRepo::new_dedicated_daemon(); + + write_file(&repo, "base.txt", "base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "base.txt"]) + .unwrap(); + let base = repo.stage_all_and_commit("base").unwrap().commit_sha; + let mut base_file = repo.filename("base.txt"); + base_file.assert_committed_lines(crate::lines!["base".human()]); + + let first = traced_ai_commit_file( + &repo, + "first-reset.txt", + "first reset ai\n", + "first reset source", + ); + let mut first_file = repo.filename("first-reset.txt"); + first_file.assert_committed_lines(crate::lines!["first reset ai".ai()]); + let tip = traced_ai_commit_file( + &repo, + "second-reset.txt", + "second reset ai\n", + "second reset source", + ); + let mut second_file = repo.filename("second-reset.txt"); + first_file.assert_committed_lines(crate::lines!["first reset ai".ai()]); + second_file.assert_committed_lines(crate::lines!["second reset ai".ai()]); + + repo.sync_daemon_force(); + raw_git(&repo, &["reset", "--soft", &first]); + raw_git(&repo, &["reset", "--hard", &tip]); + + run_traced_git(&repo, &["reset", "--soft", &base]); + repo.stage_all_and_commit("recommit reset stack").unwrap(); + first_file.assert_committed_lines(crate::lines!["first reset ai".ai()]); + second_file.assert_committed_lines(crate::lines!["second reset ai".ai()]); +} + #[test] fn test_traced_cherry_pick_after_untraced_cherry_pick_preserves_source_ai_attribution() { let repo = TestRepo::new_dedicated_daemon(); @@ -372,6 +503,55 @@ fn test_traced_cherry_pick_after_untraced_cherry_pick_preserves_source_ai_attrib traced_file.assert_committed_lines(crate::lines!["traced pick ai".ai()]); } +#[test] +fn test_traced_multi_cherry_pick_skips_prior_untraced_multi_pick_span() { + let repo = TestRepo::new_dedicated_daemon(); + + write_file(&repo, "base.txt", "base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "base.txt"]) + .unwrap(); + let base = repo.stage_all_and_commit("base").unwrap().commit_sha; + let mut base_file = repo.filename("base.txt"); + base_file.assert_committed_lines(crate::lines!["base".human()]); + + repo.git(&["checkout", "-b", "missed-sources", &base]) + .unwrap(); + let missed_a = traced_ai_commit_file(&repo, "missed-a.txt", "missed a ai\n", "missed source a"); + repo.filename("missed-a.txt") + .assert_committed_lines(crate::lines!["missed a ai".ai()]); + let missed_b = traced_ai_commit_file(&repo, "missed-b.txt", "missed b ai\n", "missed source b"); + repo.filename("missed-a.txt") + .assert_committed_lines(crate::lines!["missed a ai".ai()]); + repo.filename("missed-b.txt") + .assert_committed_lines(crate::lines!["missed b ai".ai()]); + + repo.git(&["checkout", "-b", "traced-sources", &base]) + .unwrap(); + let traced_a = traced_ai_commit_file(&repo, "traced-a.txt", "traced a ai\n", "traced source a"); + repo.filename("traced-a.txt") + .assert_committed_lines(crate::lines!["traced a ai".ai()]); + let traced_b = traced_ai_commit_file(&repo, "traced-b.txt", "traced b ai\n", "traced source b"); + repo.filename("traced-a.txt") + .assert_committed_lines(crate::lines!["traced a ai".ai()]); + repo.filename("traced-b.txt") + .assert_committed_lines(crate::lines!["traced b ai".ai()]); + + repo.git(&["checkout", "-b", "destination", &base]).unwrap(); + repo.sync_daemon_force(); + raw_git(&repo, &["cherry-pick", &missed_a, &missed_b]); + let missed_destinations = raw_git(&repo, &["rev-list", "--reverse", &format!("{base}..HEAD")]); + for destination in missed_destinations.lines() { + assert_no_authorship_note(&repo, destination); + } + + raw_git(&repo, &["reset", "--hard", &base]); + run_traced_git(&repo, &["cherry-pick", &traced_a, &traced_b]); + repo.filename("traced-a.txt") + .assert_committed_lines(crate::lines!["traced a ai".ai()]); + repo.filename("traced-b.txt") + .assert_committed_lines(crate::lines!["traced b ai".ai()]); +} + #[test] fn test_traced_revert_after_untraced_revert_restores_source_ai_attribution() { let repo = TestRepo::new_dedicated_daemon(); @@ -437,6 +617,61 @@ fn test_traced_revert_after_untraced_revert_restores_source_ai_attribution() { traced_file.assert_committed_lines(crate::lines!["traced ai".ai()]); } +#[test] +fn test_traced_multi_revert_after_untraced_multi_revert_restores_each_source() { + let repo = TestRepo::new_dedicated_daemon(); + write_file(&repo, "base.txt", "base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "base.txt"]) + .unwrap(); + let base = repo.stage_all_and_commit("base").unwrap().commit_sha; + let main = repo.current_branch(); + let mut base_file = repo.filename("base.txt"); + base_file.assert_committed_lines(crate::lines!["base".human()]); + + let make_delete_source = |branch: &str, path: &str, message: &str| { + repo.git(&["checkout", "-b", branch, &base]).unwrap(); + traced_ai_commit_file(&repo, path, &format!("{path} ai\n"), message); + repo.filename(path) + .assert_committed_lines(crate::lines![format!("{path} ai").ai()]); + fs::remove_file(repo.path().join(path)).unwrap(); + repo.git_ai(&["checkpoint", "mock_known_human", path]) + .unwrap(); + let deleted = repo + .stage_all_and_commit(&format!("delete {path}")) + .unwrap() + .commit_sha; + assert!(!repo.path().join(path).exists()); + deleted + }; + + let missed_a = make_delete_source("missed-a-source", "missed-a.txt", "add missed a"); + let missed_b = make_delete_source("missed-b-source", "missed-b.txt", "add missed b"); + let traced_a = make_delete_source("traced-a-source", "traced-a.txt", "add traced a"); + let traced_b = make_delete_source("traced-b-source", "traced-b.txt", "add traced b"); + + repo.git(&["checkout", &main]).unwrap(); + let deleted_all = raw_head(&repo); + + repo.sync_daemon_force(); + raw_git(&repo, &["revert", "--no-edit", &missed_a, &missed_b]); + let missed_destinations = raw_git( + &repo, + &["rev-list", "--reverse", &format!("{deleted_all}..HEAD")], + ); + for destination in missed_destinations.lines() { + assert_no_authorship_note(&repo, destination); + } + raw_git(&repo, &["reset", "--hard", &deleted_all]); + + run_traced_git(&repo, &["revert", "--no-edit", &traced_a, &traced_b]); + repo.filename("traced-a.txt") + .assert_committed_lines(crate::lines!["traced-a.txt ai".ai()]); + repo.filename("traced-b.txt") + .assert_committed_lines(crate::lines!["traced-b.txt ai".ai()]); + assert!(!repo.path().join("missed-a.txt").exists()); + assert!(!repo.path().join("missed-b.txt").exists()); +} + #[test] fn test_traced_commit_after_untraced_duplicate_message_head_move_notes_traced_commit() { let repo = TestRepo::new_dedicated_daemon(); @@ -475,6 +710,52 @@ fn test_cold_repo_first_traced_amend_is_processed() { assert_no_ai_authorship_for_commit(&repo, &amended); } +#[test] +fn test_traced_amend_after_untraced_amend_preserves_existing_ai_attribution() { + let repo = TestRepo::new_dedicated_daemon(); + + write_file(&repo, "base.txt", "base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "base.txt"]) + .unwrap(); + repo.stage_all_and_commit("base").unwrap(); + let mut base_file = repo.filename("base.txt"); + base_file.assert_committed_lines(crate::lines!["base".human()]); + let main = repo.current_branch(); + + repo.git(&["checkout", "-b", "missed-amend"]).unwrap(); + write_file(&repo, "missed-amend.txt", "missed amend ai\n"); + repo.git_ai(&["checkpoint", "mock_ai", "missed-amend.txt"]) + .unwrap(); + repo.stage_all_and_commit("missed amend source").unwrap(); + let mut missed_file = repo.filename("missed-amend.txt"); + missed_file.assert_committed_lines(crate::lines!["missed amend ai".ai()]); + + repo.git(&["checkout", &main]).unwrap(); + repo.git(&["checkout", "-b", "traced-amend"]).unwrap(); + write_file(&repo, "traced-amend.txt", "traced amend ai\n"); + repo.git_ai(&["checkpoint", "mock_ai", "traced-amend.txt"]) + .unwrap(); + repo.stage_all_and_commit("traced amend source").unwrap(); + let mut traced_file = repo.filename("traced-amend.txt"); + traced_file.assert_committed_lines(crate::lines!["traced amend ai".ai()]); + + repo.git(&["checkout", "missed-amend"]).unwrap(); + repo.sync_daemon_force(); + raw_git( + &repo, + &["commit", "--amend", "-m", "missed amend destination"], + ); + let missed_amend = raw_head(&repo); + assert_no_authorship_note(&repo, &missed_amend); + raw_git(&repo, &["checkout", "traced-amend"]); + + run_traced_git( + &repo, + &["commit", "--amend", "-m", "traced amend destination"], + ); + traced_file.assert_committed_lines(crate::lines!["traced amend ai".ai()]); +} + #[test] fn test_cold_repo_first_traced_soft_reset_is_processed() { let mut repo = cold_repo(); @@ -516,6 +797,123 @@ fn test_cold_repo_first_traced_rebase_is_processed() { assert_no_ai_authorship_for_commit(&repo, &rebased); } +#[test] +fn test_traced_rebase_after_untraced_rebase_preserves_source_ai_attribution() { + let repo = TestRepo::new_dedicated_daemon(); + + write_file(&repo, "base.txt", "base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "base.txt"]) + .unwrap(); + repo.stage_all_and_commit("base").unwrap(); + let mut base_file = repo.filename("base.txt"); + base_file.assert_committed_lines(crate::lines!["base".human()]); + let main = repo.current_branch(); + + repo.git(&["checkout", "-b", "missed-rebase"]).unwrap(); + write_file(&repo, "missed-rebase.txt", "missed rebase ai\n"); + repo.git_ai(&["checkpoint", "mock_ai", "missed-rebase.txt"]) + .unwrap(); + repo.stage_all_and_commit("missed rebase source").unwrap(); + let mut missed_file = repo.filename("missed-rebase.txt"); + missed_file.assert_committed_lines(crate::lines!["missed rebase ai".ai()]); + + repo.git(&["checkout", &main]).unwrap(); + repo.git(&["checkout", "-b", "traced-rebase"]).unwrap(); + write_file(&repo, "traced-rebase.txt", "traced rebase ai\n"); + repo.git_ai(&["checkpoint", "mock_ai", "traced-rebase.txt"]) + .unwrap(); + repo.stage_all_and_commit("traced rebase source").unwrap(); + let mut traced_file = repo.filename("traced-rebase.txt"); + traced_file.assert_committed_lines(crate::lines!["traced rebase ai".ai()]); + + repo.git(&["checkout", &main]).unwrap(); + write_file(&repo, "main-advance.txt", "main advance\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "main-advance.txt"]) + .unwrap(); + repo.stage_all_and_commit("main advance").unwrap(); + let mut main_advance = repo.filename("main-advance.txt"); + main_advance.assert_committed_lines(crate::lines!["main advance".human()]); + + repo.git(&["checkout", "missed-rebase"]).unwrap(); + repo.sync_daemon_force(); + raw_git(&repo, &["rebase", &main]); + let missed_rebase = raw_head(&repo); + assert_no_authorship_note(&repo, &missed_rebase); + raw_git(&repo, &["checkout", "traced-rebase"]); + + run_traced_git(&repo, &["rebase", &main]); + traced_file.assert_committed_lines(crate::lines!["traced rebase ai".ai()]); +} + +#[test] +fn test_traced_multi_commit_rebase_after_untraced_rebase_preserves_rename_attribution() { + let repo = TestRepo::new_dedicated_daemon(); + + write_file(&repo, "base.txt", "base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "base.txt"]) + .unwrap(); + let base = repo.stage_all_and_commit("base").unwrap().commit_sha; + let main = repo.current_branch(); + let mut base_file = repo.filename("base.txt"); + base_file.assert_committed_lines(crate::lines!["base".human()]); + + repo.git(&["checkout", "-b", "missed-multi", &base]) + .unwrap(); + traced_ai_commit_file( + &repo, + "missed-one.txt", + "missed one ai\n", + "missed multi one", + ); + repo.filename("missed-one.txt") + .assert_committed_lines(crate::lines!["missed one ai".ai()]); + traced_ai_commit_file( + &repo, + "missed-two.txt", + "missed two ai\n", + "missed multi two", + ); + repo.filename("missed-one.txt") + .assert_committed_lines(crate::lines!["missed one ai".ai()]); + repo.filename("missed-two.txt") + .assert_committed_lines(crate::lines!["missed two ai".ai()]); + + repo.git(&["checkout", "-b", "traced-multi", &base]) + .unwrap(); + traced_ai_commit_file( + &repo, + "before-rename.txt", + "renamed ai\n", + "traced multi one", + ); + repo.filename("before-rename.txt") + .assert_committed_lines(crate::lines!["renamed ai".ai()]); + repo.git(&["mv", "before-rename.txt", "after-rename.txt"]) + .unwrap(); + repo.git_ai(&["checkpoint", "mock_known_human", "after-rename.txt"]) + .unwrap(); + repo.stage_all_and_commit("traced multi rename").unwrap(); + let mut renamed = repo.filename("after-rename.txt"); + renamed.assert_committed_lines(crate::lines!["renamed ai".ai()]); + + repo.git(&["checkout", &main]).unwrap(); + write_file(&repo, "main-advance.txt", "main human\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "main-advance.txt"]) + .unwrap(); + repo.stage_all_and_commit("main advance").unwrap(); + repo.filename("main-advance.txt") + .assert_committed_lines(crate::lines!["main human".human()]); + + repo.git(&["checkout", "missed-multi"]).unwrap(); + repo.sync_daemon_force(); + raw_git(&repo, &["rebase", &main]); + assert_no_authorship_note(&repo, &raw_head(&repo)); + raw_git(&repo, &["checkout", "traced-multi"]); + + run_traced_git(&repo, &["rebase", &main]); + renamed.assert_committed_lines(crate::lines!["renamed ai".ai()]); +} + #[test] fn test_cold_repo_first_traced_conflict_rebase_ignores_stale_rebase_reflog_history() { let mut repo = TestRepo::new_dedicated_daemon(); @@ -739,6 +1137,48 @@ fn test_cold_repo_first_traced_squash_merge_is_processed() { assert_no_ai_authorship_for_commit(&repo, &squash_commit); } +#[test] +fn test_traced_squash_merge_after_untraced_squash_merge_preserves_source_ai_attribution() { + let repo = TestRepo::new_dedicated_daemon(); + + write_file(&repo, "base.txt", "base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "base.txt"]) + .unwrap(); + repo.stage_all_and_commit("base").unwrap(); + let mut base_file = repo.filename("base.txt"); + base_file.assert_committed_lines(crate::lines!["base".human()]); + let main = repo.current_branch(); + + repo.git(&["checkout", "-b", "missed-squash"]).unwrap(); + write_file(&repo, "missed-squash.txt", "missed squash ai\n"); + repo.git_ai(&["checkpoint", "mock_ai", "missed-squash.txt"]) + .unwrap(); + repo.stage_all_and_commit("missed squash source").unwrap(); + let mut missed_file = repo.filename("missed-squash.txt"); + missed_file.assert_committed_lines(crate::lines!["missed squash ai".ai()]); + + repo.git(&["checkout", &main]).unwrap(); + repo.git(&["checkout", "-b", "traced-squash"]).unwrap(); + write_file(&repo, "traced-squash.txt", "traced squash ai\n"); + repo.git_ai(&["checkpoint", "mock_ai", "traced-squash.txt"]) + .unwrap(); + repo.stage_all_and_commit("traced squash source").unwrap(); + let mut traced_file = repo.filename("traced-squash.txt"); + traced_file.assert_committed_lines(crate::lines!["traced squash ai".ai()]); + + repo.git(&["checkout", &main]).unwrap(); + repo.sync_daemon_force(); + raw_git(&repo, &["merge", "--squash", "missed-squash"]); + raw_git(&repo, &["commit", "-m", "missed squash destination"]); + let missed_squash = raw_head(&repo); + assert_no_authorship_note(&repo, &missed_squash); + + run_traced_git_without_sync(&repo, &["merge", "--squash", "traced-squash"]); + run_traced_git(&repo, &["commit", "-m", "traced squash destination"]); + missed_file.assert_committed_lines(crate::lines!["missed squash ai".human()]); + traced_file.assert_committed_lines(crate::lines!["traced squash ai".ai()]); +} + #[test] fn test_cold_daemon_first_traced_squash_merge_preserves_source_ai_authorship() { let mut repo = TestRepo::new_dedicated_daemon(); @@ -807,6 +1247,63 @@ fn test_cold_repo_first_traced_merge_is_processed() { assert_no_ai_authorship_for_commit(&repo, &merge_commit); } +#[test] +fn test_traced_merge_after_untraced_merge_preserves_both_source_attributions() { + let repo = TestRepo::new_dedicated_daemon(); + write_file(&repo, "base.txt", "base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "base.txt"]) + .unwrap(); + let base = repo.stage_all_and_commit("base").unwrap().commit_sha; + let main = repo.current_branch(); + let mut base_file = repo.filename("base.txt"); + base_file.assert_committed_lines(crate::lines!["base".human()]); + + repo.git(&["checkout", "-b", "missed-merge", &base]) + .unwrap(); + traced_ai_commit_file( + &repo, + "missed-merge.txt", + "missed merge ai\n", + "missed merge source", + ); + repo.filename("missed-merge.txt") + .assert_committed_lines(crate::lines!["missed merge ai".ai()]); + + repo.git(&["checkout", "-b", "traced-merge", &base]) + .unwrap(); + traced_ai_commit_file( + &repo, + "traced-merge.txt", + "traced merge ai\n", + "traced merge source", + ); + let mut traced_file = repo.filename("traced-merge.txt"); + traced_file.assert_committed_lines(crate::lines!["traced merge ai".ai()]); + + repo.git(&["checkout", &main]).unwrap(); + write_file(&repo, "main.txt", "main human\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "main.txt"]) + .unwrap(); + repo.stage_all_and_commit("main advance").unwrap(); + repo.filename("main.txt") + .assert_committed_lines(crate::lines!["main human".human()]); + + repo.sync_daemon_force(); + raw_git( + &repo, + &["merge", "--no-ff", "missed-merge", "-m", "missed merge"], + ); + assert_no_authorship_note(&repo, &raw_head(&repo)); + run_traced_git( + &repo, + &["merge", "--no-ff", "traced-merge", "-m", "traced merge"], + ); + + repo.filename("missed-merge.txt") + .assert_committed_lines(crate::lines!["missed merge ai".ai()]); + traced_file.assert_committed_lines(crate::lines!["traced merge ai".ai()]); +} + #[test] fn test_cold_repo_first_traced_stash_pop_is_processed() { let mut repo = cold_repo(); @@ -854,24 +1351,69 @@ fn test_traced_stash_after_untraced_stash_preserves_current_ai_attribution() { file.assert_committed_lines(crate::lines!["base".human(), "current ai stash".ai(),]); } +#[test] +fn test_traced_symbolic_stash_apply_after_untraced_drop_uses_current_stack() { + let repo = TestRepo::new_dedicated_daemon(); + write_file(&repo, "first.txt", "first base\n"); + write_file(&repo, "second.txt", "second base\n"); + repo.git_ai(&["checkpoint", "mock_known_human", "first.txt"]) + .unwrap(); + repo.git_ai(&["checkpoint", "mock_known_human", "second.txt"]) + .unwrap(); + repo.stage_all_and_commit("stash base").unwrap(); + let mut first = repo.filename("first.txt"); + let mut second = repo.filename("second.txt"); + first.assert_committed_lines(crate::lines!["first base".human()]); + second.assert_committed_lines(crate::lines!["second base".human()]); + + write_file(&repo, "first.txt", "first base\nfirst stash ai\n"); + repo.git_ai(&["checkpoint", "mock_ai", "first.txt"]) + .unwrap(); + run_traced_git(&repo, &["stash", "push", "-m", "first stash"]); + + write_file(&repo, "second.txt", "second base\nsecond stash ai\n"); + repo.git_ai(&["checkpoint", "mock_ai", "second.txt"]) + .unwrap(); + run_traced_git(&repo, &["stash", "push", "-m", "second stash"]); + + repo.sync_daemon_force(); + raw_git(&repo, &["stash", "drop", "stash@{0}"]); + run_traced_git(&repo, &["stash", "apply", "stash@{0}"]); + repo.stage_all_and_commit("apply surviving stash").unwrap(); + + first.assert_committed_lines(crate::lines!["first base".human(), "first stash ai".ai()]); + second.assert_committed_lines(crate::lines!["second base".human()]); +} + crate::reuse_tests_in_worktree!( test_cold_repo_first_traced_commit_is_processed, test_cold_repo_commit_message_trailing_whitespace_preserves_ai_authorship, + test_traced_pull_rebase_skips_prior_untraced_pull_rebase_span, + test_traced_pull_merge_skips_prior_untraced_pull_merge_entry, test_traced_commit_after_untraced_head_move_creates_authorship_note, test_traced_reset_after_untraced_reset_preserves_recovered_ai_attribution, + test_traced_soft_reset_after_untraced_resets_reconstructs_multiple_commits, test_traced_cherry_pick_after_untraced_cherry_pick_preserves_source_ai_attribution, + test_traced_multi_cherry_pick_skips_prior_untraced_multi_pick_span, test_traced_revert_after_untraced_revert_restores_source_ai_attribution, + test_traced_multi_revert_after_untraced_multi_revert_restores_each_source, test_traced_commit_after_untraced_duplicate_message_head_move_notes_traced_commit, test_cold_repo_first_traced_amend_is_processed, + test_traced_amend_after_untraced_amend_preserves_existing_ai_attribution, test_cold_repo_first_traced_soft_reset_is_processed, test_cold_repo_first_traced_rebase_is_processed, + test_traced_rebase_after_untraced_rebase_preserves_source_ai_attribution, + test_traced_multi_commit_rebase_after_untraced_rebase_preserves_rename_attribution, test_cold_repo_mid_rebase_continue_preserves_ai_conflict_resolution, test_cold_repo_mid_cherry_pick_continue_preserves_ai_conflict_resolution, test_cold_repo_mid_merge_commit_preserves_ai_conflict_resolution, test_cold_repo_first_traced_cherry_pick_is_processed, test_cold_repo_first_traced_squash_merge_is_processed, + test_traced_squash_merge_after_untraced_squash_merge_preserves_source_ai_attribution, test_cold_daemon_first_traced_squash_merge_preserves_source_ai_authorship, test_cold_repo_first_traced_merge_is_processed, + test_traced_merge_after_untraced_merge_preserves_both_source_attributions, test_cold_repo_first_traced_stash_pop_is_processed, test_traced_stash_after_untraced_stash_preserves_current_ai_attribution, + test_traced_symbolic_stash_apply_after_untraced_drop_uses_current_stack, );