Skip to content
Open
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
269 changes: 269 additions & 0 deletions src/authorship/conflict_resolution.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
use std::collections::{HashMap, HashSet};

use crate::authorship::authorship_log::LineRange;
use crate::authorship::authorship_log_serialization::{AttestationEntry, AuthorshipLog};
use crate::authorship::imara_diff_utils::{DiffOp, capture_diff_slices};
use crate::git::repository::Repository;

fn normalize_line_ranges(ranges: &[LineRange]) -> Vec<LineRange> {
let mut lines: Vec<u32> = ranges.iter().flat_map(LineRange::expand).collect();
lines.sort_unstable();
lines.dedup();
LineRange::compress_lines(&lines)
}

fn subtract_line_ranges(ranges: &[LineRange], covered: &[LineRange]) -> Vec<LineRange> {
let mut remaining = ranges.to_vec();
for covered_range in covered {
remaining = remaining
.iter()
.flat_map(|range| range.remove(covered_range))
.collect();
if remaining.is_empty() {
break;
}
}
normalize_line_ranges(&remaining)
}

fn line_coverage_by_file(log: &AuthorshipLog) -> HashMap<String, Vec<LineRange>> {
let mut coverage: HashMap<String, Vec<LineRange>> = HashMap::new();
for attestation in &log.attestations {
let file_coverage = coverage.entry(attestation.file_path.clone()).or_default();
for entry in &attestation.entries {
file_coverage.extend(entry.line_ranges.clone());
}
}
for ranges in coverage.values_mut() {
*ranges = normalize_line_ranges(ranges);
}
coverage
}

fn attestation_metadata_key(hash: &str) -> &str {
hash.split("::").next().unwrap_or(hash)
}

fn retain_referenced_metadata(log: &mut AuthorshipLog) {
let mut prompt_keys = HashSet::new();
let mut human_keys = HashSet::new();
let mut session_keys = HashSet::new();

for attestation in &log.attestations {
for entry in &attestation.entries {
let key = attestation_metadata_key(&entry.hash).to_string();
if key.starts_with("h_") {
human_keys.insert(key);
} else if key.starts_with("s_") {
session_keys.insert(key);
} else {
prompt_keys.insert(key);
}
}
}

log.metadata
.prompts
.retain(|key, _| prompt_keys.contains(key));
log.metadata
.humans
.retain(|key, _| human_keys.contains(key));
log.metadata
.sessions
.retain(|key, _| session_keys.contains(key));
}

fn filter_resolution_log_to_uncovered_lines(
mut resolution_log: AuthorshipLog,
shifted_log: &AuthorshipLog,
) -> AuthorshipLog {
let shifted_coverage = line_coverage_by_file(shifted_log);

for attestation in &mut resolution_log.attestations {
let covered = shifted_coverage
.get(&attestation.file_path)
.map(Vec::as_slice)
.unwrap_or(&[]);
for entry in &mut attestation.entries {
entry.line_ranges = subtract_line_ranges(&entry.line_ranges, covered);
}
attestation
.entries
.retain(|entry| !entry.line_ranges.is_empty());
}

resolution_log
.attestations
.retain(|attestation| !attestation.entries.is_empty());
retain_referenced_metadata(&mut resolution_log);
resolution_log
}

fn merge_file_attestations(target: &mut AuthorshipLog, source: &AuthorshipLog) {
for source_attestation in &source.attestations {
let target_attestation = target.get_or_create_file(&source_attestation.file_path);
for source_entry in &source_attestation.entries {
if let Some(target_entry) = target_attestation
.entries
.iter_mut()
.find(|entry| entry.hash == source_entry.hash)
{
target_entry
.line_ranges
.extend(source_entry.line_ranges.clone());
target_entry.line_ranges = normalize_line_ranges(&target_entry.line_ranges);
} else {
let mut entry = source_entry.clone();
entry.line_ranges = normalize_line_ranges(&entry.line_ranges);
target_attestation.entries.push(entry);
}
}
}
}

fn merge_authorship_metadata(target: &mut AuthorshipLog, source: &AuthorshipLog) {
for (key, record) in &source.metadata.prompts {
target
.metadata
.prompts
.entry(key.clone())
.or_insert_with(|| record.clone());
}
for (key, record) in &source.metadata.humans {
target
.metadata
.humans
.entry(key.clone())
.or_insert_with(|| record.clone());
}
for (key, record) in &source.metadata.sessions {
target
.metadata
.sessions
.entry(key.clone())
.or_insert_with(|| record.clone());
}
}

fn equal_line_mapping_between_commits(
repo: &Repository,
source_sha: &str,
destination_sha: &str,
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()?;
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);

let mut mapping = HashMap::new();
for op in diff_ops {
if let DiffOp::Equal {
old_index,
new_index,
len,
} = op
{
for offset in 0..len {
mapping.insert(
(old_index + offset + 1) as u32,
(new_index + offset + 1) as u32,
);
}
}
}
Some(mapping)
}

