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