From 61b4c2d5590081c778bbff5a8e51225dd70e29c4 Mon Sep 17 00:00:00 2001 From: Sasha Varlamov Date: Fri, 5 Jun 2026 03:29:00 +0000 Subject: [PATCH] Recover exact rewrite line attribution --- src/authorship/conflict_resolution.rs | 41 +++--- src/authorship/rewrite.rs | 50 +++++++- src/daemon.rs | 23 +--- tests/integration/pull_rebase_ff.rs | 18 ++- tests/integration/rebase_realworld.rs | 171 +++++++++++++++++++------- 5 files changed, 212 insertions(+), 91 deletions(-) diff --git a/src/authorship/conflict_resolution.rs b/src/authorship/conflict_resolution.rs index 75d533b863..67fd0ffa15 100644 --- a/src/authorship/conflict_resolution.rs +++ b/src/authorship/conflict_resolution.rs @@ -149,12 +149,16 @@ fn equal_line_mapping_between_commits( repo: &Repository, source_sha: &str, destination_sha: &str, - file_path: &str, + source_file_path: &str, + destination_file_path: &str, ) -> Option> { let source_content = - String::from_utf8(repo.get_file_content(file_path, source_sha).ok()?).ok()?; - let destination_content = - String::from_utf8(repo.get_file_content(file_path, destination_sha).ok()?).ok()?; + String::from_utf8(repo.get_file_content(source_file_path, source_sha).ok()?).ok()?; + let destination_content = String::from_utf8( + repo.get_file_content(destination_file_path, destination_sha) + .ok()?, + ) + .ok()?; let source_lines: Vec = source_content.lines().map(str::to_string).collect(); let destination_lines: Vec = destination_content.lines().map(str::to_string).collect(); let diff_ops = capture_diff_slices(&source_lines, &destination_lines); @@ -178,29 +182,35 @@ fn equal_line_mapping_between_commits( Some(mapping) } -fn recover_exact_source_lines_from_mapping( +pub fn recover_exact_source_lines_from_source_log( repo: &Repository, target: &mut AuthorshipLog, + source_log: &AuthorshipLog, source_sha: &str, destination_sha: &str, + destination_path_by_source_path: &HashMap, ) { - let Some(source_raw) = crate::git::notes_api::read_note(repo, source_sha) else { + if destination_path_by_source_path.is_empty() { return; - }; - let Ok(source_log) = AuthorshipLog::deserialize_from_string(&source_raw) else { - return; - }; + } let mut recovered_log = AuthorshipLog::new(); recovered_log.metadata = source_log.metadata.clone(); let mut target_coverage = line_coverage_by_file(target); for source_attestation in &source_log.attestations { + let Some(destination_file_path) = + destination_path_by_source_path.get(&source_attestation.file_path) + else { + continue; + }; + let Some(line_mapping) = equal_line_mapping_between_commits( repo, source_sha, destination_sha, &source_attestation.file_path, + destination_file_path, ) else { continue; }; @@ -221,7 +231,7 @@ fn recover_exact_source_lines_from_mapping( mapped_lines.dedup(); let mapped_ranges = LineRange::compress_lines(&mapped_lines); let current_coverage = target_coverage - .get(&source_attestation.file_path) + .get(destination_file_path) .map(Vec::as_slice) .unwrap_or(&[]); let missing_ranges = subtract_line_ranges(&mapped_ranges, current_coverage); @@ -230,10 +240,10 @@ fn recover_exact_source_lines_from_mapping( } target_coverage - .entry(source_attestation.file_path.clone()) + .entry(destination_file_path.clone()) .or_default() .extend(missing_ranges.clone()); - let file = recovered_log.get_or_create_file(&source_attestation.file_path); + let file = recovered_log.get_or_create_file(destination_file_path); file.add_entry(AttestationEntry::new( source_entry.hash.clone(), missing_ranges, @@ -250,16 +260,11 @@ fn recover_exact_source_lines_from_mapping( } pub fn merge_conflict_resolution_authorship( - repo: &Repository, existing_shifted_log: Option, resolution_log: AuthorshipLog, - source_sha: Option<&str>, commit_sha: &str, ) -> AuthorshipLog { let mut merged = existing_shifted_log.unwrap_or_default(); - if let Some(source_sha) = source_sha { - recover_exact_source_lines_from_mapping(repo, &mut merged, source_sha, commit_sha); - } let resolution_log = filter_resolution_log_to_uncovered_lines(resolution_log, &merged); merge_file_attestations(&mut merged, &resolution_log); diff --git a/src/authorship/rewrite.rs b/src/authorship/rewrite.rs index a78760c91e..6cbcd442b3 100644 --- a/src/authorship/rewrite.rs +++ b/src/authorship/rewrite.rs @@ -253,6 +253,7 @@ fn shift_authorship_notes_with_existing_mode( // Determine which mappings need processing struct PendingShift { + source_sha: String, new_sha: String, log: AuthorshipLog, diff_pair_idx: usize, @@ -294,6 +295,7 @@ fn shift_authorship_notes_with_existing_mode( let diff_pair_idx = diff_pairs.len(); diff_pairs.push((source_sha.clone(), new_sha.clone())); pending.push(PendingShift { + source_sha: source_sha.clone(), new_sha: new_sha.clone(), log, diff_pair_idx, @@ -313,10 +315,34 @@ fn shift_authorship_notes_with_existing_mode( // Apply shifts and merge logs that share a target commit let mut merged_by_target = existing_by_target; + let mut recoveries: Vec<(String, String, AuthorshipLog, HashMap)> = Vec::new(); for shift in pending { let diff_result = &diff_results[shift.diff_pair_idx]; let mut log = shift.log; + let destination_path_by_source_path: HashMap = diff_result + .hunks_by_file + .keys() + .map(|path| { + let source_path = diff_result + .renames + .iter() + .find_map(|(old_path, new_path)| { + if new_path == path { + Some(old_path.clone()) + } else { + None + } + }) + .unwrap_or_else(|| path.clone()); + (source_path, path.clone()) + }) + .collect(); + let source_log_for_recovery = if destination_path_by_source_path.is_empty() { + None + } else { + Some(log.clone()) + }; for (old_path, new_path) in &diff_result.renames { for attestation in &mut log.attestations { @@ -342,9 +368,31 @@ fn shift_authorship_notes_with_existing_mode( match merged_by_target.get_mut(&shift.new_sha) { Some(existing) => merge_authorship_logs(existing, &log), None => { - merged_by_target.insert(shift.new_sha, log); + merged_by_target.insert(shift.new_sha.clone(), log); } } + + if let Some(source_log) = source_log_for_recovery { + recoveries.push(( + shift.source_sha, + shift.new_sha, + source_log, + destination_path_by_source_path, + )); + } + } + + for (source_sha, new_sha, source_log, destination_path_by_source_path) in recoveries { + if let Some(target_log) = merged_by_target.get_mut(&new_sha) { + crate::authorship::conflict_resolution::recover_exact_source_lines_from_source_log( + repo, + target_log, + &source_log, + &source_sha, + &new_sha, + &destination_path_by_source_path, + ); + } } let mut all_writes = verbatim_writes; diff --git a/src/daemon.rs b/src/daemon.rs index 5c43929c7d..e35ad60904 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -747,12 +747,7 @@ fn resolve_stash_sha(cmd: &crate::daemon::domain::NormalizedCommand) -> Option<& /// After a rebase completes, check if any newly-rebased commits were created /// from conflict resolution with AI checkpoints. If so, merge those resolution /// checkpoints into the already-shifted source authorship note for the new commit. -fn process_conflict_resolution_working_logs( - repo: &Repository, - new_tip: &str, - onto: Option<&str>, - source_mappings: &[(String, String)], -) { +fn process_conflict_resolution_working_logs(repo: &Repository, new_tip: &str, onto: Option<&str>) { let onto_sha = match onto { Some(s) if !s.is_empty() => s, _ => return, @@ -770,10 +765,6 @@ fn process_conflict_resolution_working_logs( Err(_) => return, }; let log_output = String::from_utf8_lossy(&output.stdout); - let source_by_destination: HashMap = source_mappings - .iter() - .map(|(source, destination)| (destination.clone(), source.clone())) - .collect(); for line in log_output.lines() { let parts: Vec<&str> = line.split_whitespace().collect(); @@ -790,7 +781,6 @@ fn process_conflict_resolution_working_logs( let existing_shifted_log = crate::git::notes_api::read_note(repo, &commit_sha) .and_then(|raw| AuthorshipLog::deserialize_from_string(&raw).ok()); let author = repo.git_author_identity().formatted_or_unknown(); - let source_sha = source_by_destination.get(&commit_sha).cloned(); let commit_for_transform = commit_sha.clone(); let commit_for_log = commit_sha.clone(); if let Err(err) = @@ -803,10 +793,8 @@ fn process_conflict_resolution_working_logs( move |resolution_log| { Ok( crate::authorship::conflict_resolution::merge_conflict_resolution_authorship( - repo, existing_shifted_log, resolution_log, - source_sha.as_deref(), &commit_for_transform, ), ) @@ -3692,19 +3680,14 @@ impl ActorDaemonCoordinator { && original_head != new_tip && !is_ancestor_commit(&repo, &original_head, &new_tip) { - let mappings = crate::authorship::rewrite::handle_non_fast_forward_rewrite( + let _ = crate::authorship::rewrite::handle_non_fast_forward_rewrite( &repo, &original_head, &new_tip, rebase_onto.as_deref(), )?; let _ = repo.storage.rename_working_log(&original_head, &new_tip); - process_conflict_resolution_working_logs( - &repo, - &new_tip, - rebase_onto.as_deref(), - &mappings, - ); + process_conflict_resolution_working_logs(&repo, &new_tip, rebase_onto.as_deref()); } return Ok(()); } diff --git a/tests/integration/pull_rebase_ff.rs b/tests/integration/pull_rebase_ff.rs index 5d25e16620..6277e4d882 100644 --- a/tests/integration/pull_rebase_ff.rs +++ b/tests/integration/pull_rebase_ff.rs @@ -983,21 +983,27 @@ fn test_regular_rebase_with_conflict_preserves_ai_notes() { "Rebased commit should have authorship notes (notes should follow SHA rewrite)" ); - // After conflict resolution, AI-attributed lines fall inside diff hunks - // (git diff-tree shows the region as modified), so attribution is correctly dropped. - // The note exists (metadata preserved) but shared.txt has no attributed lines. + // Even though Git's raw diff sees the final newline change as a replacement + // of the AI line, the logical line content survived and should retain attribution. let note_content = post_rebase_note.unwrap(); let post_rebase_log = AuthorshipLog::deserialize_from_string(¬e_content).expect("parse post-rebase note"); assert_eq!( post_rebase_log.metadata.sessions, pre_rebase_log.metadata.sessions, - "session metadata should be preserved even when changed-hunk attestations are dropped" + "session metadata should be preserved for recovered logical-line attribution" ); assert!( - !note_content.contains("shared.txt"), - "Authorship note should NOT reference shared.txt (lines inside diff hunk), got: {}", + note_content.contains("shared.txt"), + "Authorship note should reference shared.txt for logically preserved lines, got: {}", note_content ); + + let mut final_file = repo.filename("shared.txt"); + final_file.assert_committed_lines(crate::lines![ + "line 1".human(), + "main change line 2".human(), + "AI feature line 2".ai(), + ]); } #[test] diff --git a/tests/integration/rebase_realworld.rs b/tests/integration/rebase_realworld.rs index cbf584c58e..a4a1a1d7b1 100644 --- a/tests/integration/rebase_realworld.rs +++ b/tests/integration/rebase_realworld.rs @@ -7758,15 +7758,16 @@ fn test_slow_path_file_grows_then_unique_files_each_commit() { // ============================================================================ // Category 3: Conflict Resolved by Human // Feature branch has AI-generated changes that conflict with main branch. -// Human resolves via fs::write (no checkpoint) — conflicted file loses AI -// attribution for that specific rebased commit. All other AI files in the -// chain retain their attribution. +// Human resolves via fs::write (no checkpoint): exact AI source lines that +// survive resolution retain attribution, while replaced source lines are not +// attributed. All other AI files in the chain retain their attribution. // ============================================================================ /// Test 1: Python auth.py — feature adds AI login/logout functions, main edits /// the same file's header comment → conflict on C1. Human resolves by keeping -/// both parts. C1' must have NO auth.py in its note; C2'–C5' accumulate other -/// AI files (models.py, views.py, serializers.py, signals.py) normally. +/// both parts. C1' keeps the exact AI lines that survive resolution; C2'–C5' +/// accumulate other AI files (models.py, views.py, serializers.py, signals.py) +/// normally. #[test] fn test_human_conflict_python_auth_c1_conflicts_rest_accumulate() { let repo = TestRepo::new(); @@ -7937,8 +7938,8 @@ fn test_human_conflict_python_auth_c1_conflicts_rest_accumulate() { /// Test 2: Rust lib.rs — feature adds AI parser functions, main edits the same /// mod declaration at the top → conflict on C2 (middle of chain). -/// C1' is attributed normally; C2' loses lib.rs; C3'–C5' accumulate helpers.rs, -/// types.rs, error.rs as expected. +/// C1' is attributed normally; C2' keeps the exact AI line that survives the +/// human resolution; C3'–C5' accumulate helpers.rs, types.rs, error.rs as expected. #[test] fn test_human_conflict_rust_lib_c2_conflicts_surroundings_ok() { let repo = TestRepo::new(); @@ -8065,9 +8066,20 @@ fn test_human_conflict_rust_lib_c2_conflicts_surroundings_ok() { assert_note_base_commit_matches(&repo, &chain[0], "c1_base"); assert_note_files_exact(&repo, &chain[0], "c1_files", &["src/tokenizer.rs"]); - // C2': lib.rs human-resolved conflict — all AI lines inside diff hunk, attribution dropped + // C2': lib.rs human-resolved conflict — the exact AI line survived resolution assert_note_base_commit_matches(&repo, &chain[1], "c2_base"); - assert_note_files_exact(&repo, &chain[1], "c2_files", &[]); + assert_note_files_exact(&repo, &chain[1], "c2_files", &["src/lib.rs"]); + assert_blame_at_commit( + &repo, + &chain[1], + "src/lib.rs", + "c2_blame", + &[ + ("pub mod parser;", false), + ("pub mod types;", false), + ("pub mod tokenizer;", true), + ], + ); // C3': helpers.rs only assert_note_base_commit_matches(&repo, &chain[2], "c3_base"); @@ -8084,7 +8096,8 @@ fn test_human_conflict_rust_lib_c2_conflicts_surroundings_ok() { /// Test 3: TypeScript api.ts — feature adds AI REST handlers, main adds an /// import at the top that conflicts with feature's C3. C1'–C2' accumulate -/// dto.ts and service.ts; C3' loses api.ts attribution; C4'–C5' add more files. +/// dto.ts and service.ts; C3' keeps the exact AI import that survives human +/// resolution; C4'–C5' add more files. #[test] fn test_human_conflict_typescript_api_c3_conflicts_accumulation_intact() { let repo = TestRepo::new(); @@ -8223,9 +8236,20 @@ fn test_human_conflict_typescript_api_c3_conflicts_accumulation_intact() { assert_note_base_commit_matches(&repo, &chain[1], "c2_base"); assert_note_files_exact(&repo, &chain[1], "c2_files", &["src/service.ts"]); - // C3': api.ts human-resolved conflict — AI lines inside diff hunk, attribution dropped + // C3': api.ts human-resolved conflict — the exact AI import survived resolution assert_note_base_commit_matches(&repo, &chain[2], "c3_base"); - assert_note_files_exact(&repo, &chain[2], "c3_files", &[]); + assert_note_files_exact(&repo, &chain[2], "c3_files", &["src/api.ts"]); + assert_blame_at_commit( + &repo, + &chain[2], + "src/api.ts", + "c3_blame", + &[ + ("// api module", false), + ("export { version };", false), + ("import { createUser, getUser } from './service';", true), + ], + ); // C4': middleware.ts only assert_note_base_commit_matches(&repo, &chain[3], "c4_base"); @@ -8238,7 +8262,7 @@ fn test_human_conflict_typescript_api_c3_conflicts_accumulation_intact() { /// Test 4: Python models.py — main adds a class attribute that conflicts with /// feature's last commit (C5). All prior AI commits C1'–C4' are attributed -/// normally; C5' loses models.py. +/// normally; C5' keeps the exact AI validator line that survives human resolution. #[test] fn test_human_conflict_python_models_c5_last_commit_conflicts() { let repo = TestRepo::new(); @@ -8380,16 +8404,32 @@ fn test_human_conflict_python_models_c5_last_commit_conflicts() { assert_note_base_commit_matches(&repo, &chain[3], "c4_base"); assert_note_files_exact(&repo, &chain[3], "c4_files", &["events.py"]); - // C5': models.py human-resolved conflict — AI lines inside diff hunk, attribution dropped + // C5': models.py human-resolved conflict — the exact AI validator survived resolution assert_note_base_commit_matches(&repo, &chain[4], "c5_base"); - assert_note_files_exact(&repo, &chain[4], "c5_files", &[]); + assert_note_files_exact(&repo, &chain[4], "c5_files", &["models.py"]); + assert_blame_at_commit( + &repo, + &chain[4], + "models.py", + "c5_blame", + &[ + ("class User:", false), + (" table_name = 'users'", false), + ( + " def validate(self): return bool(getattr(self, 'name', None))", + true, + ), + (" pass", false), + ], + ); } /// Test 5: Rust src/config.rs — main and feature both extend a constants block, -/// triggering a conflict on C2. C1' has config.rs attributed; C2' loses it -/// due to human resolution; C3'–C5' accumulate cache.rs, retry.rs, timeout.rs. +/// triggering a conflict on C2. C1' has config.rs attributed; C2' keeps the +/// exact AI constant that survives human resolution; C3'–C5' accumulate cache.rs, +/// retry.rs, timeout.rs. #[test] -fn test_human_conflict_rust_config_c2_loses_attribution_rest_accumulate() { +fn test_human_conflict_rust_config_c2_preserves_surviving_ai_line() { let repo = TestRepo::new(); write_raw_commit( @@ -8520,9 +8560,20 @@ fn test_human_conflict_rust_config_c2_loses_attribution_rest_accumulate() { assert_note_base_commit_matches(&repo, &chain[0], "c1_base"); assert_note_files_exact(&repo, &chain[0], "c1_files", &["src/defaults.rs"]); - // C2': config.rs human-resolved conflict — AI lines inside diff hunk, attribution dropped + // C2': config.rs human-resolved conflict — the exact AI constant survived resolution assert_note_base_commit_matches(&repo, &chain[1], "c2_base"); - assert_note_files_exact(&repo, &chain[1], "c2_files", &[]); + assert_note_files_exact(&repo, &chain[1], "c2_files", &["src/config.rs"]); + assert_blame_at_commit( + &repo, + &chain[1], + "src/config.rs", + "c2_blame", + &[ + ("pub const MAX_CONN: u32 = 10;", false), + ("pub const TIMEOUT_MS: u64 = 5000;", false), + ("pub const IDLE_TIMEOUT_MS: u64 = 30_000;", true), + ], + ); // C3': cache.rs only assert_note_base_commit_matches(&repo, &chain[2], "c3_base"); @@ -8540,8 +8591,8 @@ fn test_human_conflict_rust_config_c2_loses_attribution_rest_accumulate() { /// Test 6: TypeScript store.ts — the entire feature file is written by the AI /// via fs::write + git_og add + checkpoint (simulating an AI-created file). /// Main edits the same file causing conflict on C1; human resolves. -/// No file is attributed in C1' (human resolved the only AI file in that commit). -/// C2'–C5' accumulate actions.ts, selectors.ts, reducers.ts, hooks.ts. +/// C1' keeps the exact AI lines that survive resolution. C2'–C5' accumulate +/// actions.ts, selectors.ts, reducers.ts, hooks.ts. #[test] fn test_human_conflict_typescript_store_ai_created_file_conflict() { let repo = TestRepo::new(); @@ -8692,7 +8743,8 @@ fn test_human_conflict_typescript_store_ai_created_file_conflict() { /// Test 7: Rust src/server.rs — feature adds AI HTTP handler functions; main /// adds a conflicting use declaration in C4. C1'–C3' and C5' keep their AI -/// attribution; C4' (server.rs) is dropped due to human resolution. +/// attribution; C4' has no server.rs attestation because the human resolution +/// changed the original AI line. #[test] fn test_human_conflict_rust_server_c4_human_resolved_c5_accumulates() { let repo = TestRepo::new(); @@ -8858,7 +8910,8 @@ fn test_human_conflict_rust_server_c4_human_resolved_c5_accumulates() { /// Test 8: Python pipeline.py — feature starts from a mixed human+AI baseline, /// then modifies the same file as main in C3. C1' + C2' accumulate other AI -/// files; C3' loses pipeline.py; C4'–C5' add transform.py and sink.py. +/// files; C3' keeps the exact AI filter line that survives human resolution; +/// C4'–C5' add transform.py and sink.py. #[test] fn test_human_conflict_python_pipeline_mixed_baseline_c3_conflict() { let repo = TestRepo::new(); @@ -9004,9 +9057,25 @@ fn test_human_conflict_python_pipeline_mixed_baseline_c3_conflict() { assert_note_base_commit_matches(&repo, &chain[1], "c2_base"); assert_note_files_exact(&repo, &chain[1], "c2_files", &["filter.py"]); - // C3': pipeline.py human-resolved conflict — AI lines inside diff hunk, attribution dropped + // C3': pipeline.py human-resolved conflict — the exact AI filter survived resolution assert_note_base_commit_matches(&repo, &chain[2], "c3_base"); - assert_note_files_exact(&repo, &chain[2], "c3_files", &[]); + assert_note_files_exact(&repo, &chain[2], "c3_files", &["pipeline.py"]); + assert_blame_at_commit( + &repo, + &chain[2], + "pipeline.py", + "c3_blame", + &[ + ("class Pipeline:", false), + (" def __init__(self): self.stages = []", false), + (" def run(self, data): return data", false), + (" def validate(self, data): return bool(data)", false), + ( + " def add_filter(self, f): self.stages.append(f); return self", + true, + ), + ], + ); // C4': transform.py only assert_note_base_commit_matches(&repo, &chain[3], "c4_base"); @@ -9019,8 +9088,8 @@ fn test_human_conflict_python_pipeline_mixed_baseline_c3_conflict() { /// Test 9: TypeScript component.tsx — AI writes entire component file (via /// fs::write + checkpoint), main adds a style import that conflicts on C2. -/// C1' accumulates hooks.ts; C2' loses component.tsx; C3'–C5' add context.ts, -/// provider.tsx, types.ts normally. +/// C1' accumulates hooks.ts; C2' keeps the exact AI lines that survive human +/// resolution; C3'–C5' add context.ts, provider.tsx, types.ts normally. #[test] fn test_human_conflict_typescript_component_ai_created_c2_conflict() { let repo = TestRepo::new(); @@ -9172,8 +9241,9 @@ fn test_human_conflict_typescript_component_ai_created_c2_conflict() { /// Test 10: Rust 7-commit chain — feature adds AI functions across multiple /// files; main edits shared.rs causing conflict on C4 (middle of a 7-commit -/// chain). Verifies 7-element chain: C1'–C3' accumulate normally, C4' loses -/// shared.rs, C5'–C7' continue accumulating math.rs, string_utils.rs, io.rs. +/// chain). Verifies 7-element chain: C1'–C3' accumulate normally, C4' keeps +/// the exact AI clamp line that survives human resolution, C5'–C7' continue +/// accumulating math.rs, string_utils.rs, io.rs. #[test] fn test_human_conflict_rust_7_commit_chain_c4_conflict_surroundings_intact() { let repo = TestRepo::new(); @@ -9347,9 +9417,20 @@ fn test_human_conflict_rust_7_commit_chain_c4_conflict_surroundings_intact() { assert_note_base_commit_matches(&repo, &chain[2], "c3_base"); assert_note_files_exact(&repo, &chain[2], "c3_files", &["src/result_utils.rs"]); - // C4': shared.rs human-resolved conflict — AI lines inside diff hunk, attribution dropped + // C4': shared.rs human-resolved conflict — the exact AI clamp survived resolution assert_note_base_commit_matches(&repo, &chain[3], "c4_base"); - assert_note_files_exact(&repo, &chain[3], "c4_files", &[]); + assert_note_files_exact(&repo, &chain[3], "c4_files", &["src/shared.rs"]); + assert_blame_at_commit( + &repo, + &chain[3], + "src/shared.rs", + "c4_blame", + &[ + ("pub const VERSION: &str = \"1.0\";", false), + ("pub fn identity(x: T) -> T { x }", false), + ("pub fn clamp(x: T, lo: T, hi: T) -> T", true), + ], + ); // C5': math.rs only assert_note_base_commit_matches(&repo, &chain[4], "c5_base"); @@ -9365,9 +9446,8 @@ fn test_human_conflict_rust_7_commit_chain_c4_conflict_surroundings_intact() { } /// Test: Human resolves conflict by replacing ALL AI lines with completely -/// different content. After rebase, the conflict commit should have NO note -/// (commit_has_attestations=false → else branch returns None). -/// Subsequent AI commits should be unaffected. +/// different content. After rebase, the conflict commit keeps note metadata but +/// has no file attestations. Subsequent AI commits should be unaffected. #[test] fn test_human_conflict_resolves_all_ai_lines_replaced() { let repo = TestRepo::new(); @@ -9431,10 +9511,9 @@ fn test_human_conflict_resolves_all_ai_lines_replaced() { // match original AI content, so diff_based_line_attribution_transfer produces // only Replace ops → commit_has_attestations = false. // - // However, the original commit DID have an AI authorship note. Rather than - // silently dropping provenance, the slow-path fallback remaps the original note - // to the rebased commit. The attestation line numbers may be stale but the AI - // authorship record is preserved. + // However, the original commit DID have an AI authorship note. Rather than + // silently dropping provenance, the rewrite keeps the metadata note on the + // rebased commit while leaving compute.py unattributed. fs::write( repo.path().join("compute.py"), "# human resolved\nresult = 42\n", @@ -9564,7 +9643,7 @@ fn test_human_conflict_ai_file_is_conflict_file_note_preserved() { /// commit's file conflicts with upstream. After human conflict resolution and /// `rebase --continue`, ALL three rebased commits must retain their authorship /// notes. Before the fix, the conflict commit's note was lost (content-diff -/// produced nothing for the manually resolved file and the fallback remap was +/// produced nothing for the manually resolved file and metadata preservation was /// too narrow). #[test] fn test_human_conflict_multicommit_chain_middle_conflict_all_notes_preserved() { @@ -9833,7 +9912,7 @@ fn test_conflict_ai_resolves_timeout_constant() { // C3': config.py only (AI-resolved, TIMEOUT = 90 attributed as AI) assert_note_base_commit_matches(&repo, &chain[2], "c3_base"); assert_note_files_exact(&repo, &chain[2], "c3_files", &["config.py"]); - // 1 AI line: TIMEOUT = 90 (working-log fallback path must set accepted_lines correctly) + // 1 AI line: TIMEOUT = 90 (working-log resolution path must set accepted_lines correctly) assert_accepted_lines_exact(&repo, &chain[2], "c3_accepted_lines", 1); // blame at chain[2] for config.py: the AI-resolved TIMEOUT line should be AI @@ -11766,7 +11845,7 @@ fn test_conflict_mixed_ai_and_human_resolve_different_commits() { // Category 5: Path-specific correctness tests // ============================================================================ -/// Verify that the working-log fallback path is the **sole** source of attribution +/// Verify that the conflict-resolution working-log path is the sole source of attribution /// when an AI conflict resolution writes *different* content than the original commit. /// /// Scenario: @@ -11775,7 +11854,7 @@ fn test_conflict_mixed_ai_and_human_resolve_different_commits() { /// - AI resolves by setting `TIMEOUT = 45` (a compromise — different from both sides). /// - `set_contents` records a working-log checkpoint for the resolved value. /// - Content-diff compares original (`= 30`) with resolved (`= 45`) → Replace → no match. -/// - The working-log fallback must fire and attribute `= 45` as AI. +/// - The working-log resolution path must attribute `= 45` as AI. /// /// Regression: if `build_note_from_conflict_wl` were removed, C1' would have no note. #[test] @@ -11851,7 +11930,7 @@ fn test_conflict_working_log_is_sole_attribution_source() { let chain = get_commit_chain(&repo, 2); - // C1': config.py only — note MUST exist (working-log fallback fired) + // C1': config.py only — note MUST exist (working-log resolution path fired) assert_note_base_commit_matches(&repo, &chain[0], "c1_base"); assert_note_files_exact(&repo, &chain[0], "c1_files", &["config.py"]); // 1 AI line: TIMEOUT = 45 — accepted_lines must be 1 (not 0). @@ -12142,7 +12221,7 @@ fn test_conflict_ai_resolves_timeout_constant_standard_human() { // C3': config.py only (AI-resolved, TIMEOUT = 90 attributed as AI) assert_note_base_commit_matches(&repo, &chain[2], "c3_base"); assert_note_files_exact(&repo, &chain[2], "c3_files", &["config.py"]); - // 1 AI line: TIMEOUT = 90 (working-log fallback path must set accepted_lines correctly) + // 1 AI line: TIMEOUT = 90 (working-log resolution path must set accepted_lines correctly) assert_accepted_lines_exact(&repo, &chain[2], "c3_accepted_lines", 1); // blame at chain[2] for config.py: the AI-resolved TIMEOUT line should be AI @@ -13574,7 +13653,7 @@ crate::reuse_tests_in_worktree!( test_human_conflict_rust_lib_c2_conflicts_surroundings_ok, test_human_conflict_typescript_api_c3_conflicts_accumulation_intact, test_human_conflict_python_models_c5_last_commit_conflicts, - test_human_conflict_rust_config_c2_loses_attribution_rest_accumulate, + test_human_conflict_rust_config_c2_preserves_surviving_ai_line, test_human_conflict_typescript_store_ai_created_file_conflict, test_human_conflict_rust_server_c4_human_resolved_c5_accumulates, test_human_conflict_python_pipeline_mixed_baseline_c3_conflict,