fn recover_exact_source_lines_from_mapping(
repo: &Repository,
target: &mut AuthorshipLog,
source_sha: &str,
destination_sha: &str,
) {
let Some(source_raw) = crate::git::notes_api::read_note(repo, source_sha) else {
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(line_mapping) = equal_line_mapping_between_commits(
repo,
source_sha,
destination_sha,
&source_attestation.file_path,
) else {
continue;
};

for source_entry in &source_attestation.entries {
let mut mapped_lines = Vec::new();
for source_line in source_entry.line_ranges.iter().flat_map(LineRange::expand) {
if let Some(destination_line) = line_mapping.get(&source_line) {
mapped_lines.push(*destination_line);
}
}

if mapped_lines.is_empty() {
continue;
}

mapped_lines.sort_unstable();
mapped_lines.dedup();
let mapped_ranges = LineRange::compress_lines(&mapped_lines);
let current_coverage = target_coverage
.get(&source_attestation.file_path)
.map(Vec::as_slice)
.unwrap_or(&[]);
let missing_ranges = subtract_line_ranges(&mapped_ranges, current_coverage);
if missing_ranges.is_empty() {
continue;
}

target_coverage
.entry(source_attestation.file_path.clone())
.or_default()
.extend(missing_ranges.clone());
let file = recovered_log.get_or_create_file(&source_attestation.file_path);
file.add_entry(AttestationEntry::new(
source_entry.hash.clone(),
missing_ranges,
));
}
}

recovered_log
.attestations
.retain(|attestation| !attestation.entries.is_empty());
retain_referenced_metadata(&mut recovered_log);
merge_file_attestations(target, &recovered_log);
merge_authorship_metadata(target, &recovered_log);
}

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);
merge_authorship_metadata(&mut merged, &resolution_log);
merged.metadata.base_commit_sha = commit_sha.to_string();
merged
}
1 change: 1 addition & 0 deletions src/authorship/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod attribution_tracker;
pub mod authorship_log;
pub mod authorship_log_serialization;
pub mod background_agent;
pub mod conflict_resolution;
pub mod diff_ai_accepted;
pub mod git_ai_hooks;
pub mod hunk_shift;
Expand Down
24 changes: 24 additions & 0 deletions src/authorship/post_commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,27 @@ pub fn post_commit_from_working_log(
human_author: String,
supress_output: bool,
) -> Result<(String, AuthorshipLog), GitAiError> {
post_commit_from_working_log_with_transform(
repo,
base_commit,
commit_sha,
human_author,
supress_output,
Ok,
)
}

pub fn post_commit_from_working_log_with_transform<F>(
repo: &Repository,
base_commit: Option<String>,
commit_sha: String,
human_author: String,
supress_output: bool,
transform: F,
) -> Result<(String, AuthorshipLog), GitAiError>
where
F: FnOnce(AuthorshipLog) -> Result<AuthorshipLog, GitAiError>,
{
// Use base_commit parameter if provided, otherwise use "initial" for empty repos
// This matches the convention in checkpoint.rs
let parent_sha = base_commit.unwrap_or_else(|| "initial".to_string());
Expand Down Expand Up @@ -157,6 +178,9 @@ pub fn post_commit_from_working_log(
}
}

authorship_log = transform(authorship_log)?;
authorship_log.metadata.base_commit_sha = commit_sha.clone();

// Long-lived daemon processes should read a fresh config snapshot.
// Always use Config::fresh() to support runtime config updates
// (especially important for daemon mode, but also good for consistency)
Expand Down
45 changes: 25 additions & 20 deletions src/authorship/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,26 +40,7 @@ pub fn handle_rewrite_event(repo: &Repository, event: RewriteEvent) -> Result<()
ref old_tip,
ref new_tip,
ref onto,
} => {
let result = derive_mappings_from_range_diff(repo, old_tip, new_tip, onto.as_deref())?;
match result {
RangeDiffResult::Squash { base } => {
handle_squash_merge(repo, old_tip, new_tip, &base)
}
RangeDiffResult::Mappings(mappings) => {
if mappings.is_empty() {
return Ok(());
}
let source_shas: Vec<String> =
mappings.iter().map(|(src, _)| src.clone()).collect();
crate::git::sync_authorship::fetch_missing_notes_for_commits(
repo,
&source_shas,
);
shift_authorship_notes(repo, &mappings)
}
}
}
} => handle_non_fast_forward_rewrite(repo, old_tip, new_tip, onto.as_deref()).map(|_| ()),
RewriteEvent::CherryPickComplete {
sources,
new_commits,
Expand All @@ -75,6 +56,30 @@ pub fn handle_rewrite_event(repo: &Repository, event: RewriteEvent) -> Result<()
}
}

pub fn handle_non_fast_forward_rewrite(
repo: &Repository,
old_tip: &str,
new_tip: &str,
onto: Option<&str>,
) -> Result<Vec<(String, String)>, GitAiError> {
let result = derive_mappings_from_range_diff(repo, old_tip, new_tip, onto)?;
match result {
RangeDiffResult::Squash { base } => {
handle_squash_merge(repo, old_tip, new_tip, &base)?;
Ok(Vec::new())
}
RangeDiffResult::Mappings(mappings) => {
if mappings.is_empty() {
return Ok(Vec::new());
}
let source_shas: Vec<String> = mappings.iter().map(|(src, _)| src.clone()).collect();
crate::git::sync_authorship::fetch_missing_notes_for_commits(repo, &source_shas);
shift_authorship_notes(repo, &mappings)?;
Ok(mappings)
}
}
}

fn handle_squash_merge(
repo: &Repository,
source_head: &str,
Expand Down
Loading
Loading