Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions src/authorship/conflict_resolution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<u32, u32>> {
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<String> = source_content.lines().map(str::to_string).collect();
let destination_lines: Vec<String> = destination_content.lines().map(str::to_string).collect();
let diff_ops = capture_diff_slices(&source_lines, &destination_lines);
Expand All @@ -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<String, String>,
) {
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;
};
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -250,16 +260,11 @@ fn recover_exact_source_lines_from_mapping(
}

pub fn merge_conflict_resolution_authorship(
repo: &Repository,
existing_shifted_log: Option<AuthorshipLog>,
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);
Expand Down
50 changes: 49 additions & 1 deletion src/authorship/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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<String, String>)> = 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<String, String> = 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 {
Expand All @@ -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;
Expand Down
23 changes: 3 additions & 20 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<String, String> = 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();
Expand All @@ -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) =
Expand All @@ -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,
),
)
Expand Down Expand Up @@ -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(());
}
Expand Down
18 changes: 12 additions & 6 deletions tests/integration/pull_rebase_ff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&note_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]
Expand Down
Loading
Loading