Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
121 changes: 96 additions & 25 deletions src/authorship/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,22 @@ fn derive_mappings_from_range_diff(
}
_ => &base,
};
let commit_limit = range_diff_commit_limit();
let old_count = range_commit_count_up_to(repo, &base, old_tip, commit_limit);
let new_count = range_commit_count_up_to(repo, onto, new_tip, commit_limit);
if !matches!((old_count, new_count), (Some(old), Some(new)) if old <= commit_limit && new <= commit_limit)
{
tracing::warn!(
old_tip,
new_tip,
old_count,
new_count,
commit_limit,
"skipping range-diff mapping derivation because a range exceeds the commit limit"
);
return Ok(Vec::new());
}
Comment thread
svarlamov marked this conversation as resolved.
Outdated

let range_diff_output = run_range_diff(repo, &base, old_tip, onto, new_tip)?;
let mut mappings = parse_range_diff_output(&range_diff_output);

Expand Down Expand Up @@ -1046,6 +1062,40 @@ pub(crate) fn list_commits_in_range(repo: &Repository, base: &str, tip: &str) ->
.unwrap_or_default()
}

/// `git range-diff` retains every commit's patch while calculating its
/// pairwise matching costs. Bounding each input side prevents a divergent
/// ref move from making the daemon spawn a multi-GB range-diff child.
const MAX_RANGE_DIFF_COMMITS: u64 = 1000;

fn range_diff_commit_limit() -> u64 {
#[cfg(feature = "test-support")]
if let Ok(raw) = std::env::var("GIT_AI_TEST_RANGE_DIFF_COMMIT_LIMIT")
&& let Ok(limit) = raw.parse()
{
return limit;
}

MAX_RANGE_DIFF_COMMITS
}

/// Count at most `limit + 1` commits so the preflight itself has bounded work.
/// This is called once per range-diff side: two constant git spawns total,
/// independent of the number of commits in either range.
fn range_commit_count_up_to(repo: &Repository, base: &str, tip: &str, limit: u64) -> Option<u64> {
let mut args = repo.global_args_for_exec();
args.extend([
"rev-list".to_string(),
"--count".to_string(),
format!("--max-count={}", limit.saturating_add(1)),
format!("{}..{}", base, tip),
]);
let output = exec_git_allow_nonzero(&args).ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}

fn run_range_diff(
repo: &Repository,
old_base: &str,
Expand All @@ -1067,16 +1117,12 @@ fn run_range_diff(
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

/// Maximum number of unmatched old-range (`<`) commits buffered ahead of the
/// first matched pair. A genuine rebase squash absorbs a handful of commits
/// into the first surviving one, so small queues are drained into that match
/// as before. But when the count exceeds this bound the history is divergent
/// — e.g. a restack undo (`git reset --keep <pre-rebase-tip>`), where the old
/// range spans every trunk commit landed since the branch forked — and the
/// whole queue is discarded, permanently. Without this, each bogus mapping
/// whose source commit has an authorship note becomes a full-root-tree diff
/// pair, and daemon memory scales as
/// (trunk commits since fork) × (lines modified on trunk).
/// Maximum number of consecutive unmatched old-range (`<`) commits that may
/// be treated as a squash into an adjacent matched commit. This applies both
/// before the first match and after a match: exceeding it discards the whole
/// fabricated run while retaining exact `=`/`!` mappings. Without the
/// post-match bound, moving a branch away from many commits on top of its
/// matched stack fabricated one full-root-tree diff pair per dropped commit.
const MAX_PENDING_DROPPED_COMMITS: usize = 64;

fn parse_range_diff_output(output: &str) -> Vec<(String, String)> {
Expand Down Expand Up @@ -1105,19 +1151,14 @@ fn parse_range_diff_output(output: &str) -> Vec<(String, String)> {
match status_char {
'<' => {
// Dropped commit (squashed into a later commit)
if !old_sha.chars().all(|c| c == '0') {
if let Some(new_sha) = previous_new_sha.as_ref() {
mappings.push((old_sha, new_sha.clone()));
} else if !pending_overflowed {
pending_dropped.push(old_sha);
if pending_dropped.len() > MAX_PENDING_DROPPED_COMMITS {
// Divergent history (e.g. restack undo), not a
// squash: discard the queue and stop collecting so
// the mapping count stays bounded.
pending_dropped.clear();
pending_dropped.shrink_to_fit();
pending_overflowed = true;
}
if !old_sha.chars().all(|c| c == '0') && !pending_overflowed {
pending_dropped.push(old_sha);
if pending_dropped.len() > MAX_PENDING_DROPPED_COMMITS {
// Divergent history, not a squash: discard the entire
// run and stop collecting until the next exact match.
pending_dropped.clear();
pending_dropped.shrink_to_fit();
pending_overflowed = true;
}
}
}
Expand All @@ -1130,10 +1171,14 @@ fn parse_range_diff_output(output: &str) -> Vec<(String, String)> {
if old_sha.chars().all(|c| c == '0') || new_sha.chars().all(|c| c == '0') {
continue;
}
// Map any preceding dropped commits to this new commit (squash)
// Leading drops squash into this match. Drops after a prior
// match squash into that prior destination, preserving the
// parser's existing direction for small runs.
let dropped_destination = previous_new_sha.as_ref().unwrap_or(&new_sha);
for dropped in pending_dropped.drain(..) {
mappings.push((dropped, new_sha.clone()));
mappings.push((dropped, dropped_destination.clone()));
}
pending_overflowed = false;
previous_new_sha = Some(new_sha.clone());
mappings.push((old_sha, new_sha));
}
Expand All @@ -1144,6 +1189,12 @@ fn parse_range_diff_output(output: &str) -> Vec<(String, String)> {
}
}

if !pending_overflowed && let Some(new_sha) = previous_new_sha {
for dropped in pending_dropped {
mappings.push((dropped, new_sha.clone()));
}
}

mappings
}

Expand Down Expand Up @@ -1821,6 +1872,26 @@ Binary files a/image.png and b/image.png differ
);
}

#[test]
fn test_parse_range_diff_output_discards_many_drops_after_match() {
let old_matched = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
let new_matched = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string();
let mut output = format!("1: {old_matched} = 1: {new_matched} matched\n");
for i in 1..=MAX_PENDING_DROPPED_COMMITS + 1 {
let sha = format!("{i:040}");
output.push_str(&format!(
"{}: {} < -: ---------------------------------------- dropped {}\n",
i + 1,
sha,
i
));
}

let mappings = parse_range_diff_output(&output);

assert_eq!(mappings, vec![(old_matched, new_matched)]);
}

#[test]
fn test_parse_range_diff_output_divergent_history_discards_pending() {
// Regression: restack-undo (moving a branch tip backward to undo a
Expand Down
Loading
Loading