1use 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#[derive(Debug, Copy, Clone, Ord, PartialOrd, PartialEq, Eq)]
26pub enum ChangeKind {
27 Deletion,
29 Modification,
31 Addition,
33}
34
35pub trait Change: Clone {
37 fn id(&self) -> &gix_hash::oid;
42 fn relation(&self) -> Option<Relation>;
53 fn kind(&self) -> ChangeKind;
55 fn entry_mode(&self) -> EntryMode;
57 fn id_and_entry_mode(&self) -> (&gix_hash::oid, EntryMode);
59}
60
61pub(crate) struct Item<T> {
63 change: T,
65 path: Range<usize>,
67 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
94pub mod visit {
96 use bstr::BStr;
97 use gix_object::tree::EntryMode;
98
99 use crate::blob::DiffLineStats;
100
101 #[derive(Debug, Clone, PartialEq, PartialOrd)]
103 pub struct Source<'a, T> {
104 pub entry_mode: EntryMode,
106 pub id: gix_hash::ObjectId,
108 pub kind: SourceKind,
110 pub location: &'a BStr,
112 pub change: &'a T,
114 pub diff: Option<DiffLineStats>,
116 }
117
118 #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
120 pub enum SourceKind {
121 Rename,
123 Copy,
125 }
126
127 #[derive(Debug, Clone)]
129 pub struct Destination<'a, T: Clone> {
130 pub change: T,
132 pub location: &'a BStr,
134 }
135}
136
137impl<T: Change> Tracker<T> {
139 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
150impl<T: Change> Tracker<T> {
152 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 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 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 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 self.match_pairs_of_kind(
253 visit::SourceKind::Rename,
254 &mut cb,
255 None, &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 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 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 let needs_second_pass = !needs_exact_match(percentage);
367
368 if self
372 .match_pairs(cb, None , 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 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 || 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 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 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 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 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
636fn 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
673type SourceTuple<'a, T> = (usize, &'a Item<T>, Option<DiffLineStats>);
675
676#[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 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 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 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 }
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}