girt/rewrites/detect.rs
1use std::collections::BTreeMap;
2use std::sync::atomic::AtomicBool;
3
4use super::similarity::{Budget, Content, spend};
5use super::{Copies, Detection, Error, InexactSkipped, Kind, Limits, Options, Rewrite};
6use crate::{EntryMode, ObjectId, ObjectKind, Objects, TreeChange, TreeValue};
7
8impl Objects {
9 /// Infers renames and optional copies between two stored trees, without changing storage.
10 ///
11 /// Only additions of regular/executable blobs are destinations. Deleted blobs are rename
12 /// sources; [`Copies::Modified`] additionally uses preimages of modified blobs, including
13 /// mode-only changes, and permits source reuse. [`Copies::ModifiedPostimage`] scores their
14 /// postimages but reports their preimage IDs. Symlinks, gitlinks and directories are never
15 /// results. File/directory replacements use the structural comparison's leaf changes.
16 /// Both object formats are supported; candidate blobs are read and verified even for exact IDs.
17 ///
18 /// Exact identity matches precede unique-basename pairing and exhaustive edited inference.
19 /// Unique unmatched basenames pair without copies at halfway from the minimum score to 100%.
20 /// This can prefer a basename pair over a more similar differently named destination.
21 /// Exact ties prefer an unused deleted source, then equal basename, then source raw path order.
22 /// Edited ties prefer higher rounded percentage, equal basename, source path, then
23 /// destination path; selected pairs greedily consume destinations and (without copies)
24 /// sources. This explicit deterministic policy does not promise Git's every heuristic
25 /// pairing choice. [`Rewrite`] defines scoring.
26 ///
27 /// `targets` is an optional set of exact destination byte paths, applied **after** all
28 /// inference and classification. It cannot change a tie or convert a copy into a rename.
29 /// Results sort by target bytes. `None` allows all; an empty slice returns no records but
30 /// still performs validation/inference. Paths need no OS/UTF-8 conversion.
31 ///
32 /// Cancellation is checked before validation, between reads/candidates, during span scanning,
33 /// and before and after sorting. Individual storage reads, allocation, tree parsing and sorting
34 /// cannot be interrupted. Callers own worker scheduling and must leave `cancel` set until
35 /// return. No attributes, clean/smudge/textconv drivers, index or worktree are consulted: these
36 /// are comparisons of stored blob bytes. Callers own configuration and merge/history policy.
37 ///
38 /// [`Self::detect_rewrites_or_exact`] instead returns the pairs already found when the
39 /// [`Options::candidate_limit`] skips exhaustive edited-content detection.
40 ///
41 /// # Errors
42 ///
43 /// Returns [`Error`] on invalid options, tree/blob storage failures, missing/wrong-kind blobs,
44 /// cancellation, or exhausted candidate/resource limits. Errors return no partial inference;
45 /// no repository state is changed. Exact matching is attempted before the candidate limit,
46 /// but storage and work bounds apply throughout. See [`Limits`] for memory accounting.
47 ///
48 /// ```
49 /// use std::sync::atomic::AtomicBool;
50 ///
51 /// use girt::rewrites::{Kind, Limits, Options};
52 /// use girt::{EntryMode, InitKind, ObjectFormat, Repository, Tree, TreeEntry};
53 ///
54 /// let directory = tempfile::tempdir()?;
55 /// let repo = Repository::init(
56 /// ObjectFormat::Sha1,
57 /// directory.path().join("r"),
58 /// InitKind::Bare,
59 /// )?;
60 ///
61 /// let loose = repo.loose_objects();
62 /// let tree = |files: &[(&[u8], &[u8])]| -> Result<_, Box<dyn std::error::Error>> {
63 /// let entries = files
64 /// .iter()
65 /// .map(|(name, bytes)| {
66 /// Ok(TreeEntry {
67 /// name: name.to_vec(),
68 /// mode: EntryMode::Blob,
69 /// id: loose.write_blob(bytes)?,
70 /// })
71 /// })
72 /// .collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
73 /// Ok(loose.write_tree(&Tree::new(ObjectFormat::Sha1, entries)?)?)
74 /// };
75 ///
76 /// let old = tree(&[(b"before", b"same content\n")])?;
77 /// let new = tree(&[(b"after", b"same content\n")])?;
78 ///
79 /// let records = repo.objects_with(Default::default())?.detect_rewrites(
80 /// Some(old),
81 /// Some(new),
82 /// Options::default(),
83 /// Limits::default(),
84 /// None,
85 /// &AtomicBool::new(false),
86 /// )?;
87 ///
88 /// assert_eq!(records[0].kind, Kind::Rename);
89 /// assert_eq!(records[0].similarity, 100);
90 /// assert_eq!(records[0].source_id, records[0].target_id);
91 /// # Ok::<(), Box<dyn std::error::Error>>(())
92 /// ```
93 pub fn detect_rewrites(
94 &self,
95 old: Option<ObjectId>,
96 new: Option<ObjectId>,
97 options: Options,
98 limits: Limits,
99 targets: Option<&[&[u8]]>,
100 cancel: &AtomicBool,
101 ) -> Result<Vec<Rewrite>, Error> {
102 let detection =
103 self.detect_rewrites_or_exact(old, new, options, limits, targets, cancel)?;
104 match detection.inexact_skipped {
105 None => Ok(detection.rewrites),
106 Some(skipped) => Err(Error::Candidates {
107 sources: skipped.sources,
108 targets: skipped.targets,
109 limit: skipped.limit,
110 }),
111 }
112 }
113
114 /// Infers rewrites as [`Self::detect_rewrites`] does, keeping pre-limit matches.
115 ///
116 /// When the remaining candidates exceed [`Options::candidate_limit`], edited-content
117 /// detection is skipped and [`Detection::inexact_skipped`] says so. Exact and unique-basename
118 /// pairs are still returned. Callers decide whether to warn, retry with a higher limit, or
119 /// accept the pairs found before the limit.
120 ///
121 /// # Errors
122 ///
123 /// Returns [`Error`] on the same conditions as [`Self::detect_rewrites`], except that an
124 /// exceeded candidate limit is reported in the result rather than as [`Error::Candidates`].
125 ///
126 /// # Keep exact renames when edited detection is too expensive
127 ///
128 /// The fallback API, available since 0.10.0, keeps a useful result for large changes while
129 /// making its limitation visible. Unpaired additions/deletions must remain visible to the
130 /// caller. Other work limits and storage failures still return errors, never partial
131 /// detection.
132 ///
133 /// ```
134 /// use std::sync::atomic::AtomicBool;
135 ///
136 /// use girt::rewrites::{Error, Limits, Options};
137 /// use girt::{EntryMode, InitKind, ObjectFormat, Repository, Tree, TreeEntry};
138 ///
139 /// let directory = tempfile::tempdir()?;
140 /// let repo = Repository::init(
141 /// ObjectFormat::Sha1,
142 /// directory.path().join("r"),
143 /// InitKind::Bare,
144 /// )?;
145 ///
146 /// let loose = repo.loose_objects();
147 /// let tree = |files: &[(&[u8], &[u8])]| -> Result<_, Box<dyn std::error::Error>> {
148 /// let entries = files
149 /// .iter()
150 /// .map(|(name, bytes)| {
151 /// Ok(TreeEntry {
152 /// name: name.to_vec(),
153 /// mode: EntryMode::Blob,
154 /// id: loose.write_blob(bytes)?,
155 /// })
156 /// })
157 /// .collect::<Result<Vec<_>, Box<dyn std::error::Error>>>()?;
158 /// Ok(loose.write_tree(&Tree::new(ObjectFormat::Sha1, entries)?)?)
159 /// };
160 ///
161 /// let old = tree(&[
162 /// (b"exact-old", b"identical\n"),
163 /// (b"edited-old", b"line one\nline two\n"),
164 /// ])?;
165 /// let new = tree(&[
166 /// (b"exact-new", b"identical\n"),
167 /// (b"edited-new", b"line one\nline THREE\n"),
168 /// ])?;
169 ///
170 /// let objects = repo.objects_with(Default::default())?;
171 /// let options = Options::default().with_candidate_limit(0);
172 /// let cancel = AtomicBool::new(false);
173 /// let detection = objects.detect_rewrites_or_exact(
174 /// Some(old),
175 /// Some(new),
176 /// options,
177 /// Limits::default(),
178 /// None,
179 /// &cancel,
180 /// )?;
181 ///
182 /// assert_eq!(detection.rewrites.len(), 1); // The identical pair survived.
183 /// assert_eq!(detection.rewrites[0].source, b"exact-old");
184 /// let skipped = detection.inexact_skipped.expect("one edited pair remains");
185 /// assert_eq!((skipped.sources, skipped.targets, skipped.limit), (1, 1, 0));
186 /// assert_eq!(skipped.suggested_limit(), 1);
187 ///
188 /// assert!(matches!(
189 /// objects.detect_rewrites(
190 /// Some(old),
191 /// Some(new),
192 /// options,
193 /// Limits::default(),
194 /// None,
195 /// &cancel
196 /// ),
197 /// Err(Error::Candidates { .. })
198 /// ));
199 /// # Ok::<(), Box<dyn std::error::Error>>(())
200 /// ```
201 pub fn detect_rewrites_or_exact(
202 &self,
203 old: Option<ObjectId>,
204 new: Option<ObjectId>,
205 options: Options,
206 limits: Limits,
207 targets: Option<&[&[u8]]>,
208 cancel: &AtomicBool,
209 ) -> Result<Detection, Error> {
210 let budget = Budget {
211 remaining: limits,
212 cancel,
213 };
214 budget.check()?;
215 if !(1..=100).contains(&options.similarity) {
216 return Err(Error::Similarity(options.similarity));
217 }
218 let changes = self.compare_trees_with(old, new, limits.trees, cancel)?;
219 self.detect_rewrites_from_changes(&changes, options, limits, targets, cancel)
220 }
221
222 /// Reuses an already bounded structural comparison without traversing trees again.
223 pub(crate) fn detect_rewrites_from_changes(
224 &self,
225 changes: &[TreeChange],
226 options: Options,
227 limits: Limits,
228 targets: Option<&[&[u8]]>,
229 cancel: &AtomicBool,
230 ) -> Result<Detection, Error> {
231 self.detect_rewrites_with_memory(changes, options, limits, targets, cancel, None)
232 }
233
234 /// Includes verified, newly generated merge blobs without installing them in the store.
235 pub(crate) fn detect_rewrites_with_memory(
236 &self,
237 changes: &[TreeChange],
238 options: Options,
239 limits: Limits,
240 targets: Option<&[&[u8]]>,
241 cancel: &AtomicBool,
242 memory: Option<&BTreeMap<ObjectId, Vec<u8>>>,
243 ) -> Result<Detection, Error> {
244 let mut budget = Budget {
245 remaining: limits,
246 cancel,
247 };
248 budget.check()?;
249 if !(1..=100).contains(&options.similarity) {
250 return Err(Error::Similarity(options.similarity));
251 }
252 let (sources, destinations) = candidates(changes, options.copies, &mut budget)?;
253 let mut blobs = BTreeMap::new();
254 for candidate in sources.iter().chain(&destinations) {
255 budget.check()?;
256 if let std::collections::btree_map::Entry::Vacant(entry) =
257 blobs.entry(candidate.value.id)
258 {
259 entry.insert(read(self, candidate.value.id, &mut budget, memory)?);
260 }
261 }
262 let mut selection = Selection::new(&sources, &destinations, options);
263 selection.exact(&blobs, &mut budget)?;
264 let inexact_skipped = if options.similarity < 100 {
265 selection.edited(&mut blobs, &mut budget)?
266 } else {
267 None
268 };
269 budget.check()?;
270 let mut records = selection.records;
271 if let Some(targets) = targets {
272 // Build once so target filtering cannot multiply candidate work by filter count.
273 let mut selected = std::collections::BTreeSet::new();
274 for target in targets {
275 budget.work(
276 target
277 .len()
278 .saturating_mul((selected.len() + 1).ilog2() as usize + 1),
279 )?;
280 selected.insert(*target);
281 }
282 records.retain(|record| selected.contains(record.target.as_slice()));
283 }
284 records.sort_by(|a, b| a.target.cmp(&b.target));
285 budget.check()?;
286 Ok(Detection {
287 rewrites: records,
288 inexact_skipped,
289 })
290 }
291}
292
293struct Candidate<'a> {
294 path: &'a [u8],
295 value: TreeValue,
296 source_id: ObjectId,
297 deleted: bool,
298}
299
300fn candidates<'a>(
301 changes: &'a [TreeChange],
302 copies: Copies,
303 budget: &mut Budget<'_>,
304) -> Result<(Vec<Candidate<'a>>, Vec<Candidate<'a>>), Error> {
305 let mut sources = Vec::new();
306 let mut targets = Vec::new();
307 for change in changes {
308 budget.work(1)?;
309 if let Some(old) = change.old.filter(|value| is_file(value.mode))
310 && (change.new.is_none()
311 || (copies != Copies::Disabled && change.new.is_some_and(|v| is_file(v.mode))))
312 {
313 let value = if copies == Copies::ModifiedPostimage {
314 change.new.unwrap_or(old)
315 } else {
316 old
317 };
318 sources.push(Candidate {
319 path: &change.path,
320 value,
321 source_id: old.id,
322 deleted: change.new.is_none(),
323 });
324 }
325 if change.old.is_none()
326 && let Some(new) = change.new.filter(|value| is_file(value.mode))
327 {
328 targets.push(Candidate {
329 path: &change.path,
330 value: new,
331 source_id: new.id,
332 deleted: false,
333 });
334 }
335 }
336 Ok((sources, targets))
337}
338
339fn is_file(mode: EntryMode) -> bool {
340 matches!(mode, EntryMode::Blob | EntryMode::Executable)
341}
342
343struct Blob {
344 bytes: Vec<u8>,
345 content: Option<Content>,
346}
347
348impl Blob {
349 fn binary(&self) -> bool {
350 self.bytes[..self.bytes.len().min(8000)].contains(&0)
351 }
352}
353
354fn read(
355 objects: &Objects,
356 id: ObjectId,
357 budget: &mut Budget<'_>,
358 memory: Option<&BTreeMap<ObjectId, Vec<u8>>>,
359) -> Result<Blob, Error> {
360 let mut limits = budget.remaining.read;
361 limits.max_object_bytes = limits.max_object_bytes.min(budget.remaining.max_blob_bytes);
362 if let Some(bytes) = memory.and_then(|memory| memory.get(&id)) {
363 if bytes.len() > limits.max_object_bytes {
364 return Err(Error::Limit("overlay blob bytes"));
365 }
366 spend(
367 &mut budget.remaining.max_blob_bytes,
368 bytes.len(),
369 "blob bytes",
370 )?;
371 budget.check()?;
372 return Ok(Blob {
373 bytes: bytes.clone(),
374 content: None,
375 });
376 }
377 let object = objects
378 .read_with(id, limits)
379 .map_err(|source| Error::Read { id, source })?
380 .ok_or(Error::Missing(id))?;
381 if object.kind() != ObjectKind::Blob {
382 return Err(Error::NotBlob {
383 id,
384 actual: object.kind(),
385 });
386 }
387 let bytes = object.into_data();
388 spend(
389 &mut budget.remaining.max_blob_bytes,
390 bytes.len(),
391 "blob bytes",
392 )?;
393 budget.check()?;
394 Ok(Blob {
395 bytes,
396 content: None,
397 })
398}
399
400struct Selection<'a> {
401 sources: &'a [Candidate<'a>],
402 targets: &'a [Candidate<'a>],
403 used_sources: Vec<bool>,
404 used_targets: Vec<bool>,
405 options: Options,
406 records: Vec<Rewrite>,
407}
408
409impl<'a> Selection<'a> {
410 fn new(sources: &'a [Candidate<'a>], targets: &'a [Candidate<'a>], options: Options) -> Self {
411 Self {
412 sources,
413 targets,
414 used_sources: vec![false; sources.len()],
415 used_targets: vec![false; targets.len()],
416 options,
417 records: Vec::new(),
418 }
419 }
420
421 fn available(&self, source: usize) -> bool {
422 !self.used_sources[source] || self.options.copies != Copies::Disabled
423 }
424
425 fn exact(
426 &mut self,
427 blobs: &BTreeMap<ObjectId, Blob>,
428 budget: &mut Budget<'_>,
429 ) -> Result<(), Error> {
430 let mut by_id: BTreeMap<ObjectId, Vec<usize>> = BTreeMap::new();
431 for (index, source) in self.sources.iter().enumerate() {
432 budget.work(1)?;
433 if self.options.track_empty || !blobs[&source.value.id].bytes.is_empty() {
434 by_id.entry(source.value.id).or_default().push(index);
435 }
436 }
437 for (target, destination) in self.targets.iter().enumerate() {
438 budget.work(1)?;
439 let Some(sources) = by_id.get(&destination.value.id) else {
440 continue;
441 };
442 let mut best = None;
443 for &source in sources {
444 budget.pair()?;
445 if !self.available(source) {
446 continue;
447 }
448 let candidate = &self.sources[source];
449 let rank = (
450 !candidate.deleted || self.used_sources[source],
451 basename(candidate.path) != basename(destination.path),
452 candidate.path,
453 );
454 if best.is_none_or(|(_, previous)| rank < previous) {
455 best = Some((source, rank));
456 }
457 }
458 if let Some((source, _)) = best {
459 self.select(source, target, 100);
460 }
461 }
462 Ok(())
463 }
464
465 /// Pairs remaining candidates by edited content, unless the candidate limit skips it.
466 fn edited(
467 &mut self,
468 blobs: &mut BTreeMap<ObjectId, Blob>,
469 budget: &mut Budget<'_>,
470 ) -> Result<Option<InexactSkipped>, Error> {
471 self.same_basename(blobs, budget)?;
472 // Git counts available sources (reusable ones too, with copies) and unmatched destinations,
473 // including binary files, and empty files when empty renames are enabled. Decide the skip
474 // before filtering to scorable files, which can leave nothing to score.
475 let counted = |blob: &Blob| self.options.track_empty || !blob.bytes.is_empty();
476 let scorable = |blob: &Blob| {
477 !blob.bytes.is_empty() && (self.options.approximate_binary || !blob.binary())
478 };
479 let source_count = (0..self.sources.len())
480 .filter(|&s| self.available(s) && counted(&blobs[&self.sources[s].value.id]))
481 .count();
482 let target_count = (0..self.targets.len())
483 .filter(|&t| !self.used_targets[t] && counted(&blobs[&self.targets[t].value.id]))
484 .count();
485 // Git compares the candidate product with the squared limit, so one source may be scored
486 // against many destinations. Widen so the comparison cannot overflow.
487 let limit = self.options.candidate_limit as u128;
488 if source_count as u128 * target_count as u128 > limit * limit {
489 return Ok(Some(InexactSkipped {
490 sources: source_count,
491 targets: target_count,
492 limit: self.options.candidate_limit,
493 }));
494 }
495 let sources: Vec<_> = (0..self.sources.len())
496 .filter(|&s| self.available(s) && scorable(&blobs[&self.sources[s].value.id]))
497 .collect();
498 let targets: Vec<_> = (0..self.targets.len())
499 .filter(|&t| !self.used_targets[t] && scorable(&blobs[&self.targets[t].value.id]))
500 .collect();
501 if sources.is_empty() || targets.is_empty() {
502 return Ok(None);
503 }
504 for id in sources
505 .iter()
506 .map(|&s| self.sources[s].value.id)
507 .chain(targets.iter().map(|&t| self.targets[t].value.id))
508 {
509 let blob = blobs.get_mut(&id).expect("candidate blob was loaded");
510 if blob.content.is_none() {
511 blob.content = Some(Content::new(&blob.bytes, budget)?);
512 }
513 }
514 let mut pairs = Vec::new();
515 for source in sources {
516 for &target in &targets {
517 budget.pair()?;
518 let old = blobs[&self.sources[source].value.id]
519 .content
520 .as_ref()
521 .unwrap();
522 let new = blobs[&self.targets[target].value.id]
523 .content
524 .as_ref()
525 .unwrap();
526 if let Some(score) = old.score(new, self.options.similarity, budget)? {
527 pairs.push((source, target, score));
528 }
529 }
530 }
531 budget.check()?;
532 pairs.sort_by_key(|&(source, target, score)| {
533 (
534 std::cmp::Reverse(score),
535 basename(self.sources[source].path) != basename(self.targets[target].path),
536 self.sources[source].path,
537 self.targets[target].path,
538 )
539 });
540 budget.check()?;
541 for (source, target, score) in pairs {
542 budget.work(1)?;
543 if self.available(source) && !self.used_targets[target] {
544 self.select(source, target, score);
545 }
546 }
547 Ok(None)
548 }
549
550 /// Unique unmatched basenames get first refusal at halfway from the requested score to 100%.
551 /// Copy detection skips this pass because its sources remain reusable.
552 fn same_basename(
553 &mut self,
554 blobs: &mut BTreeMap<ObjectId, Blob>,
555 budget: &mut Budget<'_>,
556 ) -> Result<(), Error> {
557 if self.options.copies != Copies::Disabled {
558 return Ok(());
559 }
560 let sources = unique_basenames(
561 self.sources,
562 &self.used_sources,
563 blobs,
564 self.options.track_empty,
565 budget,
566 )?;
567 let targets = unique_basenames(
568 self.targets,
569 &self.used_targets,
570 blobs,
571 self.options.track_empty,
572 budget,
573 )?;
574 for (name, source) in sources {
575 budget.work(
576 name.len()
577 .saturating_mul((targets.len() + 1).ilog2() as usize + 1),
578 )?;
579 let (Some(source), Some(&Some(target))) = (source, targets.get(name)) else {
580 continue;
581 };
582 budget.pair()?;
583 let source_id = self.sources[source].value.id;
584 let target_id = self.targets[target].value.id;
585 if [source_id, target_id].iter().any(|id| {
586 let blob = &blobs[id];
587 blob.bytes.is_empty() || (!self.options.approximate_binary && blob.binary())
588 }) {
589 continue;
590 }
591 for id in [source_id, target_id] {
592 let blob = blobs.get_mut(&id).expect("candidate blob was loaded");
593 if blob.content.is_none() {
594 blob.content = Some(Content::new(&blob.bytes, budget)?);
595 }
596 }
597 let old = blobs[&source_id].content.as_ref().unwrap();
598 let new = blobs[&target_id].content.as_ref().unwrap();
599 let minimum = 100 + u16::from(self.options.similarity);
600 if let Some(score) = old.score_half_percent(new, minimum, budget)? {
601 self.select(source, target, score);
602 }
603 }
604 Ok(())
605 }
606
607 fn select(&mut self, source: usize, target: usize, similarity: u8) {
608 let old = &self.sources[source];
609 let new = &self.targets[target];
610 let kind = if old.deleted && !self.used_sources[source] {
611 Kind::Rename
612 } else {
613 Kind::Copy
614 };
615 self.records.push(Rewrite {
616 source: old.path.to_vec(),
617 target: new.path.to_vec(),
618 source_id: old.source_id,
619 target_id: new.value.id,
620 similarity,
621 kind,
622 });
623 self.used_sources[source] = true;
624 self.used_targets[target] = true;
625 }
626}
627
628/// Ambiguous basenames remain present as `None`, so a later collision cannot become unique again.
629fn unique_basenames<'a>(
630 candidates: &[Candidate<'a>],
631 used: &[bool],
632 blobs: &BTreeMap<ObjectId, Blob>,
633 track_empty: bool,
634 budget: &mut Budget<'_>,
635) -> Result<BTreeMap<&'a [u8], Option<usize>>, Error> {
636 let mut names = BTreeMap::new();
637 for (index, candidate) in candidates.iter().enumerate() {
638 budget.work(
639 candidate
640 .path
641 .len()
642 .saturating_mul((names.len() + 1).ilog2() as usize + 1),
643 )?;
644 if !used[index] && (track_empty || !blobs[&candidate.value.id].bytes.is_empty()) {
645 names
646 .entry(basename(candidate.path))
647 .and_modify(|entry| *entry = None)
648 .or_insert(Some(index));
649 }
650 }
651 Ok(names)
652}
653
654fn basename(path: &[u8]) -> &[u8] {
655 path.rsplit(|&b| b == b'/').next().unwrap_or(path)
656}