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