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