1use gix_error::{Result, message};
12use std::ops::Range;
13
14use bstr::{BStr, ByteSlice};
15use gix_error::ResultExt;
16use gix_object::tree::{EntryKind, EntryMode};
17
18use crate::{
19 Rewrites,
20 blob::{DiffLineStats, ResourceKind, platform::prepare_diff::Operation},
21 rewrites::{CopySource, Outcome, Tracker, tracker::visit::SourceKind},
22 tree::visit::{Action, ChangeId, Relation},
23};
24
25#[derive(Debug, Copy, Clone, Ord, PartialOrd, PartialEq, Eq)]
27pub enum ChangeKind {
28 Deletion,
30 Modification,
32 Addition,
34}
35
36pub trait Change: Clone {
38 fn id(&self) -> &gix_hash::oid;
43 fn relation(&self) -> Option<Relation>;
54 fn kind(&self) -> ChangeKind;
56 fn entry_mode(&self) -> EntryMode;
58 fn id_and_entry_mode(&self) -> (&gix_hash::oid, EntryMode);
60}
61
62pub(crate) struct Item<T> {
64 change: T,
66 path: Range<usize>,
68 emitted: bool,
70}
71
72impl<T: Change> Item<T> {
73 fn location<'a>(&self, backing: &'a [u8]) -> &'a BStr {
74 backing[self.path.clone()].as_ref()
75 }
76 fn entry_mode_compatible(&self, other: EntryMode) -> bool {
77 use EntryKind::*;
78 matches!(
79 (other.kind(), self.change.entry_mode().kind()),
80 (Blob | BlobExecutable, Blob | BlobExecutable) | (Link, Link) | (Tree, Tree) | (Commit, Commit)
81 )
82 }
83
84 fn is_source_for_destination_of(&self, kind: visit::SourceKind, dest_item_mode: EntryMode) -> bool {
85 self.entry_mode_compatible(dest_item_mode)
86 && match kind {
87 visit::SourceKind::Rename => !self.emitted && matches!(self.change.kind(), ChangeKind::Deletion),
88 visit::SourceKind::Copy => {
89 matches!(self.change.kind(), ChangeKind::Modification)
90 }
91 }
92 }
93}
94
95pub mod visit {
97 use bstr::BStr;
98 use gix_object::tree::EntryMode;
99
100 use crate::blob::DiffLineStats;
101
102 #[derive(Debug, Clone, PartialEq, PartialOrd)]
104 pub struct Source<'a, T> {
105 pub entry_mode: EntryMode,
107 pub id: gix_hash::ObjectId,
109 pub kind: SourceKind,
111 pub location: &'a BStr,
113 pub change: &'a T,
115 pub diff: Option<DiffLineStats>,
117 }
118
119 #[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
121 pub enum SourceKind {
122 Rename,
124 Copy,
126 }
127
128 #[derive(Debug, Clone)]
130 pub struct Destination<'a, T: Clone> {
131 pub change: T,
133 pub location: &'a BStr,
135 }
136}
137
138impl<T: Change> Tracker<T> {
140 pub fn new(rewrites: Rewrites) -> Self {
142 Tracker {
143 items: vec![],
144 path_backing: vec![],
145 rewrites,
146 child_renames: Default::default(),
147 }
148 }
149}
150
151impl<T: Change> Tracker<T> {
153 pub fn try_push_change(&mut self, change: T, location: &BStr) -> Option<T> {
155 let change_kind = change.kind();
156 if let (None, ChangeKind::Modification) = (self.rewrites.copies, change_kind) {
157 return Some(change);
158 }
159
160 let entry_kind = change.entry_mode().kind();
161 let relation = change
162 .relation()
163 .filter(|_| matches!(change_kind, ChangeKind::Addition | ChangeKind::Deletion));
164 if let (None, EntryKind::Tree) = (relation, entry_kind) {
165 return Some(change);
166 }
167
168 let start = self.path_backing.len();
169 self.path_backing.extend_from_slice(location);
170 let path = start..self.path_backing.len();
171
172 self.items.push(Item {
173 path,
174 change,
175 emitted: false,
176 });
177 None
178 }
179
180 pub fn emit<PushSourceTreeFn, E>(
203 &mut self,
204 mut cb: impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
205 diff_cache: &mut crate::blob::Platform,
206 objects: &impl gix_object::FindObjectOrHeader,
207 mut push_source_tree: PushSourceTreeFn,
208 ) -> Result<Outcome>
209 where
210 PushSourceTreeFn: FnMut(&mut dyn FnMut(T, &BStr)) -> std::result::Result<(), E>,
211 E: std::error::Error + Send + Sync + 'static,
212 {
213 fn is_parent(change: &impl Change) -> bool {
214 matches!(change.relation(), Some(Relation::Parent(_)))
215 }
216 diff_cache.options.skip_internal_diff_if_external_is_configured = false;
217
218 let has_work = {
220 let (mut num_deletions, mut num_additions, mut num_modifications) = (0, 0, 0);
221 let mut has_work = false;
222 for change in &self.items {
223 match change.change.kind() {
224 ChangeKind::Deletion => {
225 num_deletions += 1;
226 }
227 ChangeKind::Modification => {
228 num_modifications += 1;
230 }
231 ChangeKind::Addition => num_additions += 1,
232 }
233 if (num_deletions != 0 && num_additions != 0)
234 || (self.rewrites.copies.is_some() && num_modifications + num_additions > 1)
235 {
236 has_work = true;
237 break;
238 }
239 }
240 has_work
241 };
242
243 let mut out = Outcome {
244 options: self.rewrites,
245 ..Default::default()
246 };
247 if has_work {
248 self.sort_items_by_id_and_location();
249
250 self.match_pairs_of_kind(
254 visit::SourceKind::Rename,
255 &mut cb,
256 None, &mut out,
258 diff_cache,
259 objects,
260 Some(is_parent),
261 )?;
262
263 self.match_pairs_of_kind(
264 visit::SourceKind::Rename,
265 &mut cb,
266 self.rewrites.percentage,
267 &mut out,
268 diff_cache,
269 objects,
270 None,
271 )?;
272
273 self.match_renamed_directories(&mut cb)?;
274
275 if let Some(copies) = self.rewrites.copies {
276 self.match_pairs_of_kind(
277 visit::SourceKind::Copy,
278 &mut cb,
279 copies.percentage,
280 &mut out,
281 diff_cache,
282 objects,
283 None,
284 )?;
285
286 match copies.source {
287 CopySource::FromSetOfModifiedFiles => {}
288 CopySource::FromSetOfModifiedFilesAndAllSources => {
289 push_source_tree(&mut |change, location| {
290 if self.try_push_change(change, location).is_none() {
291 self.items.last_mut().expect("just pushed").emitted = true;
293 }
294 })
295 .or_raise(|| {
296 message(
297 "Could not obtain exhaustive item set to use as possible sources for copy detection",
298 )
299 })?;
300 self.sort_items_by_id_and_location();
301
302 self.match_pairs_of_kind(
303 visit::SourceKind::Copy,
304 &mut cb,
305 copies.percentage,
306 &mut out,
307 diff_cache,
308 objects,
309 None,
310 )?;
311 }
312 }
313 }
314 }
315
316 self.items
317 .sort_by(|a, b| a.location(&self.path_backing).cmp(b.location(&self.path_backing)));
318 for item in self.items.drain(..).filter(|item| !item.emitted) {
319 if cb(
320 visit::Destination {
321 location: item.location(&self.path_backing),
322 change: item.change,
323 },
324 None,
325 )
326 .is_break()
327 {
328 break;
329 }
330 }
331 Ok(out)
332 }
333}
334
335impl<T: Change> Tracker<T> {
336 fn sort_items_by_id_and_location(&mut self) {
344 self.items.sort_by(|a, b| {
345 a.change
346 .id()
347 .cmp(b.change.id())
348 .then_with(|| a.location(&self.path_backing).cmp(b.location(&self.path_backing)))
349 .then_with(|| a.change.kind().cmp(&b.change.kind()))
350 .then_with(|| a.change.relation().cmp(&b.change.relation()))
351 .then_with(|| a.change.entry_mode().cmp(&b.change.entry_mode()))
352 });
353 }
354
355 #[expect(clippy::too_many_arguments)]
356 fn match_pairs_of_kind(
357 &mut self,
358 kind: visit::SourceKind,
359 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
360 percentage: Option<f32>,
361 out: &mut Outcome,
362 diff_cache: &mut crate::blob::Platform,
363 objects: &impl gix_object::FindObjectOrHeader,
364 filter: Option<fn(&T) -> bool>,
365 ) -> Result {
366 let needs_second_pass = !needs_exact_match(percentage);
368
369 if self
373 .match_pairs(cb, None , kind, out, diff_cache, objects, filter)?
374 .is_break()
375 {
376 return Ok(());
377 }
378 if needs_second_pass {
379 let is_limited = if self.rewrites.limit == 0 {
380 false
381 } else {
382 let (num_src, num_dst) =
383 estimate_involved_items(self.items.iter().map(|item| (item.emitted, item.change.kind())), kind);
384 let permutations = num_src * num_dst;
385 if permutations > self.rewrites.limit {
386 match kind {
387 visit::SourceKind::Rename => {
388 out.num_similarity_checks_skipped_for_rename_tracking_due_to_limit = permutations;
389 }
390 visit::SourceKind::Copy => {
391 out.num_similarity_checks_skipped_for_copy_tracking_due_to_limit = permutations;
392 }
393 }
394 true
395 } else {
396 false
397 }
398 };
399 if !is_limited {
400 let _ = self.match_pairs(cb, percentage, kind, out, diff_cache, objects, None)?;
401 }
402 }
403 Ok(())
404 }
405
406 #[expect(clippy::too_many_arguments)]
407 fn match_pairs(
408 &mut self,
409 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
410 percentage: Option<f32>,
411 kind: visit::SourceKind,
412 stats: &mut Outcome,
413 diff_cache: &mut crate::blob::Platform,
414 objects: &impl gix_object::FindObjectOrHeader,
415 filter: Option<fn(&T) -> bool>,
416 ) -> Result<Action> {
417 let mut dest_ofs = 0;
418 let mut num_checks = 0;
419 let max_checks = {
420 let limit = self.rewrites.limit.saturating_pow(2);
421 if self.items.len() < 100_000 { 0 } else { limit }
425 };
426
427 while let Some((mut dest_idx, dest)) = self.items[dest_ofs..].iter().enumerate().find_map(|(idx, item)| {
428 (!item.emitted
429 && matches!(item.change.kind(), ChangeKind::Addition)
430 && filter.map_or_else(
431 || {
432 self.rewrites.track_empty
433 || matches!(item.change.relation(), Some(Relation::ChildOfParent(_)))
436 || {
437 let id = item.change.id();
438 id != gix_hash::ObjectId::empty_blob(id.kind())
439 }
440 },
441 |f| f(&item.change),
442 ))
443 .then_some((idx, item))
444 }) {
445 dest_idx += dest_ofs;
446 dest_ofs = dest_idx + 1;
447 self.items[dest_idx].location(&self.path_backing);
448 let src = find_match(
449 &self.items,
450 dest,
451 dest_idx,
452 percentage,
453 kind,
454 stats,
455 objects,
456 diff_cache,
457 &self.path_backing,
458 &mut num_checks,
459 )?
460 .map(|(src_idx, src, diff)| {
461 let (id, entry_mode) = src.change.id_and_entry_mode();
462 let id = id.to_owned();
463 let location = src.location(&self.path_backing);
464 (
465 visit::Source {
466 entry_mode,
467 id,
468 kind,
469 location,
470 change: &src.change,
471 diff,
472 },
473 src_idx,
474 )
475 });
476 if max_checks != 0 && num_checks > max_checks {
477 gix_trace::warn!(
478 "Cancelled rename matching as there were too many iterations ({num_checks} > {max_checks})"
479 );
480 return Ok(std::ops::ControlFlow::Break(()));
481 }
482 let Some((src, src_idx)) = src else {
483 continue;
484 };
485 let location = dest.location(&self.path_backing);
486 let change = dest.change.clone();
487 let dest = visit::Destination { change, location };
488 let relations = if percentage.is_none() {
489 src.change.relation().zip(dest.change.relation())
490 } else {
491 None
492 };
493 let res = cb(dest, Some(src));
494
495 self.items[dest_idx].emitted = true;
496 self.items[src_idx].emitted = true;
497
498 if res.is_break() {
499 return Ok(std::ops::ControlFlow::Break(()));
500 }
501
502 match relations {
503 Some((Relation::Parent(src), Relation::Parent(dst))) => {
504 let res = self.emit_child_renames_matching_identity(cb, kind, src, dst)?;
505 if res.is_break() {
506 return Ok(std::ops::ControlFlow::Break(()));
507 }
508 }
509 Some((Relation::ChildOfParent(src), Relation::ChildOfParent(dst))) => {
510 self.child_renames.insert((src, dst));
511 }
512 _ => {}
513 }
514 }
515 Ok(std::ops::ControlFlow::Continue(()))
516 }
517
518 fn emit_child_renames_matching_identity(
522 &mut self,
523 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
524 kind: visit::SourceKind,
525 src_parent_id: ChangeId,
526 dst_parent_id: ChangeId,
527 ) -> Result<Action> {
528 debug_assert_ne!(
529 src_parent_id, dst_parent_id,
530 "src and destination directories must be distinct"
531 );
532 let (mut src_items, mut dst_items) = (Vec::with_capacity(1), Vec::with_capacity(1));
533 for item in self.items.iter_mut().filter(|item| !item.emitted) {
534 match item.change.relation() {
535 Some(Relation::ChildOfParent(id)) if id == src_parent_id => {
536 src_items.push((item.change.id().to_owned(), item));
537 }
538 Some(Relation::ChildOfParent(id)) if id == dst_parent_id => {
539 dst_items.push((item.change.id().to_owned(), item));
540 }
541 _ => continue,
542 }
543 }
544
545 for ((src_id, src_item), (dst_id, dst_item)) in src_items.into_iter().zip(dst_items) {
546 if src_id == dst_id
549 && filename(src_item.location(&self.path_backing)) == filename(dst_item.location(&self.path_backing))
550 {
551 let entry_mode = src_item.change.entry_mode();
552 let location = src_item.location(&self.path_backing);
553 let src = visit::Source {
554 entry_mode,
555 id: src_id,
556 kind,
557 location,
558 change: &src_item.change,
559 diff: None,
560 };
561 let location = dst_item.location(&self.path_backing);
562 let change = dst_item.change.clone();
563 let dst = visit::Destination { change, location };
564 let res = cb(dst, Some(src));
565
566 src_item.emitted = true;
567 dst_item.emitted = true;
568
569 if res.is_break() {
570 return Ok(res);
571 }
572 } else {
573 gix_trace::warn!(
574 "Children of parents with change-id {src_parent_id} and {dst_parent_id} were not equal, even though their parents claimed to be"
575 );
576 break;
577 }
578 }
579 Ok(std::ops::ControlFlow::Continue(()))
580 }
581
582 fn match_renamed_directories(
588 &mut self,
589 cb: &mut impl FnMut(visit::Destination<'_, T>, Option<visit::Source<'_, T>>) -> Action,
590 ) -> Result {
591 fn unemitted_directory_matching_relation_id<T: Change>(items: &[Item<T>], child_id: ChangeId) -> Option<usize> {
592 items.iter().position(|i| {
593 !i.emitted && matches!(i.change.relation(), Some(Relation::Parent(pid)) if pid == child_id)
594 })
595 }
596 for (deleted_child_id, added_child_id) in &self.child_renames {
597 let Some(src_idx) = unemitted_directory_matching_relation_id(&self.items, *deleted_child_id) else {
598 continue;
599 };
600 let Some(dst_idx) = unemitted_directory_matching_relation_id(&self.items, *added_child_id) else {
601 continue;
604 };
605
606 let (src_item, dst_item) = (&self.items[src_idx], &self.items[dst_idx]);
607 let entry_mode = src_item.change.entry_mode();
608 let location = src_item.location(&self.path_backing);
609 let src = visit::Source {
610 entry_mode,
611 id: src_item.change.id().to_owned(),
612 kind: SourceKind::Rename,
613 location,
614 change: &src_item.change,
615 diff: None,
616 };
617 let location = dst_item.location(&self.path_backing);
618 let change = dst_item.change.clone();
619 let dst = visit::Destination { change, location };
620 let res = cb(dst, Some(src));
621
622 self.items[src_idx].emitted = true;
623 self.items[dst_idx].emitted = true;
624
625 if res.is_break() {
626 return Ok(());
627 }
628 }
629 Ok(())
630 }
631}
632
633fn filename(path: &BStr) -> &BStr {
634 path.rfind_byte(b'/').map_or(path, |idx| path[idx + 1..].as_bstr())
635}
636
637fn estimate_involved_items(
639 items: impl IntoIterator<Item = (bool, ChangeKind)>,
640 kind: visit::SourceKind,
641) -> (usize, usize) {
642 items
643 .into_iter()
644 .filter(|(emitted, _)| match kind {
645 visit::SourceKind::Rename => !*emitted,
646 visit::SourceKind::Copy => true,
647 })
648 .fold((0, 0), |(mut src, mut dest), (emitted, change_kind)| {
649 match change_kind {
650 ChangeKind::Addition => {
651 if kind == visit::SourceKind::Rename || !emitted {
652 dest += 1;
653 }
654 }
655 ChangeKind::Deletion => {
656 if kind == visit::SourceKind::Rename {
657 src += 1;
658 }
659 }
660 ChangeKind::Modification => {
661 if kind == visit::SourceKind::Copy {
662 src += 1;
663 }
664 }
665 }
666 (src, dest)
667 })
668}
669
670fn needs_exact_match(percentage: Option<f32>) -> bool {
671 percentage.is_none_or(|p| p >= 1.0)
672}
673
674type SourceTuple<'a, T> = (usize, &'a Item<T>, Option<DiffLineStats>);
676
677#[expect(clippy::too_many_arguments)]
685fn find_match<'a, T: Change>(
686 items: &'a [Item<T>],
687 item: &Item<T>,
688 item_idx: usize,
689 percentage: Option<f32>,
690 kind: visit::SourceKind,
691 stats: &mut Outcome,
692 objects: &impl gix_object::FindObjectOrHeader,
693 diff_cache: &mut crate::blob::Platform,
694 path_backing: &[u8],
695 num_checks: &mut usize,
696) -> Result<Option<SourceTuple<'a, T>>> {
697 let (item_id, item_mode) = item.change.id_and_entry_mode();
698 if needs_exact_match(percentage) || item_mode.is_link() || item_mode.is_commit() {
700 let first_idx = items.partition_point(|a| a.change.id() < item_id);
701 let range = items.get(first_idx..).map(|slice| {
702 let end = slice
703 .iter()
704 .position(|a| a.change.id() != item_id)
705 .map_or(items.len(), |idx| first_idx + idx);
706 first_idx..end
707 });
708 let range = match range {
709 Some(range) => range,
710 None => return Ok(None),
711 };
712 if range.is_empty() {
713 return Ok(None);
714 }
715 let item_name = filename(item.location(path_backing));
716 let mut fallback = None;
717 for (mut src_idx, src) in items[range.clone()].iter().enumerate() {
718 src_idx += range.start;
719 *num_checks += 1;
720 if src_idx == item_idx || !src.is_source_for_destination_of(kind, item_mode) {
721 continue;
722 }
723 if filename(src.location(path_backing)) == item_name {
726 return Ok(Some((src_idx, src, None)));
727 }
728 fallback.get_or_insert((src_idx, src, None));
729 }
730 if fallback.is_some() {
731 return Ok(fallback);
732 }
733 } else if item_mode.is_blob() {
734 let mut has_new = false;
735 let percentage = percentage.expect("it's set to something below 1.0 and we assured this");
736 let item_name = filename(item.location(path_backing));
737
738 let mut best: Option<(usize, &Item<T>, DiffLineStats, bool)> = None;
741 for (can_idx, src) in items
742 .iter()
743 .enumerate()
744 .filter(|(src_idx, src)| *src_idx != item_idx && src.is_source_for_destination_of(kind, item_mode))
745 {
746 if !has_new {
747 diff_cache
748 .set_resource(
749 item_id.to_owned(),
750 item_mode.kind(),
751 item.location(path_backing),
752 ResourceKind::NewOrDestination,
753 objects,
754 )
755 .or_raise(|| message("Could not set destination for similarity checking"))?;
756 has_new = true;
757 }
758 let (src_id, src_mode) = src.change.id_and_entry_mode();
759 diff_cache
760 .set_resource(
761 src_id.to_owned(),
762 src_mode.kind(),
763 src.location(path_backing),
764 ResourceKind::OldOrSource,
765 objects,
766 )
767 .or_raise(|| message("Could not set source for similarity checking"))?;
768 let prep = diff_cache
769 .prepare_diff()
770 .or_raise(|| message("Could not prepare resources for similarity checking"))?;
771 stats.num_similarity_checks += 1;
772 *num_checks += 1;
773 match prep.operation {
774 Operation::InternalDiff { algorithm } => {
775 let tokens = crate::blob::InternedInput::new(prep.old.intern_source(), prep.new.intern_source());
776 let diff = crate::blob::Diff::compute(algorithm, &tokens);
777 let removed_bytes = diff::removed_bytes(&diff, &tokens);
778 let old_data_len = prep.old.data.as_slice().unwrap_or_default().len();
779 let new_data_len = prep.new.data.as_slice().unwrap_or_default().len();
780 let similarity = (old_data_len - removed_bytes) as f32 / old_data_len.max(new_data_len) as f32;
781 if similarity >= percentage {
782 let candidate_diff = DiffLineStats {
783 removals: diff.count_removals(),
784 insertions: diff.count_additions(),
785 before: tokens.before.len(),
786 after: tokens.after.len(),
787 similarity,
788 };
789 let has_same_filename = filename(src.location(path_backing)) == item_name;
790 let is_better =
791 best.as_ref()
792 .is_none_or(|(_, _, best_diff, best_has_same_filename)| {
793 match candidate_diff.similarity.total_cmp(&best_diff.similarity) {
794 std::cmp::Ordering::Greater => true,
795 std::cmp::Ordering::Equal => has_same_filename && !best_has_same_filename,
796 std::cmp::Ordering::Less => false,
797 }
798 });
799 if is_better {
800 best = Some((can_idx, src, candidate_diff, has_same_filename));
801 }
802 }
803 }
804 Operation::ExternalCommand { .. } => {
805 unreachable!("we have disabled this possibility with an option")
806 }
807 Operation::SourceOrDestinationIsBinary => {
808 }
810 }
811 }
812 return Ok(best.map(|(candidate_idx, src, diff, _)| (candidate_idx, src, Some(diff))));
813 }
814 Ok(None)
815}
816
817mod diff {
818 pub fn removed_bytes(diff: &crate::blob::Diff, input: &crate::blob::InternedInput<&[u8]>) -> usize {
819 diff.hunks()
820 .map(|hunk| {
821 input.before[hunk.before.start as usize..hunk.before.end as usize]
822 .iter()
823 .map(|token| input.interner[*token].len())
824 .sum::<usize>()
825 })
826 .sum()
827 }
828}
829
830#[cfg(test)]
831mod estimate_involved_items {
832 use super::estimate_involved_items;
833 use crate::rewrites::tracker::{ChangeKind, visit::SourceKind};
834
835 #[test]
836 fn renames_count_unemitted_as_sources_and_destinations() {
837 let items = [
838 (false, ChangeKind::Addition),
839 (true, ChangeKind::Deletion),
840 (true, ChangeKind::Deletion),
841 ];
842 assert_eq!(
843 estimate_involved_items(items, SourceKind::Rename),
844 (0, 1),
845 "here we only have one eligible source, hence nothing to do"
846 );
847 assert_eq!(
848 estimate_involved_items(items.into_iter().map(|t| (false, t.1)), SourceKind::Rename),
849 (2, 1),
850 "now we have more possibilities as renames count un-emitted deletions as source"
851 );
852 }
853
854 #[test]
855 fn copies_do_not_count_additions_as_sources() {
856 let items = [
857 (false, ChangeKind::Addition),
858 (true, ChangeKind::Addition),
859 (true, ChangeKind::Deletion),
860 ];
861 assert_eq!(
862 estimate_involved_items(items, SourceKind::Copy),
863 (0, 1),
864 "one addition as source, the other isn't counted as it's emitted, nor is it considered a copy-source.\
865 deletions don't count"
866 );
867 }
868
869 #[test]
870 fn copies_count_modifications_as_sources() {
871 let items = [
872 (false, ChangeKind::Addition),
873 (true, ChangeKind::Modification),
874 (false, ChangeKind::Modification),
875 ];
876 assert_eq!(
877 estimate_involved_items(items, SourceKind::Copy),
878 (2, 1),
879 "any modifications is a valid source, emitted or not"
880 );
881 }
882}