Skip to main content

gix_diff/rewrites/
tracker.rs

1//! ### Deviation
2//!
3//! Note that the algorithm implemented here is in many ways different from what `git` does.
4//!
5//! - it's less sophisticated than `git`, but prefers a candidate whose file name matches the
6//!   destination's in identity matches, and uses that as a tie-breaker for similarity matches.
7//! - the set used for copy-detection is probably smaller by default.
8// TODO: Rewrite this based on what Git actually this, as long as there are test-cases for any 'complication'.
9//       In practice, even this simplified version seems to have worked pretty well.
10
11use gix_error::{Result, message};
12use std::ops::Range;
13
14use bstr::{BStr, ByteSlice};
15use gix_error::ResultExt;
16use gix_object::tree::{EntryKind, EntryMode};
17
18use crate::{
19    Rewrites,
20    blob::{DiffLineStats, ResourceKind, platform::prepare_diff::Operation},
21    rewrites::{CopySource, Outcome, Tracker, tracker::visit::SourceKind},
22    tree::visit::{Action, ChangeId, Relation},
23};
24
25/// The kind of a change.
26#[derive(Debug, Copy, Clone, Ord, PartialOrd, PartialEq, Eq)]
27pub enum ChangeKind {
28    /// The change represents the *deletion* of an item.
29    Deletion,
30    /// The change represents the *modification* of an item.
31    Modification,
32    /// The change represents the *addition* of an item.
33    Addition,
34}
35
36/// A trait providing all functionality to abstract over the concept of a change, as seen by the [`Tracker`].
37pub trait Change: Clone {
38    /// Return the hash of the object behind this change for identification.
39    ///
40    /// Note that this is the id of the object as stored in `git`, i.e. it must have gone through workspace
41    /// conversions. What matters is that the IDs are comparable.
42    fn id(&self) -> &gix_hash::oid;
43    /// Return the relation that this change may have with other changes.
44    ///
45    /// It allows to associate a directory with its children that are added or removed at the same moment.
46    /// Note that this is ignored for modifications.
47    ///
48    /// If rename-tracking should always be on leaf-level, this should be set to `None` consistently.
49    /// Note that trees will never be looked up by their `id` as their children are assumed to be passed in
50    /// with the respective relationship.
51    ///
52    /// Also note that the tracker only sees what's given to it, it will not lookup trees or match paths itself.
53    fn relation(&self) -> Option<Relation>;
54    /// Return the kind of this change.
55    fn kind(&self) -> ChangeKind;
56    /// Return more information about the kind of entry affected by this change.
57    fn entry_mode(&self) -> EntryMode;
58    /// Return the id of the change along with its mode.
59    fn id_and_entry_mode(&self) -> (&gix_hash::oid, EntryMode);
60}
61
62/// A set of tracked items allows to figure out their relations by figuring out their similarity.
63pub(crate) struct Item<T> {
64    /// The underlying raw change
65    change: T,
66    /// That slice into the backing for paths.
67    path: Range<usize>,
68    /// If true, this item was already emitted, i.e. seen by the caller.
69    emitted: bool,
70}
71
72impl<T: Change> Item<T> {
73    fn location<'a>(&self, backing: &'a [u8]) -> &'a BStr {
74        backing[self.path.clone()].as_ref()
75    }
76    fn entry_mode_compatible(&self, other: EntryMode) -> bool {
77        use EntryKind::*;
78        matches!(
79            (other.kind(), self.change.entry_mode().kind()),
80            (Blob | BlobExecutable, Blob | BlobExecutable) | (Link, Link) | (Tree, Tree) | (Commit, Commit)
81        )
82    }
83
84    fn is_source_candidate(&self, kind: visit::SourceKind) -> bool {
85        match kind {
86            visit::SourceKind::Rename => !self.emitted && matches!(self.change.kind(), ChangeKind::Deletion),
87            visit::SourceKind::Copy => matches!(self.change.kind(), ChangeKind::Modification),
88        }
89    }
90
91    fn is_source_for_destination_of(&self, kind: visit::SourceKind, dest_item_mode: EntryMode) -> bool {
92        self.entry_mode_compatible(dest_item_mode) && self.is_source_candidate(kind)
93    }
94}
95
96/// A module with types used in the user-callback in [Tracker::emit()](crate::rewrites::Tracker::emit()).
97pub mod visit {
98    use bstr::BStr;
99    use gix_object::tree::EntryMode;
100
101    use crate::blob::DiffLineStats;
102
103    /// The source of a rewrite, rename or copy.
104    #[derive(Debug, Clone, PartialEq, PartialOrd)]
105    pub struct Source<'a, T> {
106        /// The kind of entry.
107        pub entry_mode: EntryMode,
108        /// The hash of the state of the source as seen in the object database.
109        pub id: gix_hash::ObjectId,
110        /// Further specify what kind of source this is.
111        pub kind: SourceKind,
112        /// The repository-relative location of this entry.
113        pub location: &'a BStr,
114        /// The change that was registered as source.
115        pub change: &'a T,
116        /// If this is a rewrite, indicate how many lines would need to change to turn this source into the destination.
117        pub diff: Option<DiffLineStats>,
118    }
119
120    /// Further identify the kind of [Source].
121    #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
122    pub enum SourceKind {
123        /// This is the source of an entry that was renamed, as `source` was renamed to `destination`.
124        Rename,
125        /// This is the source of a copy, as `source` was copied into `destination`.
126        Copy,
127    }
128
129    /// A change along with a location.
130    #[derive(Debug, Clone)]
131    pub struct Destination<'a, T: Clone> {
132        /// The change at the given `location`.
133        pub change: T,
134        /// The repository-relative location of this destination.
135        pub location: &'a BStr,
136    }
137}
138
139/// Lifecycle
140impl<T: Change> Tracker<T> {
141    /// Create a new instance with `rewrites` configuration.
142    pub fn new(rewrites: Rewrites) -> Self {
143        Tracker {
144            items: vec![],
145            path_backing: vec![],
146            rewrites,
147            child_renames: Default::default(),
148        }
149    }
150}
151
152/// build state and find matches.
153impl<T: Change> Tracker<T> {
154    /// We may refuse the push if that information isn't needed for what we have to track.
155    pub fn try_push_change(&mut self, change: T, location: &BStr) -> Option<T> {
156        let change_kind = change.kind();
157        if let (None, ChangeKind::Modification) = (self.rewrites.copies, change_kind) {
158            return Some(change);
159        }
160
161        let entry_kind = change.entry_mode().kind();
162        let relation = change
163            .relation()
164            .filter(|_| matches!(change_kind, ChangeKind::Addition | ChangeKind::Deletion));
165        if let (None, EntryKind::Tree) = (relation, entry_kind) {
166            return Some(change);
167        }
168
169        let start = self.path_backing.len();
170        self.path_backing.extend_from_slice(location);
171        let path = start..self.path_backing.len();
172
173        self.items.push(Item {
174            path,
175            change,
176            emitted: false,
177        });
178        None
179    }
180
181    /// Can only be called once effectively as it alters its own state to assure each item is only emitted once.
182    ///
183    /// `cb(destination, source)` is called for each item, either with `Some(source)` if it's
184    /// the destination of a copy or rename, or with `None` for source if no relation to other
185    /// items in the tracked set exist, which is like saying 'no rename or rewrite or copy' happened.
186    /// Note that directories with [relation](Relation) will be emitted if there is a match, along with all their matching
187    /// child-items which are similarly bundled as rename.
188    ///
189    /// `objects` is used to access blob data for similarity checks if required and is taken directly from the object database.
190    /// Worktree filters and text conversions will be applied afterwards automatically. Note that object-caching *should not*
191    /// be enabled as caching is implemented by `diff_cache`, after all, the blob that's actually diffed is going
192    /// through conversion steps.
193    ///
194    /// `diff_cache` is a way to retain a cache of resources that are prepared for rapid diffing, and it also controls
195    /// the diff-algorithm (provided no user-algorithm is set).
196    /// Note that we control a few options of `diff_cache` to assure it will ignore external commands.
197    /// Note that we do not control how the `diff_cache` converts resources, it's left to the caller to decide
198    /// if it should look at what's stored in `git`, or in the working tree, along with all diff-specific conversions.
199    ///
200    /// `push_source_tree(push_fn: push(change, location))` is a function that is called when the entire tree of the source
201    /// should be added as modifications by calling `push` repeatedly to use for perfect copy tracking. Note that `push`
202    /// will panic if `change` is not a modification, and it's valid to not call `push` at all.
203    pub fn emit<PushSourceTreeFn, E>(
204        &mut self,
205        mut cb: impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
206        diff_cache: &mut crate::blob::Platform,
207        objects: &impl gix_object::FindObjectOrHeader,
208        mut push_source_tree: PushSourceTreeFn,
209    ) -> Result<Outcome>
210    where
211        PushSourceTreeFn: FnMut(&mut dyn FnMut(T, &BStr)) -> std::result::Result<(), E>,
212        E: std::error::Error + Send + Sync + 'static,
213    {
214        fn is_parent(change: &impl Change) -> bool {
215            matches!(change.relation(), Some(Relation::Parent(_)))
216        }
217        diff_cache.options.skip_internal_diff_if_external_is_configured = false;
218
219        // Early abort: if there is no pair, don't do anything.
220        let has_work = {
221            let (mut num_deletions, mut num_additions, mut num_modifications) = (0, 0, 0);
222            let mut has_work = false;
223            for change in &self.items {
224                match change.change.kind() {
225                    ChangeKind::Deletion => {
226                        num_deletions += 1;
227                    }
228                    ChangeKind::Modification => {
229                        // This means we have copy-tracking enabled
230                        num_modifications += 1;
231                    }
232                    ChangeKind::Addition => num_additions += 1,
233                }
234                if (num_deletions != 0 && num_additions != 0)
235                    || (self.rewrites.copies.is_some() && num_modifications + num_additions > 1)
236                {
237                    has_work = true;
238                    break;
239                }
240            }
241            has_work
242        };
243
244        let mut out = Outcome {
245            options: self.rewrites,
246            ..Default::default()
247        };
248        if has_work {
249            self.sort_items_by_id_and_location();
250
251            // Rewrites by directory (without local changes) can be pruned out quickly,
252            // by finding only parents, their counterpart, and then all children can be matched by
253            // relationship ID.
254            self.match_pairs_of_kind(
255                visit::SourceKind::Rename,
256                &mut cb,
257                None, /* by identity for parents */
258                &mut out,
259                diff_cache,
260                objects,
261                Some(is_parent),
262            )?;
263
264            self.match_pairs_of_kind(
265                visit::SourceKind::Rename,
266                &mut cb,
267                self.rewrites.percentage,
268                &mut out,
269                diff_cache,
270                objects,
271                None,
272            )?;
273
274            self.match_renamed_directories(&mut cb)?;
275
276            if let Some(copies) = self.rewrites.copies {
277                self.match_pairs_of_kind(
278                    visit::SourceKind::Copy,
279                    &mut cb,
280                    copies.percentage,
281                    &mut out,
282                    diff_cache,
283                    objects,
284                    None,
285                )?;
286
287                match copies.source {
288                    CopySource::FromSetOfModifiedFiles => {}
289                    CopySource::FromSetOfModifiedFilesAndAllSources => {
290                        push_source_tree(&mut |change, location| {
291                            if self.try_push_change(change, location).is_none() {
292                                // make sure these aren't viable to be emitted anymore.
293                                self.items.last_mut().expect("just pushed").emitted = true;
294                            }
295                        })
296                        .or_raise(|| {
297                            message(
298                                "Could not obtain exhaustive item set to use as possible sources for copy detection",
299                            )
300                        })?;
301                        self.sort_items_by_id_and_location();
302
303                        self.match_pairs_of_kind(
304                            visit::SourceKind::Copy,
305                            &mut cb,
306                            copies.percentage,
307                            &mut out,
308                            diff_cache,
309                            objects,
310                            None,
311                        )?;
312                    }
313                }
314            }
315        }
316
317        self.items
318            .sort_by(|a, b| a.location(&self.path_backing).cmp(b.location(&self.path_backing)));
319        for item in self.items.drain(..).filter(|item| !item.emitted) {
320            if cb(
321                visit::Destination {
322                    location: item.location(&self.path_backing),
323                    change: item.change,
324                },
325                None,
326            )
327            .is_break()
328            {
329                break;
330            }
331        }
332        Ok(out)
333    }
334}
335
336impl<T: Change> Tracker<T> {
337    /// Sort `items` primarily by their id, so identity-based lookups can be found quickly by
338    /// partitioning (see `find_match`). Same-id items are then ordered by location, change-kind,
339    /// relation and entry-mode - a total order over each item's observable identity - so that
340    /// matching is deterministic and independent of the order in which changes were pushed, which
341    /// the parallel dirwalk and index-traversal threads leave nondeterministic
342    /// (see <https://github.com/GitoxideLabs/gitoxide/issues/1832>). Any items that still compare
343    /// equal are identical in every observable field and are thus interchangeable for matching.
344    fn sort_items_by_id_and_location(&mut self) {
345        self.items.sort_by(|a, b| {
346            a.change
347                .id()
348                .cmp(b.change.id())
349                .then_with(|| a.location(&self.path_backing).cmp(b.location(&self.path_backing)))
350                .then_with(|| a.change.kind().cmp(&b.change.kind()))
351                .then_with(|| a.change.relation().cmp(&b.change.relation()))
352                .then_with(|| a.change.entry_mode().cmp(&b.change.entry_mode()))
353        });
354    }
355
356    #[expect(clippy::too_many_arguments)]
357    fn match_pairs_of_kind(
358        &mut self,
359        kind: visit::SourceKind,
360        cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
361        percentage: Option<f32>,
362        out: &mut Outcome,
363        diff_cache: &mut crate::blob::Platform,
364        objects: &impl gix_object::FindObjectOrHeader,
365        filter: Option<fn(&T) -> bool>,
366    ) -> Result {
367        // we try to cheaply reduce the set of possibilities first, before possibly looking more exhaustively.
368        let needs_second_pass = !needs_exact_match(percentage);
369
370        // https://github.com/git/git/blob/cc01bad4a9f566cf4453c7edd6b433851b0835e2/diffcore-rename.c#L350-L369
371        // We would need a hashmap to be OK to not use the limit here, otherwise the performance is too bad.
372        // This also means we don't find all renames if we hit the rename limit.
373        if self
374            .match_pairs(cb, None /* by identity */, kind, out, diff_cache, objects, filter)?
375            .is_break()
376        {
377            return Ok(());
378        }
379        if needs_second_pass {
380            let is_limited = if self.rewrites.limit == 0 {
381                false
382            } else {
383                let (num_src, num_dst) =
384                    estimate_involved_items(self.items.iter().map(|item| (item.emitted, item.change.kind())), kind);
385                if limit_is_exceeded(num_src, num_dst, self.rewrites.limit) {
386                    let permutations = num_src.saturating_mul(num_dst);
387                    match kind {
388                        visit::SourceKind::Rename => {
389                            out.num_similarity_checks_skipped_for_rename_tracking_due_to_limit = permutations;
390                        }
391                        visit::SourceKind::Copy => {
392                            out.num_similarity_checks_skipped_for_copy_tracking_due_to_limit = permutations;
393                        }
394                    }
395                    true
396                } else {
397                    false
398                }
399            };
400            if !is_limited {
401                let _ = self.match_pairs(cb, percentage, kind, out, diff_cache, objects, None)?;
402            }
403        }
404        Ok(())
405    }
406
407    #[expect(clippy::too_many_arguments)]
408    fn match_pairs(
409        &mut self,
410        cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
411        percentage: Option<f32>,
412        kind: visit::SourceKind,
413        stats: &mut Outcome,
414        diff_cache: &mut crate::blob::Platform,
415        objects: &impl gix_object::FindObjectOrHeader,
416        filter: Option<fn(&T) -> bool>,
417    ) -> Result<Action> {
418        let mut dest_ofs = 0;
419        let mut num_checks = 0;
420        let max_checks = {
421            let limit = self.rewrites.limit.saturating_pow(2);
422            // There can be trees with a lot of entries and pathological search behaviour, as they can be repeated
423            // and then have a lot of similar hashes. This also means we have to search a lot of candidates which
424            // can be too slow despite best attempts. So play it save and detect such cases 'roughly' by amount of items.
425            if self.items.len() < 100_000 { 0 } else { limit }
426        };
427        // Items only stop being possible sources while matching, so this is a superset of the sources for all
428        // destinations. Destinations then only need to be compared to these instead of to all items, which matters
429        // when there are many more destinations than sources.
430        let source_candidates: Vec<usize> = self
431            .items
432            .iter()
433            .enumerate()
434            .filter_map(|(idx, item)| item.is_source_candidate(kind).then_some(idx))
435            .collect();
436        if source_candidates.is_empty() {
437            return Ok(std::ops::ControlFlow::Continue(()));
438        }
439
440        while let Some((mut dest_idx, dest)) = self.items[dest_ofs..].iter().enumerate().find_map(|(idx, item)| {
441            (!item.emitted
442                && matches!(item.change.kind(), ChangeKind::Addition)
443                && filter.map_or_else(
444                    || {
445                        self.rewrites.track_empty
446                            // We always want to keep track of entries that are involved of a directory rename.
447                            // Note that this may still match them up arbitrarily if empty, but empty is empty.
448                            || matches!(item.change.relation(), Some(Relation::ChildOfParent(_)))
449                            || {
450                                let id = item.change.id();
451                                id != gix_hash::ObjectId::empty_blob(id.kind())
452                            }
453                    },
454                    |f| f(&item.change),
455                ))
456            .then_some((idx, item))
457        }) {
458            dest_idx += dest_ofs;
459            dest_ofs = dest_idx + 1;
460            self.items[dest_idx].location(&self.path_backing);
461            let src = find_match(
462                &self.items,
463                &source_candidates,
464                dest,
465                dest_idx,
466                percentage,
467                kind,
468                stats,
469                objects,
470                diff_cache,
471                &self.path_backing,
472                &mut num_checks,
473            )?
474            .map(|(src_idx, src, diff)| {
475                let (id, entry_mode) = src.change.id_and_entry_mode();
476                let id = id.to_owned();
477                let location = src.location(&self.path_backing);
478                (
479                    visit::Source {
480                        entry_mode,
481                        id,
482                        kind,
483                        location,
484                        change: &src.change,
485                        diff,
486                    },
487                    src_idx,
488                )
489            });
490            if max_checks != 0 && num_checks > max_checks {
491                gix_trace::warn!(
492                    "Cancelled rename matching as there were too many iterations ({num_checks} > {max_checks})"
493                );
494                return Ok(std::ops::ControlFlow::Break(()));
495            }
496            let Some((src, src_idx)) = src else {
497                continue;
498            };
499            let location = dest.location(&self.path_backing);
500            let change = dest.change.clone();
501            let dest = visit::Destination { change, location };
502            let relations = if percentage.is_none() {
503                src.change.relation().zip(dest.change.relation())
504            } else {
505                None
506            };
507            let res = cb(dest, Some(src));
508
509            self.items[dest_idx].emitted = true;
510            self.items[src_idx].emitted = true;
511
512            if res.is_break() {
513                return Ok(std::ops::ControlFlow::Break(()));
514            }
515
516            match relations {
517                Some((Relation::Parent(src), Relation::Parent(dst))) => {
518                    let res = self.emit_child_renames_matching_identity(cb, kind, src, dst)?;
519                    if res.is_break() {
520                        return Ok(std::ops::ControlFlow::Break(()));
521                    }
522                }
523                Some((Relation::ChildOfParent(src), Relation::ChildOfParent(dst))) => {
524                    self.child_renames.insert((src, dst));
525                }
526                _ => {}
527            }
528        }
529        Ok(std::ops::ControlFlow::Continue(()))
530    }
531
532    /// Emit the children of `src_parent_id` and `dst_parent_id` as pairs of exact matches, which are assumed
533    /// as `src` and `dst` were an exact match (so all children have to match exactly).
534    /// Note that we intentionally do not record them as their parents will be emitted, too.
535    fn emit_child_renames_matching_identity(
536        &mut self,
537        cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
538        kind: visit::SourceKind,
539        src_parent_id: ChangeId,
540        dst_parent_id: ChangeId,
541    ) -> Result<Action> {
542        debug_assert_ne!(
543            src_parent_id, dst_parent_id,
544            "src and destination directories must be distinct"
545        );
546        let (mut src_items, mut dst_items) = (Vec::with_capacity(1), Vec::with_capacity(1));
547        for item in self.items.iter_mut().filter(|item| !item.emitted) {
548            match item.change.relation() {
549                Some(Relation::ChildOfParent(id)) if id == src_parent_id => {
550                    src_items.push((item.change.id().to_owned(), item));
551                }
552                Some(Relation::ChildOfParent(id)) if id == dst_parent_id => {
553                    dst_items.push((item.change.id().to_owned(), item));
554                }
555                _ => continue,
556            }
557        }
558
559        for ((src_id, src_item), (dst_id, dst_item)) in src_items.into_iter().zip(dst_items) {
560            // Since the parent items are already identical by ID, we know that the children will also match, we just
561            // double-check to still have a chance to be correct in case some of that goes wrong.
562            if src_id == dst_id
563                && filename(src_item.location(&self.path_backing)) == filename(dst_item.location(&self.path_backing))
564            {
565                let entry_mode = src_item.change.entry_mode();
566                let location = src_item.location(&self.path_backing);
567                let src = visit::Source {
568                    entry_mode,
569                    id: src_id,
570                    kind,
571                    location,
572                    change: &src_item.change,
573                    diff: None,
574                };
575                let location = dst_item.location(&self.path_backing);
576                let change = dst_item.change.clone();
577                let dst = visit::Destination { change, location };
578                let res = cb(dst, Some(src));
579
580                src_item.emitted = true;
581                dst_item.emitted = true;
582
583                if res.is_break() {
584                    return Ok(res);
585                }
586            } else {
587                gix_trace::warn!(
588                    "Children of parents with change-id {src_parent_id} and {dst_parent_id} were not equal, even though their parents claimed to be"
589                );
590                break;
591            }
592        }
593        Ok(std::ops::ControlFlow::Continue(()))
594    }
595
596    /// Find directories with relation id that haven't been emitted yet and store them for lookup.
597    /// Then use the previously stored emitted renames with relation id to learn which directories they 'link'
598    /// and emit them, too.
599    /// Note that this works whenever top-level directories are renamed because they are always added and deleted,
600    /// and we only match those. Thus, one rewrite inside the directory is enough.
601    fn match_renamed_directories(
602        &mut self,
603        cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
604    ) -> Result {
605        fn unemitted_directory_matching_relation_id<T: Change>(items: &[Item<T>], child_id: ChangeId) -> Option<usize> {
606            items.iter().position(|i| {
607                !i.emitted && matches!(i.change.relation(), Some(Relation::Parent(pid)) if pid == child_id)
608            })
609        }
610        for (deleted_child_id, added_child_id) in &self.child_renames {
611            let Some(src_idx) = unemitted_directory_matching_relation_id(&self.items, *deleted_child_id) else {
612                continue;
613            };
614            let Some(dst_idx) = unemitted_directory_matching_relation_id(&self.items, *added_child_id) else {
615                // This could go wrong in case there are mismatches, so be defensive here.
616                // But generally, we'd expect the destination item to exist.
617                continue;
618            };
619
620            let (src_item, dst_item) = (&self.items[src_idx], &self.items[dst_idx]);
621            let entry_mode = src_item.change.entry_mode();
622            let location = src_item.location(&self.path_backing);
623            let src = visit::Source {
624                entry_mode,
625                id: src_item.change.id().to_owned(),
626                kind: SourceKind::Rename,
627                location,
628                change: &src_item.change,
629                diff: None,
630            };
631            let location = dst_item.location(&self.path_backing);
632            let change = dst_item.change.clone();
633            let dst = visit::Destination { change, location };
634            let res = cb(dst, Some(src));
635
636            self.items[src_idx].emitted = true;
637            self.items[dst_idx].emitted = true;
638
639            if res.is_break() {
640                return Ok(());
641            }
642        }
643        Ok(())
644    }
645}
646
647fn filename(path: &BStr) -> &BStr {
648    path.rfind_byte(b'/').map_or(path, |idx| path[idx + 1..].as_bstr())
649}
650
651/// Returns the amount of viable sources and destinations for `items` as eligible for the given `kind` of operation.
652fn estimate_involved_items(
653    items: impl IntoIterator<Item = (bool, ChangeKind)>,
654    kind: visit::SourceKind,
655) -> (usize, usize) {
656    items
657        .into_iter()
658        .filter(|(emitted, _)| match kind {
659            visit::SourceKind::Rename => !*emitted,
660            visit::SourceKind::Copy => true,
661        })
662        .fold((0, 0), |(mut src, mut dest), (emitted, change_kind)| {
663            match change_kind {
664                ChangeKind::Addition => {
665                    if kind == visit::SourceKind::Rename || !emitted {
666                        dest += 1;
667                    }
668                }
669                ChangeKind::Deletion => {
670                    if kind == visit::SourceKind::Rename {
671                        src += 1;
672                    }
673                }
674                ChangeKind::Modification => {
675                    if kind == visit::SourceKind::Copy {
676                        src += 1;
677                    }
678                }
679            }
680            (src, dest)
681        })
682}
683
684fn limit_is_exceeded(num_src: usize, num_dst: usize, limit: usize) -> bool {
685    // Saturating both products can hide an exceeded limit; u128 holds them exactly on supported targets.
686    let limit = limit as u128;
687    (num_src as u128) * (num_dst as u128) > limit * limit
688}
689
690fn needs_exact_match(percentage: Option<f32>) -> bool {
691    percentage.is_none_or(|p| p >= 1.0)
692}
693
694/// <`src_idx`, src, possibly diff stat>
695type SourceTuple<'a, T> = (usize, &'a Item<T>, Option<DiffLineStats>);
696
697/// Find `item` in our set of items ignoring `item_idx` to avoid finding ourselves, by similarity indicated by `percentage`.
698/// The latter can be `None` or `Some(x)` where `x>=1` for identity, and anything else for similarity.
699/// We also ignore emitted items entirely.
700/// Use `kind` to indicate what kind of match we are looking for, which might be deletions matching an `item` addition, or
701/// any non-deletion otherwise.
702/// Note that we always try to find by identity first even if a percentage is given as it's much faster and may reduce the set
703/// of items to be searched.
704#[expect(clippy::too_many_arguments)]
705fn find_match<'a, T: Change>(
706    items: &'a [Item<T>],
707    source_candidates: &[usize],
708    item: &Item<T>,
709    item_idx: usize,
710    percentage: Option<f32>,
711    kind: visit::SourceKind,
712    stats: &mut Outcome,
713    objects: &impl gix_object::FindObjectOrHeader,
714    diff_cache: &mut crate::blob::Platform,
715    path_backing: &[u8],
716    num_checks: &mut usize,
717) -> Result<Option<SourceTuple<'a, T>>> {
718    let (item_id, item_mode) = item.change.id_and_entry_mode();
719    // Symlinks and gitlinks only participate in exact-ID matching; neither has meaningful blob similarity here.
720    if needs_exact_match(percentage) || item_mode.is_link() || item_mode.is_commit() {
721        // Items are sorted by id, so the candidates with the same id are a contiguous range of `source_candidates`.
722        let start = source_candidates.partition_point(|&idx| items[idx].change.id() < item_id);
723        let end = source_candidates.partition_point(|&idx| items[idx].change.id() <= item_id);
724        if start == end {
725            return Ok(None);
726        }
727        let item_name = filename(item.location(path_backing));
728        let mut fallback = None;
729        for &src_idx in &source_candidates[start..end] {
730            let src = &items[src_idx];
731            *num_checks += 1;
732            if src_idx == item_idx || !src.is_source_for_destination_of(kind, item_mode) {
733                continue;
734            }
735            // Like Git, prefer a source whose file name matches the destination's to keep
736            // renames of equally-named files together when contents are identical.
737            if filename(src.location(path_backing)) == item_name {
738                return Ok(Some((src_idx, src, None)));
739            }
740            fallback.get_or_insert((src_idx, src, None));
741        }
742        if fallback.is_some() {
743            return Ok(fallback);
744        }
745    } else if item_mode.is_blob() {
746        let mut has_new = false;
747        let percentage = percentage.expect("it's set to something below 1.0 and we assured this");
748        let item_name = filename(item.location(path_backing));
749
750        // Like Git's inexact rename matrix, choose by similarity score first and use basename as
751        // a tie-breaker only.
752        let mut best: Option<(usize, &Item<T>, DiffLineStats, bool)> = None;
753        for (can_idx, src) in source_candidates
754            .iter()
755            .map(|&src_idx| (src_idx, &items[src_idx]))
756            .filter(|(src_idx, src)| *src_idx != item_idx && src.is_source_for_destination_of(kind, item_mode))
757        {
758            if !has_new {
759                diff_cache
760                    .set_resource(
761                        item_id.to_owned(),
762                        item_mode.kind(),
763                        item.location(path_backing),
764                        ResourceKind::NewOrDestination,
765                        objects,
766                    )
767                    .or_raise(|| message("Could not set destination for similarity checking"))?;
768                has_new = true;
769            }
770            let (src_id, src_mode) = src.change.id_and_entry_mode();
771            diff_cache
772                .set_resource(
773                    src_id.to_owned(),
774                    src_mode.kind(),
775                    src.location(path_backing),
776                    ResourceKind::OldOrSource,
777                    objects,
778                )
779                .or_raise(|| message("Could not set source for similarity checking"))?;
780            let prep = diff_cache
781                .prepare_diff()
782                .or_raise(|| message("Could not prepare resources for similarity checking"))?;
783            stats.num_similarity_checks += 1;
784            *num_checks += 1;
785            match prep.operation {
786                Operation::InternalDiff { algorithm } => {
787                    let tokens = crate::blob::InternedInput::new(prep.old.intern_source(), prep.new.intern_source());
788                    let diff = crate::blob::Diff::compute(algorithm, &tokens);
789                    let removed_bytes = diff::removed_bytes(&diff, &tokens);
790                    let old_data_len = prep.old.data.as_slice().unwrap_or_default().len();
791                    let new_data_len = prep.new.data.as_slice().unwrap_or_default().len();
792                    let similarity = (old_data_len - removed_bytes) as f32 / old_data_len.max(new_data_len) as f32;
793                    if similarity >= percentage {
794                        let candidate_diff = DiffLineStats {
795                            removals: diff.count_removals(),
796                            insertions: diff.count_additions(),
797                            before: tokens.before.len(),
798                            after: tokens.after.len(),
799                            similarity,
800                        };
801                        let has_same_filename = filename(src.location(path_backing)) == item_name;
802                        let is_better =
803                            best.as_ref()
804                                .is_none_or(|(_, _, best_diff, best_has_same_filename)| {
805                                    match candidate_diff.similarity.total_cmp(&best_diff.similarity) {
806                                        std::cmp::Ordering::Greater => true,
807                                        std::cmp::Ordering::Equal => has_same_filename && !best_has_same_filename,
808                                        std::cmp::Ordering::Less => false,
809                                    }
810                                });
811                        if is_better {
812                            best = Some((can_idx, src, candidate_diff, has_same_filename));
813                        }
814                    }
815                }
816                Operation::ExternalCommand { .. } => {
817                    unreachable!("we have disabled this possibility with an option")
818                }
819                Operation::SourceOrDestinationIsBinary => {
820                    // TODO: figure out if git does more here
821                }
822            }
823        }
824        return Ok(best.map(|(candidate_idx, src, diff, _)| (candidate_idx, src, Some(diff))));
825    }
826    Ok(None)
827}
828
829mod diff {
830    pub fn removed_bytes(diff: &crate::blob::Diff, input: &crate::blob::InternedInput<&[u8]>) -> usize {
831        diff.hunks()
832            .map(|hunk| {
833                input.before[hunk.before.start as usize..hunk.before.end as usize]
834                    .iter()
835                    .map(|token| input.interner[*token].len())
836                    .sum::<usize>()
837            })
838            .sum()
839    }
840}
841
842#[cfg(test)]
843mod limit_is_exceeded {
844    use super::limit_is_exceeded;
845
846    #[test]
847    fn overflowing_products_are_compared_without_saturation() {
848        for limit in [70_000, usize::MAX / 2] {
849            assert!(
850                limit_is_exceeded(limit + 1, limit + 1, limit),
851                "candidate counts just above the limit must be rejected even when both products overflow usize"
852            );
853            assert!(
854                !limit_is_exceeded(limit, limit, limit),
855                "a product equal to the squared limit is allowed"
856            );
857            assert!(
858                !limit_is_exceeded(limit - 1, limit, limit),
859                "a product below the squared limit is allowed even when both products overflow usize"
860            );
861        }
862    }
863
864    #[test]
865    fn maximum_counts_and_unbalanced_candidates() {
866        assert!(
867            !limit_is_exceeded(usize::MAX, usize::MAX, usize::MAX),
868            "squaring the largest supported count must not overflow the comparison"
869        );
870        assert!(
871            !limit_is_exceeded(0, usize::MAX, 1),
872            "without sources there are no comparisons to limit"
873        );
874        assert!(
875            limit_is_exceeded(usize::MAX, 1, 1),
876            "the product, not the smaller candidate count, determines whether the limit is exceeded"
877        );
878    }
879}
880
881#[cfg(test)]
882mod estimate_involved_items {
883    use super::estimate_involved_items;
884    use crate::rewrites::tracker::{ChangeKind, visit::SourceKind};
885
886    #[test]
887    fn renames_count_unemitted_as_sources_and_destinations() {
888        let items = [
889            (false, ChangeKind::Addition),
890            (true, ChangeKind::Deletion),
891            (true, ChangeKind::Deletion),
892        ];
893        assert_eq!(
894            estimate_involved_items(items, SourceKind::Rename),
895            (0, 1),
896            "here we only have one eligible source, hence nothing to do"
897        );
898        assert_eq!(
899            estimate_involved_items(items.into_iter().map(|t| (false, t.1)), SourceKind::Rename),
900            (2, 1),
901            "now we have more possibilities as renames count un-emitted deletions as source"
902        );
903    }
904
905    #[test]
906    fn copies_do_not_count_additions_as_sources() {
907        let items = [
908            (false, ChangeKind::Addition),
909            (true, ChangeKind::Addition),
910            (true, ChangeKind::Deletion),
911        ];
912        assert_eq!(
913            estimate_involved_items(items, SourceKind::Copy),
914            (0, 1),
915            "one addition as source, the other isn't counted as it's emitted, nor is it considered a copy-source.\
916            deletions don't count"
917        );
918    }
919
920    #[test]
921    fn copies_count_modifications_as_sources() {
922        let items = [
923            (false, ChangeKind::Addition),
924            (true, ChangeKind::Modification),
925            (false, ChangeKind::Modification),
926        ];
927        assert_eq!(
928            estimate_involved_items(items, SourceKind::Copy),
929            (2, 1),
930            "any modifications is a valid source, emitted or not"
931        );
932    }
933}