1use std::collections::HashMap;
23
24use crate::CommitDiff;
25
26mod spg;
27pub use spg::dump_per_file_spg_stats;
28use spg::{build_file_clusters, build_file_clusters_and_assign_hunks, deduplicate_clusters};
29
30#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct FileSpan {
38 pub path: String,
40 pub start_line: u32,
42 pub end_line: u32,
44}
45
46pub fn extract_spans_propagated(commit_diffs: &[CommitDiff]) -> Vec<(String, Vec<FileSpan>)> {
55 let mut file_commits: HashMap<String, Vec<(usize, Vec<HunkInfo>)>> = HashMap::new();
58
59 for (commit_idx, diff) in commit_diffs.iter().enumerate() {
60 for file in &diff.files {
61 let path = match &file.new_path {
62 Some(p) => p.clone(),
63 None => continue,
64 };
65
66 let hunks: Vec<HunkInfo> = file
67 .hunks
68 .iter()
69 .map(|h| HunkInfo {
70 old_start: h.old_start,
71 old_lines: h.old_lines,
72 new_start: h.new_start,
73 new_lines: h.new_lines,
74 })
75 .collect();
76
77 if !hunks.is_empty() {
78 file_commits
79 .entry(path)
80 .or_default()
81 .push((commit_idx, hunks));
82 }
83 }
84 }
85
86 let mut all_spans: Vec<(usize, FileSpan)> = Vec::new();
88
89 for (path, commits) in &file_commits {
90 for (ci, (commit_idx, hunks)) in commits.iter().enumerate() {
91 for hunk in hunks {
92 if hunk.new_lines == 0 {
93 continue;
94 }
95
96 let mut spans = vec![(hunk.new_start, hunk.new_start + hunk.new_lines)];
98
99 for (_, later_hunks) in &commits[ci + 1..] {
103 spans = spans
104 .into_iter()
105 .flat_map(|(s, e)| split_and_propagate(s, e, later_hunks))
106 .collect();
107 }
108
109 for (start, end) in spans {
111 if end > start {
112 all_spans.push((
113 *commit_idx,
114 FileSpan {
115 path: path.clone(),
116 start_line: start,
117 end_line: end - 1,
118 },
119 ));
120 }
121 }
122 }
123 }
124 }
125
126 let mut result: Vec<(String, Vec<FileSpan>)> = commit_diffs
128 .iter()
129 .map(|d| (d.commit.oid.clone(), Vec::new()))
130 .collect();
131
132 for (commit_idx, span) in all_spans {
133 result[commit_idx].1.push(span);
134 }
135
136 result
137}
138
139#[derive(Debug, Clone)]
141struct HunkInfo {
142 old_start: u32,
143 old_lines: u32,
144 new_start: u32,
145 new_lines: u32,
146}
147
148fn map_line_forward(line: u32, hunks: &[HunkInfo]) -> u32 {
158 let mut cumulative_delta: i64 = 0;
159
160 for hunk in hunks {
161 if line <= hunk.old_start {
165 return (line as i64 + cumulative_delta) as u32;
166 }
167
168 cumulative_delta += hunk.new_lines as i64 - hunk.old_lines as i64;
169 }
170
171 (line as i64 + cumulative_delta) as u32
172}
173
174fn split_and_propagate(start: u32, end: u32, hunks: &[HunkInfo]) -> Vec<(u32, u32)> {
183 let mut remaining = vec![(start, end)];
185
186 for hunk in hunks {
187 let old_start = hunk.old_start;
188 let old_end = hunk.old_start + hunk.old_lines;
189
190 let mut next = Vec::new();
191 for (s, e) in remaining {
192 if e <= old_start || s >= old_end {
193 next.push((s, e));
194 } else {
195 if s < old_start {
196 next.push((s, old_start));
197 }
198 if e > old_end {
199 next.push((old_end, e));
200 }
201 }
202 }
203 remaining = next;
204 }
205
206 remaining
208 .into_iter()
209 .filter(|(s, e)| e > s)
210 .map(|(s, e)| (map_line_forward(s, hunks), map_line_forward(e, hunks)))
211 .filter(|(s, e)| e > s)
212 .collect()
213}
214
215pub fn extract_spans(commit_diff: &CommitDiff) -> Vec<FileSpan> {
218 let mut spans = Vec::new();
219
220 for file in &commit_diff.files {
221 let path = match &file.new_path {
222 Some(p) => p.clone(),
223 None => continue,
224 };
225
226 for hunk in &file.hunks {
227 if hunk.new_lines == 0 {
228 continue;
229 }
230
231 spans.push(FileSpan {
232 path: path.to_string(),
233 start_line: hunk.new_start,
234 end_line: hunk.new_start + hunk.new_lines - 1,
235 });
236 }
237 }
238
239 spans
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum TouchKind {
247 Added,
249 Modified,
251 Deleted,
253 None,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct SpanCluster {
263 pub spans: Vec<FileSpan>,
265 pub commit_oids: Vec<String>,
267}
268
269#[derive(Debug, Clone)]
275pub struct FragMap {
276 pub commits: Vec<String>,
278 pub clusters: Vec<SpanCluster>,
280 pub matrix: Vec<Vec<TouchKind>>,
282}
283
284pub fn build_fragmap(commit_diffs: &[CommitDiff], deduplicate: bool) -> FragMap {
298 let mut file_commits: HashMap<String, Vec<(usize, Vec<HunkInfo>)>> = HashMap::new();
299
300 for (commit_idx, diff) in commit_diffs.iter().enumerate() {
301 for file in &diff.files {
302 let path = match &file.new_path {
303 Some(p) => p.clone(),
304 None => continue,
305 };
306
307 let hunks: Vec<HunkInfo> = file
308 .hunks
309 .iter()
310 .map(|h| HunkInfo {
311 old_start: h.old_start,
312 old_lines: h.old_lines,
313 new_start: h.new_start,
314 new_lines: h.new_lines,
315 })
316 .collect();
317
318 if !hunks.is_empty() {
319 let entry = file_commits.entry(path).or_default();
320 if let Some(last) = entry.last_mut()
323 && last.0 == commit_idx
324 {
325 last.1.extend(hunks);
326 continue;
327 }
328 entry.push((commit_idx, hunks));
329 }
330 }
331 }
332
333 let mut clusters: Vec<SpanCluster> = Vec::new();
334
335 let mut sorted_paths: Vec<&String> = file_commits.keys().collect();
336 sorted_paths.sort();
337
338 for path in sorted_paths {
339 let commits_for_file = &file_commits[path];
340 clusters.extend(build_file_clusters(path, commits_for_file, commit_diffs));
341 }
342
343 if deduplicate {
344 deduplicate_clusters(&mut clusters);
345 }
346
347 let commits: Vec<String> = commit_diffs.iter().map(|d| d.commit.oid.clone()).collect();
348 let matrix = build_matrix(&commits, &clusters, commit_diffs);
349
350 FragMap {
351 commits,
352 clusters,
353 matrix,
354 }
355}
356
357pub fn assign_hunk_groups(
371 commit_diffs: &[CommitDiff],
372 commit_oid: &str,
373) -> Option<(usize, HashMap<String, Vec<usize>>)> {
374 let k_idx = commit_diffs
375 .iter()
376 .position(|d| d.commit.oid == commit_oid)?;
377
378 let mut file_commits: HashMap<String, Vec<(usize, Vec<HunkInfo>)>> = HashMap::new();
380 for (commit_idx, diff) in commit_diffs.iter().enumerate() {
381 for file in &diff.files {
382 let path = match &file.new_path {
383 Some(p) => p.clone(),
384 None => continue,
385 };
386 let hunks: Vec<HunkInfo> = file
387 .hunks
388 .iter()
389 .map(|h| HunkInfo {
390 old_start: h.old_start,
391 old_lines: h.old_lines,
392 new_start: h.new_start,
393 new_lines: h.new_lines,
394 })
395 .collect();
396 if !hunks.is_empty() {
397 let entry = file_commits.entry(path).or_default();
398 if let Some(last) = entry.last_mut()
399 && last.0 == commit_idx
400 {
401 last.1.extend(hunks);
402 continue;
403 }
404 entry.push((commit_idx, hunks));
405 }
406 }
407 }
408
409 let mut sorted_paths: Vec<&String> = file_commits.keys().collect();
410 sorted_paths.sort();
411
412 let mut pre_dedup_clusters: Vec<SpanCluster> = Vec::new();
419 let mut k_hunk_prededup: HashMap<String, Vec<Vec<usize>>> = HashMap::new();
420
421 for path in &sorted_paths {
422 let commits_for_file = &file_commits[*path];
423 let file_cluster_offset = pre_dedup_clusters.len();
424
425 let has_k = commits_for_file.iter().any(|(idx, _)| *idx == k_idx);
426 let (file_clusters, hunk_to_local) = if has_k {
427 build_file_clusters_and_assign_hunks(path, commits_for_file, commit_diffs, k_idx)
428 } else {
429 (
430 build_file_clusters(path, commits_for_file, commit_diffs),
431 vec![],
432 )
433 };
434
435 if has_k && !hunk_to_local.is_empty() {
436 let global_indices: Vec<Vec<usize>> = hunk_to_local
437 .iter()
438 .map(|locals| {
439 locals
440 .iter()
441 .map(|&local| file_cluster_offset + local)
442 .collect()
443 })
444 .collect();
445 k_hunk_prededup.insert((*path).clone(), global_indices);
446 }
447
448 pre_dedup_clusters.extend(file_clusters);
449 }
450
451 for cluster in &mut pre_dedup_clusters {
454 cluster.commit_oids.sort();
455 }
456 let mut seen_patterns: Vec<Vec<String>> = Vec::new();
457 let mut group_idx_for_prededup: Vec<usize> = Vec::with_capacity(pre_dedup_clusters.len());
458 for cluster in &pre_dedup_clusters {
459 if let Some(pos) = seen_patterns.iter().position(|p| p == &cluster.commit_oids) {
460 group_idx_for_prededup.push(pos);
461 } else {
462 let new_idx = seen_patterns.len();
463 seen_patterns.push(cluster.commit_oids.clone());
464 group_idx_for_prededup.push(new_idx);
465 }
466 }
467 let group_count = seen_patterns.len();
468
469 let mut candidates: Vec<(String, usize, Vec<usize>)> = Vec::new();
485 for (path, prededup_multi) in &k_hunk_prededup {
486 for (h, prededup_indices) in prededup_multi.iter().enumerate() {
487 let mut groups: Vec<usize> = prededup_indices
488 .iter()
489 .map(|&pre_idx| group_idx_for_prededup[pre_idx])
490 .collect();
491 groups.sort();
492 groups.dedup();
493 if groups.is_empty() {
494 groups.push(0);
495 }
496 candidates.push((path.clone(), h, groups));
497 }
498 }
499
500 let all_touched: std::collections::BTreeSet<usize> = candidates
502 .iter()
503 .flat_map(|(_, _, gs)| gs.iter().copied())
504 .collect();
505
506 let mut assigned: Vec<Option<usize>> = vec![None; candidates.len()];
507
508 for (i, (_, _, groups)) in candidates.iter().enumerate() {
510 if groups.len() == 1 {
511 assigned[i] = Some(groups[0]);
512 }
513 }
514
515 let mut covered: std::collections::HashSet<usize> =
517 assigned.iter().filter_map(|a| *a).collect();
518 for &g in &all_touched {
519 if covered.contains(&g) {
520 continue;
521 }
522 let best = candidates
523 .iter()
524 .enumerate()
525 .filter(|(i, (_, _, groups))| assigned[*i].is_none() && groups.contains(&g))
526 .min_by_key(|(_, (_, _, groups))| groups.len())
527 .map(|(i, _)| i);
528 if let Some(i) = best {
529 assigned[i] = Some(g);
530 covered.insert(g);
531 }
532 }
533
534 for (i, (_, _, groups)) in candidates.iter().enumerate() {
536 if assigned[i].is_none() {
537 assigned[i] = Some(groups[0]);
538 }
539 }
540
541 let mut assignment: HashMap<String, Vec<usize>> = HashMap::new();
543 for (i, (path, h, _)) in candidates.iter().enumerate() {
544 let entry = assignment
545 .entry(path.clone())
546 .or_insert_with(|| vec![0; k_hunk_prededup[path].len()]);
547 entry[*h] = assigned[i].unwrap_or(0);
548 }
549
550 Some((group_count, assignment))
551}
552
553impl FragMap {
554 pub fn squash_target(&self, commit_idx: usize) -> Option<usize> {
560 let mut target: Option<usize> = None;
561
562 for cluster_idx in 0..self.clusters.len() {
563 if self.matrix[commit_idx][cluster_idx] == TouchKind::None {
564 continue;
565 }
566
567 let earlier = (0..commit_idx).find(|&i| self.matrix[i][cluster_idx] != TouchKind::None);
568
569 let earlier_idx = earlier?;
570
571 match self.cluster_relation(earlier_idx, commit_idx, cluster_idx) {
572 SquashRelation::Squashable => match target {
573 None => target = Some(earlier_idx),
574 Some(t) if t == earlier_idx => {}
575 Some(_) => return None,
576 },
577 _ => return None,
578 }
579 }
580
581 target
582 }
583
584 pub fn is_fully_squashable(&self, commit_idx: usize) -> bool {
586 self.squash_target(commit_idx).is_some()
587 }
588
589 pub fn shares_cluster_with(&self, a: usize, b: usize) -> bool {
591 if a == b {
592 return false;
593 }
594 (0..self.clusters.len())
595 .any(|c| self.matrix[a][c] != TouchKind::None && self.matrix[b][c] != TouchKind::None)
596 }
597
598 pub fn pairwise_squash_relation(&self, a: usize, b: usize) -> SquashRelation {
606 if a == b || a >= self.commits.len() || b >= self.commits.len() {
607 return SquashRelation::NoRelation;
608 }
609 let (earlier, later) = if a < b { (a, b) } else { (b, a) };
610
611 let mut has_shared = false;
612 for c in 0..self.clusters.len() {
613 if self.matrix[earlier][c] == TouchKind::None
614 || self.matrix[later][c] == TouchKind::None
615 {
616 continue;
617 }
618 has_shared = true;
619 if self.cluster_relation(earlier, later, c) == SquashRelation::Conflicting {
620 return SquashRelation::Conflicting;
621 }
622 }
623
624 if has_shared {
625 SquashRelation::Squashable
626 } else {
627 SquashRelation::NoRelation
628 }
629 }
630
631 pub fn cluster_relation(
637 &self,
638 earlier_commit_idx: usize,
639 later_commit_idx: usize,
640 cluster_idx: usize,
641 ) -> SquashRelation {
642 if earlier_commit_idx >= self.commits.len()
643 || later_commit_idx >= self.commits.len()
644 || cluster_idx >= self.clusters.len()
645 {
646 return SquashRelation::NoRelation;
647 }
648
649 if earlier_commit_idx >= later_commit_idx {
650 return SquashRelation::NoRelation;
651 }
652
653 let earlier_touches = self.matrix[earlier_commit_idx][cluster_idx] != TouchKind::None;
654 let later_touches = self.matrix[later_commit_idx][cluster_idx] != TouchKind::None;
655
656 if !earlier_touches || !later_touches {
657 return SquashRelation::NoRelation;
658 }
659
660 for commit_idx in (earlier_commit_idx + 1)..later_commit_idx {
661 if self.matrix[commit_idx][cluster_idx] != TouchKind::None {
662 return SquashRelation::Conflicting;
663 }
664 }
665
666 SquashRelation::Squashable
667 }
668
669 pub fn connector_squashable(
680 &self,
681 commit_idx: usize,
682 cluster_idx: usize,
683 scope: SquashableScope,
684 ) -> Option<bool> {
685 let earlier = (0..commit_idx).find(|&i| self.matrix[i][cluster_idx] != TouchKind::None)?;
686
687 match scope {
688 SquashableScope::Group => Some(
689 self.cluster_relation(earlier, commit_idx, cluster_idx)
690 == SquashRelation::Squashable,
691 ),
692 SquashableScope::Commit => {
693 Some(self.squash_target(commit_idx).is_some_and(|t| t == earlier))
694 }
695 }
696 }
697}
698
699fn build_matrix(
704 commits: &[String],
705 clusters: &[SpanCluster],
706 commit_diffs: &[CommitDiff],
707) -> Vec<Vec<TouchKind>> {
708 let mut matrix = vec![vec![TouchKind::None; clusters.len()]; commits.len()];
709
710 for (commit_idx, commit_oid) in commits.iter().enumerate() {
711 let commit_diff = &commit_diffs[commit_idx];
712
713 for (cluster_idx, cluster) in clusters.iter().enumerate() {
714 if cluster.commit_oids.contains(commit_oid) {
716 matrix[commit_idx][cluster_idx] = determine_touch_kind(commit_diff, cluster);
718 }
719 }
720 }
721
722 matrix
723}
724
725fn determine_touch_kind(commit_diff: &CommitDiff, cluster: &SpanCluster) -> TouchKind {
730 for cluster_span in &cluster.spans {
731 for file in &commit_diff.files {
732 let file_path = file.new_path.as_ref().or(file.old_path.as_ref());
734 if file_path.map(|p| p == &cluster_span.path).unwrap_or(false) {
735 if file.old_path.is_none() && file.new_path.is_some() {
737 return TouchKind::Added;
738 } else if file.old_path.is_some() && file.new_path.is_none() {
739 return TouchKind::Deleted;
740 } else {
741 return TouchKind::Modified;
742 }
743 }
744 }
745 }
746
747 TouchKind::None
748}
749
750#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
754pub enum SquashableScope {
755 #[default]
759 Group,
760 Commit,
765}
766
767#[derive(Debug, Clone, Copy, PartialEq, Eq)]
772pub enum SquashRelation {
773 NoRelation,
775 Squashable,
778 Conflicting,
781}
782
783#[cfg(test)]
784mod tests {
785 use super::*;
786 use crate::{CommitDiff, CommitInfo, FileDiff, Hunk};
787
788 fn make_commit_info() -> CommitInfo {
789 CommitInfo {
790 oid: "abc123".to_string(),
791 summary: "Test commit".to_string(),
792 author: Some("Test Author".to_string()),
793 date: Some("123456789".to_string()),
794 parent_oids: vec![],
795 message: "Test commit".to_string(),
796 author_email: Some("test@example.com".to_string()),
797 author_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
798 committer: Some("Test Committer".to_string()),
799 committer_email: Some("committer@example.com".to_string()),
800 commit_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
801 }
802 }
803
804 #[test]
805 fn test_extract_spans_single_hunk() {
806 let commit_diff = CommitDiff {
807 commit: make_commit_info(),
808 files: vec![FileDiff {
809 old_path: Some("file.txt".to_string()),
810 new_path: Some("file.txt".to_string()),
811 status: crate::DeltaStatus::Modified,
812 hunks: vec![Hunk {
813 old_start: 10,
814 old_lines: 3,
815 new_start: 10,
816 new_lines: 5,
817 lines: vec![],
818 }],
819 }],
820 };
821
822 let spans = extract_spans(&commit_diff);
823
824 assert_eq!(spans.len(), 1);
825 assert_eq!(spans[0].path, "file.txt");
826 assert_eq!(spans[0].start_line, 10);
827 assert_eq!(spans[0].end_line, 14); }
829
830 #[test]
831 fn test_extract_spans_multiple_hunks() {
832 let commit_diff = CommitDiff {
833 commit: make_commit_info(),
834 files: vec![FileDiff {
835 old_path: Some("file.txt".to_string()),
836 new_path: Some("file.txt".to_string()),
837 status: crate::DeltaStatus::Modified,
838 hunks: vec![
839 Hunk {
840 old_start: 5,
841 old_lines: 2,
842 new_start: 5,
843 new_lines: 3,
844 lines: vec![],
845 },
846 Hunk {
847 old_start: 20,
848 old_lines: 1,
849 new_start: 21,
850 new_lines: 2,
851 lines: vec![],
852 },
853 ],
854 }],
855 };
856
857 let spans = extract_spans(&commit_diff);
858
859 assert_eq!(spans.len(), 2);
860 assert_eq!(spans[0].path, "file.txt");
861 assert_eq!(spans[0].start_line, 5);
862 assert_eq!(spans[0].end_line, 7); assert_eq!(spans[1].path, "file.txt");
865 assert_eq!(spans[1].start_line, 21);
866 assert_eq!(spans[1].end_line, 22); }
868
869 #[test]
870 fn test_extract_spans_multiple_files() {
871 let commit_diff = CommitDiff {
872 commit: make_commit_info(),
873 files: vec![
874 FileDiff {
875 old_path: Some("a.txt".to_string()),
876 new_path: Some("a.txt".to_string()),
877 status: crate::DeltaStatus::Modified,
878 hunks: vec![Hunk {
879 old_start: 1,
880 old_lines: 1,
881 new_start: 1,
882 new_lines: 2,
883 lines: vec![],
884 }],
885 },
886 FileDiff {
887 old_path: Some("b.txt".to_string()),
888 new_path: Some("b.txt".to_string()),
889 status: crate::DeltaStatus::Modified,
890 hunks: vec![Hunk {
891 old_start: 10,
892 old_lines: 3,
893 new_start: 10,
894 new_lines: 4,
895 lines: vec![],
896 }],
897 },
898 ],
899 };
900
901 let spans = extract_spans(&commit_diff);
902
903 assert_eq!(spans.len(), 2);
904 assert_eq!(spans[0].path, "a.txt");
905 assert_eq!(spans[0].start_line, 1);
906 assert_eq!(spans[0].end_line, 2);
907
908 assert_eq!(spans[1].path, "b.txt");
909 assert_eq!(spans[1].start_line, 10);
910 assert_eq!(spans[1].end_line, 13);
911 }
912
913 #[test]
914 fn test_extract_spans_skips_deleted_files() {
915 let commit_diff = CommitDiff {
916 commit: make_commit_info(),
917 files: vec![
918 FileDiff {
919 old_path: Some("file.txt".to_string()),
920 new_path: Some("file.txt".to_string()),
921 status: crate::DeltaStatus::Modified,
922 hunks: vec![Hunk {
923 old_start: 1,
924 old_lines: 1,
925 new_start: 1,
926 new_lines: 2,
927 lines: vec![],
928 }],
929 },
930 FileDiff {
931 old_path: Some("deleted.txt".to_string()),
932 new_path: None, status: crate::DeltaStatus::Deleted,
934 hunks: vec![Hunk {
935 old_start: 1,
936 old_lines: 5,
937 new_start: 0,
938 new_lines: 0,
939 lines: vec![],
940 }],
941 },
942 ],
943 };
944
945 let spans = extract_spans(&commit_diff);
946
947 assert_eq!(spans.len(), 1);
949 assert_eq!(spans[0].path, "file.txt");
950 }
951
952 #[test]
953 fn test_extract_spans_skips_empty_hunks() {
954 let commit_diff = CommitDiff {
955 commit: make_commit_info(),
956 files: vec![FileDiff {
957 old_path: Some("file.txt".to_string()),
958 new_path: Some("file.txt".to_string()),
959 status: crate::DeltaStatus::Modified,
960 hunks: vec![
961 Hunk {
962 old_start: 5,
963 old_lines: 2,
964 new_start: 5,
965 new_lines: 3,
966 lines: vec![],
967 },
968 Hunk {
969 old_start: 10,
970 old_lines: 1,
971 new_start: 8,
972 new_lines: 0, lines: vec![],
974 },
975 ],
976 }],
977 };
978
979 let spans = extract_spans(&commit_diff);
980
981 assert_eq!(spans.len(), 1);
983 assert_eq!(spans[0].start_line, 5);
984 assert_eq!(spans[0].end_line, 7);
985 }
986
987 #[test]
988 fn test_extract_spans_added_file() {
989 let commit_diff = CommitDiff {
990 commit: make_commit_info(),
991 files: vec![FileDiff {
992 old_path: None, new_path: Some("new_file.txt".to_string()),
994 status: crate::DeltaStatus::Added,
995 hunks: vec![Hunk {
996 old_start: 0,
997 old_lines: 0,
998 new_start: 1,
999 new_lines: 10,
1000 lines: vec![],
1001 }],
1002 }],
1003 };
1004
1005 let spans = extract_spans(&commit_diff);
1006
1007 assert_eq!(spans.len(), 1);
1008 assert_eq!(spans[0].path, "new_file.txt");
1009 assert_eq!(spans[0].start_line, 1);
1010 assert_eq!(spans[0].end_line, 10);
1011 }
1012
1013 #[test]
1014 fn test_extract_spans_single_line_change() {
1015 let commit_diff = CommitDiff {
1016 commit: make_commit_info(),
1017 files: vec![FileDiff {
1018 old_path: Some("file.txt".to_string()),
1019 new_path: Some("file.txt".to_string()),
1020 status: crate::DeltaStatus::Modified,
1021 hunks: vec![Hunk {
1022 old_start: 42,
1023 old_lines: 1,
1024 new_start: 42,
1025 new_lines: 1,
1026 lines: vec![],
1027 }],
1028 }],
1029 };
1030
1031 let spans = extract_spans(&commit_diff);
1032
1033 assert_eq!(spans.len(), 1);
1034 assert_eq!(spans[0].start_line, 42);
1035 assert_eq!(spans[0].end_line, 42); }
1037
1038 #[test]
1039 fn test_extract_spans_empty_commit() {
1040 let commit_diff = CommitDiff {
1041 commit: make_commit_info(),
1042 files: vec![],
1043 };
1044
1045 let spans = extract_spans(&commit_diff);
1046
1047 assert_eq!(spans.len(), 0);
1048 }
1049
1050 #[test]
1051 fn test_map_line_forward_before_hunk() {
1052 let hunks = vec![HunkInfo {
1053 old_start: 10,
1054 old_lines: 5,
1055 new_start: 10,
1056 new_lines: 8,
1057 }];
1058 assert_eq!(map_line_forward(5, &hunks), 5);
1059 }
1060
1061 #[test]
1062 fn test_map_line_forward_after_hunk() {
1063 let hunks = vec![HunkInfo {
1064 old_start: 10,
1065 old_lines: 5,
1066 new_start: 10,
1067 new_lines: 8,
1068 }];
1069 assert_eq!(map_line_forward(20, &hunks), 23);
1071 }
1072
1073 #[test]
1074 fn test_split_and_propagate_overlap() {
1075 let hunks = vec![HunkInfo {
1076 old_start: 10,
1077 old_lines: 5,
1078 new_start: 10,
1079 new_lines: 8,
1080 }];
1081 let result = split_and_propagate(8, 20, &hunks);
1086 assert_eq!(result, vec![(8, 10), (18, 23)]);
1087 }
1088
1089 #[test]
1090 fn test_map_line_forward_two_hunks() {
1091 let hunks = vec![
1092 HunkInfo {
1093 old_start: 10,
1094 old_lines: 5,
1095 new_start: 10,
1096 new_lines: 8,
1097 },
1098 HunkInfo {
1099 old_start: 30,
1100 old_lines: 3,
1101 new_start: 33,
1102 new_lines: 5,
1103 },
1104 ];
1105 assert_eq!(map_line_forward(25, &hunks), 28);
1107 assert_eq!(map_line_forward(40, &hunks), 45);
1109 }
1110
1111 #[test]
1112 fn test_propagation_sequential_commits_same_file() {
1113 let commits = vec![
1116 CommitDiff {
1117 commit: make_commit_info_with_oid("c1"),
1118 files: vec![FileDiff {
1119 old_path: Some("f.rs".to_string()),
1120 new_path: Some("f.rs".to_string()),
1121 status: crate::DeltaStatus::Modified,
1122 hunks: vec![Hunk {
1123 old_start: 10,
1124 old_lines: 5,
1125 new_start: 10,
1126 new_lines: 8,
1127 lines: vec![],
1128 }],
1129 }],
1130 },
1131 CommitDiff {
1132 commit: make_commit_info_with_oid("c2"),
1133 files: vec![FileDiff {
1134 old_path: Some("f.rs".to_string()),
1135 new_path: Some("f.rs".to_string()),
1136 status: crate::DeltaStatus::Modified,
1137 hunks: vec![Hunk {
1138 old_start: 50,
1139 old_lines: 5,
1140 new_start: 50,
1141 new_lines: 5,
1142 lines: vec![],
1143 }],
1144 }],
1145 },
1146 ];
1147
1148 let fm = build_fragmap(&commits, true);
1149 assert!(!fm.shares_cluster_with(0, 1));
1150 }
1151
1152 #[test]
1153 fn test_propagation_overlapping_hunks_are_related() {
1154 let commits = vec![
1157 CommitDiff {
1158 commit: make_commit_info_with_oid("c1"),
1159 files: vec![FileDiff {
1160 old_path: Some("f.rs".to_string()),
1161 new_path: Some("f.rs".to_string()),
1162 status: crate::DeltaStatus::Modified,
1163 hunks: vec![Hunk {
1164 old_start: 10,
1165 old_lines: 5,
1166 new_start: 10,
1167 new_lines: 55,
1168 lines: vec![],
1169 }],
1170 }],
1171 },
1172 CommitDiff {
1173 commit: make_commit_info_with_oid("c2"),
1174 files: vec![FileDiff {
1175 old_path: Some("f.rs".to_string()),
1176 new_path: Some("f.rs".to_string()),
1177 status: crate::DeltaStatus::Modified,
1178 hunks: vec![Hunk {
1179 old_start: 30,
1180 old_lines: 10,
1181 new_start: 30,
1182 new_lines: 10,
1183 lines: vec![],
1184 }],
1185 }],
1186 },
1187 ];
1188
1189 let fm = build_fragmap(&commits, true);
1190 assert!(fm.shares_cluster_with(0, 1));
1191 }
1192
1193 #[test]
1194 fn test_propagation_distant_changes_not_related() {
1195 let commits = vec![
1197 CommitDiff {
1198 commit: make_commit_info_with_oid("c1"),
1199 files: vec![FileDiff {
1200 old_path: Some("f.rs".to_string()),
1201 new_path: Some("f.rs".to_string()),
1202 status: crate::DeltaStatus::Modified,
1203 hunks: vec![Hunk {
1204 old_start: 10,
1205 old_lines: 3,
1206 new_start: 10,
1207 new_lines: 5,
1208 lines: vec![],
1209 }],
1210 }],
1211 },
1212 CommitDiff {
1213 commit: make_commit_info_with_oid("c2"),
1214 files: vec![FileDiff {
1215 old_path: Some("f.rs".to_string()),
1216 new_path: Some("f.rs".to_string()),
1217 status: crate::DeltaStatus::Modified,
1218 hunks: vec![Hunk {
1219 old_start: 200,
1220 old_lines: 5,
1221 new_start: 202,
1222 new_lines: 5,
1223 lines: vec![],
1224 }],
1225 }],
1226 },
1227 ];
1228
1229 let fm = build_fragmap(&commits, true);
1230 assert!(!fm.shares_cluster_with(0, 1));
1231 }
1232
1233 fn make_commit_info_with_oid(oid: &str) -> CommitInfo {
1236 CommitInfo {
1237 oid: oid.to_string(),
1238 summary: format!("Commit {}", oid),
1239 author: Some("Test Author".to_string()),
1240 date: Some("123456789".to_string()),
1241 parent_oids: vec![],
1242 message: format!("Commit {}", oid),
1243 author_email: Some("test@example.com".to_string()),
1244 author_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
1245 committer: Some("Test Committer".to_string()),
1246 committer_email: Some("committer@example.com".to_string()),
1247 commit_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
1248 }
1249 }
1250
1251 fn make_file_diff(
1252 old_path: Option<&str>,
1253 new_path: Option<&str>,
1254 old_start: u32,
1255 old_lines: u32,
1256 new_start: u32,
1257 new_lines: u32,
1258 ) -> FileDiff {
1259 FileDiff {
1260 old_path: old_path.map(|s| s.to_string()),
1261 new_path: new_path.map(|s| s.to_string()),
1262 status: crate::DeltaStatus::Modified,
1263 hunks: vec![Hunk {
1264 old_start,
1265 old_lines,
1266 new_start,
1267 new_lines,
1268 lines: vec![],
1269 }],
1270 }
1271 }
1272
1273 fn make_commit_diff(oid: &str, files: Vec<FileDiff>) -> CommitDiff {
1274 CommitDiff {
1275 commit: make_commit_info_with_oid(oid),
1276 files,
1277 }
1278 }
1279
1280 #[test]
1283 fn test_build_fragmap_empty_commits() {
1284 let fragmap = build_fragmap(&[], true);
1285
1286 assert_eq!(fragmap.commits.len(), 0);
1287 assert_eq!(fragmap.clusters.len(), 0);
1288 assert_eq!(fragmap.matrix.len(), 0);
1289 }
1290
1291 #[test]
1292 fn test_build_fragmap_single_commit() {
1293 let commits = vec![make_commit_diff(
1294 "c1",
1295 vec![make_file_diff(
1296 None, Some("file.txt"),
1298 0,
1299 0,
1300 1,
1301 3,
1302 )],
1303 )];
1304
1305 let fragmap = build_fragmap(&commits, true);
1306
1307 assert_eq!(fragmap.commits.len(), 1);
1308 assert_eq!(fragmap.commits[0], "c1");
1309
1310 assert_eq!(fragmap.clusters.len(), 1);
1312 assert_eq!(fragmap.clusters[0].spans.len(), 1);
1313 assert_eq!(fragmap.clusters[0].spans[0].path, "file.txt");
1314 assert_eq!(fragmap.clusters[0].commit_oids, vec!["c1"]);
1315
1316 assert_eq!(fragmap.matrix.len(), 1);
1318 assert_eq!(fragmap.matrix[0].len(), 1);
1319 assert_eq!(fragmap.matrix[0][0], TouchKind::Added);
1320 }
1321
1322 #[test]
1323 fn test_build_fragmap_overlapping_spans_merge() {
1324 let commits = vec![
1326 make_commit_diff(
1327 "c1",
1328 vec![make_file_diff(
1329 Some("file.txt"),
1330 Some("file.txt"),
1331 1,
1332 0,
1333 1,
1334 5, )],
1336 ),
1337 make_commit_diff(
1338 "c2",
1339 vec![make_file_diff(
1340 Some("file.txt"),
1341 Some("file.txt"),
1342 3,
1343 3,
1344 3,
1345 4, )],
1347 ),
1348 ];
1349
1350 let fragmap = build_fragmap(&commits, true);
1351
1352 assert_eq!(fragmap.commits.len(), 2);
1353
1354 assert!(fragmap.shares_cluster_with(0, 1));
1356
1357 let shared = fragmap.clusters.iter().any(|c| {
1359 c.commit_oids.contains(&"c1".to_string()) && c.commit_oids.contains(&"c2".to_string())
1360 });
1361 assert!(shared);
1362
1363 let shared_idx = fragmap
1365 .clusters
1366 .iter()
1367 .position(|c| {
1368 c.commit_oids.contains(&"c1".to_string())
1369 && c.commit_oids.contains(&"c2".to_string())
1370 })
1371 .unwrap();
1372 assert_ne!(fragmap.matrix[0][shared_idx], TouchKind::None);
1373 assert_ne!(fragmap.matrix[1][shared_idx], TouchKind::None);
1374 }
1375
1376 #[test]
1377 fn test_build_fragmap_non_overlapping_separate_clusters() {
1378 let commits = vec![
1380 make_commit_diff(
1381 "c1",
1382 vec![make_file_diff(
1383 Some("file.txt"),
1384 Some("file.txt"),
1385 1,
1386 0,
1387 1,
1388 5, )],
1390 ),
1391 make_commit_diff(
1392 "c2",
1393 vec![make_file_diff(
1394 Some("file.txt"),
1395 Some("file.txt"),
1396 10,
1397 3,
1398 10,
1399 4, )],
1401 ),
1402 ];
1403
1404 let fragmap = build_fragmap(&commits, true);
1405
1406 assert_eq!(fragmap.commits.len(), 2);
1407
1408 assert_eq!(fragmap.clusters.len(), 2);
1410
1411 assert_eq!(fragmap.matrix.len(), 2);
1413 assert_eq!(fragmap.matrix[0].len(), 2);
1414 assert_eq!(fragmap.matrix[1].len(), 2);
1415
1416 assert_ne!(fragmap.matrix[0][0], TouchKind::None);
1418 assert_eq!(fragmap.matrix[0][1], TouchKind::None);
1419
1420 assert_eq!(fragmap.matrix[1][0], TouchKind::None);
1422 assert_ne!(fragmap.matrix[1][1], TouchKind::None);
1423 }
1424
1425 #[test]
1426 fn test_build_fragmap_adjacent_spans_stay_separate() {
1427 let commits = vec![
1430 make_commit_diff(
1431 "c1",
1432 vec![make_file_diff(
1433 Some("file.txt"),
1434 Some("file.txt"),
1435 1,
1436 0,
1437 1,
1438 5, )],
1440 ),
1441 make_commit_diff(
1442 "c2",
1443 vec![make_file_diff(
1444 Some("file.txt"),
1445 Some("file.txt"),
1446 6,
1447 2,
1448 6,
1449 3, )],
1451 ),
1452 ];
1453
1454 let fragmap = build_fragmap(&commits, true);
1455
1456 assert_eq!(fragmap.commits.len(), 2);
1457
1458 assert_eq!(fragmap.clusters.len(), 2);
1460 }
1461
1462 #[test]
1463 fn test_no_snowball_effect_on_cluster_ranges() {
1464 let commits = vec![
1469 make_commit_diff(
1470 "c1",
1471 vec![make_file_diff(
1472 Some("file.txt"),
1473 Some("file.txt"),
1474 1,
1475 0,
1476 1,
1477 5, )],
1479 ),
1480 make_commit_diff(
1481 "c2",
1482 vec![make_file_diff(
1483 Some("file.txt"),
1484 Some("file.txt"),
1485 3,
1486 5,
1487 3,
1488 10, )],
1490 ),
1491 make_commit_diff(
1492 "c3",
1493 vec![make_file_diff(
1494 Some("file.txt"),
1495 Some("file.txt"),
1496 50,
1497 3,
1498 50,
1499 4, )],
1501 ),
1502 ];
1503
1504 let fragmap = build_fragmap(&commits, true);
1505
1506 assert!(fragmap.shares_cluster_with(0, 1));
1508 assert!(!fragmap.shares_cluster_with(0, 2));
1509 assert!(!fragmap.shares_cluster_with(1, 2));
1510 }
1511
1512 #[test]
1513 fn test_different_functions_same_file_separate_clusters() {
1514 let commits = vec![
1518 make_commit_diff(
1519 "c1",
1520 vec![make_file_diff(
1521 Some("lib.rs"),
1522 Some("lib.rs"),
1523 10,
1524 3,
1525 10,
1526 5, )],
1528 ),
1529 make_commit_diff(
1530 "c2",
1531 vec![make_file_diff(
1532 Some("lib.rs"),
1533 Some("lib.rs"),
1534 80,
1535 2,
1536 80,
1537 4, )],
1539 ),
1540 ];
1541
1542 let fragmap = build_fragmap(&commits, true);
1543
1544 assert_eq!(fragmap.clusters.len(), 2);
1546
1547 assert!(!fragmap.is_fully_squashable(0));
1549 assert!(!fragmap.is_fully_squashable(1));
1550
1551 assert!(!fragmap.shares_cluster_with(0, 1));
1553 }
1554
1555 #[test]
1556 fn test_build_fragmap_touchkind_added() {
1557 let commits = vec![make_commit_diff(
1559 "c1",
1560 vec![make_file_diff(
1561 None, Some("new_file.txt"),
1563 0,
1564 0,
1565 1,
1566 10,
1567 )],
1568 )];
1569
1570 let fragmap = build_fragmap(&commits, true);
1571
1572 assert_eq!(fragmap.matrix[0][0], TouchKind::Added);
1573 }
1574
1575 #[test]
1576 fn test_build_fragmap_touchkind_modified() {
1577 let commits = vec![make_commit_diff(
1579 "c1",
1580 vec![make_file_diff(
1581 Some("file.txt"),
1582 Some("file.txt"),
1583 10,
1584 5, 10,
1586 6, )],
1588 )];
1589
1590 let fragmap = build_fragmap(&commits, true);
1591
1592 assert_eq!(fragmap.matrix[0][0], TouchKind::Modified);
1593 }
1594
1595 #[test]
1596 fn test_build_fragmap_touchkind_deleted() {
1597 let commits = vec![make_commit_diff(
1603 "c1",
1604 vec![make_file_diff(
1605 Some("file.txt"),
1606 Some("file.txt"),
1607 10,
1608 5,
1609 10,
1610 2, )],
1612 )];
1613
1614 let fragmap = build_fragmap(&commits, true);
1615
1616 assert_eq!(fragmap.commits.len(), 1);
1618 assert_eq!(fragmap.clusters.len(), 1);
1619 }
1620
1621 #[test]
1622 fn test_build_fragmap_multiple_files_separate_clusters() {
1623 let commits = vec![
1625 make_commit_diff(
1626 "c1",
1627 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1628 ),
1629 make_commit_diff(
1630 "c2",
1631 vec![make_file_diff(
1632 Some("b.txt"),
1633 Some("b.txt"),
1634 1,
1635 0,
1636 1,
1637 5, )],
1639 ),
1640 ];
1641
1642 let fragmap = build_fragmap(&commits, true);
1643
1644 assert_eq!(fragmap.commits.len(), 2);
1645
1646 assert_eq!(fragmap.clusters.len(), 2);
1648
1649 assert_ne!(fragmap.matrix[0][0], TouchKind::None);
1651 assert_eq!(fragmap.matrix[0][1], TouchKind::None);
1652
1653 assert_eq!(fragmap.matrix[1][0], TouchKind::None);
1654 assert_ne!(fragmap.matrix[1][1], TouchKind::None);
1655 }
1656
1657 #[test]
1658 fn test_build_fragmap_commit_touches_multiple_clusters() {
1659 let mut c1 = make_commit_diff(
1663 "c1",
1664 vec![make_file_diff(
1665 Some("file.txt"),
1666 Some("file.txt"),
1667 1,
1668 0,
1669 1,
1670 5, )],
1672 );
1673
1674 c1.files.push(make_file_diff(
1675 Some("file.txt"),
1676 Some("file.txt"),
1677 20,
1678 0,
1679 20,
1680 3, ));
1682
1683 let fragmap = build_fragmap(&[c1], true);
1684
1685 assert_eq!(fragmap.commits.len(), 1);
1686
1687 assert_eq!(fragmap.clusters.len(), 1);
1690 assert_ne!(fragmap.matrix[0][0], TouchKind::None);
1691 }
1692
1693 #[test]
1696 fn test_cluster_relation_no_relation_neither_touches() {
1697 let commits = vec![
1699 make_commit_diff(
1700 "c1",
1701 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1702 ),
1703 make_commit_diff(
1704 "c2",
1705 vec![make_file_diff(Some("b.txt"), Some("b.txt"), 1, 0, 1, 5)],
1706 ),
1707 ];
1708
1709 let fragmap = build_fragmap(&commits, true);
1710
1711 assert_eq!(fragmap.clusters.len(), 2);
1713
1714 let relation = fragmap.cluster_relation(0, 1, 0);
1715 assert_eq!(relation, SquashRelation::NoRelation);
1716 }
1717
1718 #[test]
1719 fn test_cluster_relation_no_relation_only_one_touches() {
1720 let commits = vec![
1722 make_commit_diff(
1723 "c1",
1724 vec![make_file_diff(
1725 Some("file.txt"),
1726 Some("file.txt"),
1727 1,
1728 0,
1729 1,
1730 5,
1731 )],
1732 ),
1733 make_commit_diff(
1734 "c2",
1735 vec![make_file_diff(
1736 Some("file.txt"),
1737 Some("file.txt"),
1738 100,
1739 0,
1740 100,
1741 5, )],
1743 ),
1744 ];
1745
1746 let fragmap = build_fragmap(&commits, true);
1747
1748 assert_eq!(fragmap.clusters.len(), 2);
1749
1750 let relation = fragmap.cluster_relation(0, 1, 0);
1752 assert_eq!(relation, SquashRelation::NoRelation);
1753 }
1754
1755 #[test]
1756 fn test_cluster_relation_squashable_no_collisions() {
1757 let commits = vec![
1759 make_commit_diff(
1760 "c1",
1761 vec![make_file_diff(
1762 Some("file.txt"),
1763 Some("file.txt"),
1764 1,
1765 0,
1766 1,
1767 5,
1768 )],
1769 ),
1770 make_commit_diff(
1771 "c2",
1772 vec![make_file_diff(
1773 Some("file.txt"),
1774 Some("file.txt"),
1775 3,
1776 3,
1777 3,
1778 4, )],
1780 ),
1781 ];
1782
1783 let fragmap = build_fragmap(&commits, true);
1784
1785 let shared_idx = fragmap
1787 .clusters
1788 .iter()
1789 .position(|c| {
1790 c.commit_oids.contains(&"c1".to_string())
1791 && c.commit_oids.contains(&"c2".to_string())
1792 })
1793 .expect("should have a shared cluster");
1794
1795 let relation = fragmap.cluster_relation(0, 1, shared_idx);
1796 assert_eq!(relation, SquashRelation::Squashable);
1797 }
1798
1799 #[test]
1800 fn test_cluster_relation_conflicting_with_collision() {
1801 let commits = vec![
1803 make_commit_diff(
1804 "c1",
1805 vec![make_file_diff(
1806 Some("file.txt"),
1807 Some("file.txt"),
1808 1,
1809 0,
1810 1,
1811 5,
1812 )],
1813 ),
1814 make_commit_diff(
1815 "c2",
1816 vec![make_file_diff(
1817 Some("file.txt"),
1818 Some("file.txt"),
1819 3,
1820 2,
1821 3,
1822 3, )],
1824 ),
1825 make_commit_diff(
1826 "c3",
1827 vec![make_file_diff(
1828 Some("file.txt"),
1829 Some("file.txt"),
1830 2,
1831 3,
1832 2,
1833 4, )],
1835 ),
1836 ];
1837
1838 let fragmap = build_fragmap(&commits, true);
1839
1840 let all_three_idx = fragmap
1842 .clusters
1843 .iter()
1844 .position(|c| {
1845 c.commit_oids.contains(&"c1".to_string())
1846 && c.commit_oids.contains(&"c2".to_string())
1847 && c.commit_oids.contains(&"c3".to_string())
1848 })
1849 .expect("should have a cluster with all three commits");
1850
1851 let relation = fragmap.cluster_relation(0, 2, all_three_idx);
1853 assert_eq!(relation, SquashRelation::Conflicting);
1854 }
1855
1856 #[test]
1857 fn test_cluster_relation_invalid_indices() {
1858 let commits = vec![
1859 make_commit_diff(
1860 "c1",
1861 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1862 ),
1863 make_commit_diff(
1864 "c2",
1865 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 3, 2, 3, 3)],
1866 ),
1867 ];
1868
1869 let fragmap = build_fragmap(&commits, true);
1870
1871 let relation = fragmap.cluster_relation(0, 10, 0);
1873 assert_eq!(relation, SquashRelation::NoRelation);
1874
1875 let relation = fragmap.cluster_relation(0, 1, 10);
1877 assert_eq!(relation, SquashRelation::NoRelation);
1878 }
1879
1880 #[test]
1881 fn test_cluster_relation_earlier_not_less_than_later() {
1882 let commits = vec![
1883 make_commit_diff(
1884 "c1",
1885 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1886 ),
1887 make_commit_diff(
1888 "c2",
1889 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 3, 2, 3, 3)],
1890 ),
1891 ];
1892
1893 let fragmap = build_fragmap(&commits, true);
1894
1895 let relation = fragmap.cluster_relation(1, 1, 0);
1897 assert_eq!(relation, SquashRelation::NoRelation);
1898
1899 let relation = fragmap.cluster_relation(1, 0, 0);
1901 assert_eq!(relation, SquashRelation::NoRelation);
1902 }
1903
1904 #[test]
1905 fn test_cluster_relation_multiple_clusters() {
1906 let commits = vec![
1908 make_commit_diff(
1909 "c1",
1910 vec![
1911 make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5),
1912 make_file_diff(Some("b.txt"), Some("b.txt"), 1, 0, 1, 5),
1913 ],
1914 ),
1915 make_commit_diff(
1916 "c2",
1917 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 3, 2, 3, 3)],
1918 ),
1919 make_commit_diff(
1920 "c3",
1921 vec![make_file_diff(Some("b.txt"), Some("b.txt"), 3, 2, 3, 3)],
1922 ),
1923 ];
1924
1925 let fragmap = build_fragmap(&commits, true);
1926
1927 let a_cluster_idx = fragmap
1929 .clusters
1930 .iter()
1931 .position(|c| {
1932 c.spans[0].path == "a.txt"
1933 && c.commit_oids.contains(&"c1".to_string())
1934 && c.commit_oids.contains(&"c2".to_string())
1935 })
1936 .expect("should have a shared a.txt cluster");
1937 let b_cluster_idx = fragmap
1938 .clusters
1939 .iter()
1940 .position(|c| {
1941 c.spans[0].path == "b.txt"
1942 && c.commit_oids.contains(&"c1".to_string())
1943 && c.commit_oids.contains(&"c3".to_string())
1944 })
1945 .expect("should have a shared b.txt cluster");
1946
1947 let relation = fragmap.cluster_relation(0, 1, a_cluster_idx);
1949 assert_eq!(relation, SquashRelation::Squashable);
1950
1951 let relation = fragmap.cluster_relation(0, 2, b_cluster_idx);
1953 assert_eq!(relation, SquashRelation::Squashable);
1954
1955 assert!(!fragmap.shares_cluster_with(1, 2));
1957 }
1958
1959 #[test]
1960 fn test_cluster_relation_squashable_with_gap() {
1961 let commits = vec![
1963 make_commit_diff(
1964 "c1",
1965 vec![make_file_diff(
1966 Some("file.txt"),
1967 Some("file.txt"),
1968 1,
1969 0,
1970 1,
1971 5,
1972 )],
1973 ),
1974 make_commit_diff(
1975 "c2",
1976 vec![make_file_diff(
1977 Some("other.txt"),
1978 Some("other.txt"),
1979 1,
1980 0,
1981 1,
1982 5,
1983 )],
1984 ),
1985 make_commit_diff(
1986 "c3",
1987 vec![make_file_diff(
1988 Some("another.txt"),
1989 Some("another.txt"),
1990 1,
1991 0,
1992 1,
1993 5,
1994 )],
1995 ),
1996 make_commit_diff(
1997 "c4",
1998 vec![make_file_diff(
1999 Some("file.txt"),
2000 Some("file.txt"),
2001 3,
2002 2,
2003 3,
2004 3,
2005 )],
2006 ),
2007 ];
2008
2009 let fragmap = build_fragmap(&commits, true);
2010
2011 let file_cluster_idx = fragmap
2013 .clusters
2014 .iter()
2015 .position(|c| {
2016 c.spans[0].path == "file.txt"
2017 && c.commit_oids.contains(&"c1".to_string())
2018 && c.commit_oids.contains(&"c4".to_string())
2019 })
2020 .expect("should have a shared file.txt cluster");
2021
2022 let relation = fragmap.cluster_relation(0, 3, file_cluster_idx);
2024 assert_eq!(relation, SquashRelation::Squashable);
2025 }
2026
2027 fn make_fragmap(commit_ids: &[&str], n_clusters: usize, touches: &[(usize, usize)]) -> FragMap {
2029 let commits: Vec<String> = commit_ids.iter().map(|s| s.to_string()).collect();
2030 let clusters = (0..n_clusters)
2031 .map(|_| SpanCluster {
2032 spans: vec![FileSpan {
2033 path: "f.txt".to_string(),
2034 start_line: 1,
2035 end_line: 1,
2036 }],
2037 commit_oids: vec![],
2038 })
2039 .collect();
2040 let mut matrix = vec![vec![TouchKind::None; n_clusters]; commit_ids.len()];
2041 for &(c, cl) in touches {
2042 matrix[c][cl] = TouchKind::Modified;
2043 }
2044 FragMap {
2045 commits,
2046 clusters,
2047 matrix,
2048 }
2049 }
2050
2051 #[test]
2054 fn squash_target_no_shared_clusters() {
2055 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (1, 1)]);
2057 assert_eq!(fm.squash_target(1), None);
2058 }
2059
2060 #[test]
2061 fn squash_target_adjacent() {
2062 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2064 assert_eq!(fm.squash_target(1), Some(0));
2065 }
2066
2067 #[test]
2068 fn squash_target_with_gap() {
2069 let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (2, 0)]);
2071 assert_eq!(fm.squash_target(2), Some(0));
2072 }
2073
2074 #[test]
2075 fn squash_target_conflicting_returns_none() {
2076 let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (1, 0), (2, 0)]);
2078 assert_eq!(fm.squash_target(2), None);
2079 }
2080
2081 #[test]
2082 fn squash_target_multiple_clusters_same_target() {
2083 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (0, 1), (1, 0), (1, 1)]);
2085 assert_eq!(fm.squash_target(1), Some(0));
2086 }
2087
2088 #[test]
2089 fn squash_target_multiple_clusters_different_targets() {
2090 let fm = make_fragmap(&["c0", "c1", "c2"], 2, &[(0, 0), (1, 1), (2, 0), (2, 1)]);
2094 assert_eq!(fm.squash_target(2), None);
2095 }
2096
2097 #[test]
2098 fn squash_target_earliest_commit_returns_none() {
2099 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2101 assert_eq!(fm.squash_target(0), None);
2102 }
2103
2104 #[test]
2105 fn squash_target_no_clusters_touched() {
2106 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0)]);
2108 assert_eq!(fm.squash_target(1), None);
2109 }
2110
2111 #[test]
2114 fn is_fully_squashable_single_cluster_adjacent() {
2115 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2117 assert!(fm.is_fully_squashable(1));
2118 }
2119
2120 #[test]
2121 fn is_fully_squashable_first_commit_not_squashable() {
2122 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2124 assert!(!fm.is_fully_squashable(0));
2125 }
2126
2127 #[test]
2128 fn is_fully_squashable_multiple_clusters_same_target() {
2129 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (0, 1), (1, 0), (1, 1)]);
2131 assert!(fm.is_fully_squashable(1));
2132 }
2133
2134 #[test]
2135 fn is_fully_squashable_multiple_clusters_different_targets() {
2136 let fm = make_fragmap(&["c0", "c1", "c2"], 2, &[(0, 0), (1, 1), (2, 0), (2, 1)]);
2140 assert!(!fm.is_fully_squashable(2));
2141 }
2142
2143 #[test]
2144 fn is_fully_squashable_conflicting_cluster() {
2145 let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (1, 0), (2, 0)]);
2147 assert!(!fm.is_fully_squashable(2));
2148 }
2149
2150 #[test]
2151 fn is_fully_squashable_no_clusters_touched() {
2152 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0)]);
2154 assert!(!fm.is_fully_squashable(1));
2155 }
2156
2157 #[test]
2160 fn shares_cluster_with_no_shared_cluster() {
2161 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (1, 1)]);
2162 assert!(!fm.shares_cluster_with(0, 1));
2163 }
2164
2165 #[test]
2166 fn shares_cluster_with_adjacent_pair() {
2167 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2168 assert!(fm.shares_cluster_with(0, 1));
2169 }
2170
2171 #[test]
2172 fn shares_cluster_with_blocked_by_middle_commit() {
2173 let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (1, 0), (2, 0)]);
2174 assert!(fm.shares_cluster_with(0, 2));
2175 }
2176
2177 #[test]
2178 fn shares_cluster_with_is_symmetric() {
2179 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2180 assert_eq!(fm.shares_cluster_with(0, 1), fm.shares_cluster_with(1, 0));
2181 }
2182
2183 #[test]
2184 fn shares_cluster_with_same_commit() {
2185 let fm = make_fragmap(&["c0"], 1, &[(0, 0)]);
2186 assert!(!fm.shares_cluster_with(0, 0));
2187 }
2188
2189 #[test]
2190 fn shares_cluster_with_one_shared_is_enough() {
2191 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (0, 1), (1, 1)]);
2193 assert!(fm.shares_cluster_with(0, 1));
2194 }
2195
2196 #[test]
2201 fn build_fragmap_pure_insertion_clusters_with_later_modifier() {
2202 let commits = vec![
2206 make_commit_diff(
2207 "c1",
2208 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 5, 0, 5, 10)],
2209 ),
2210 make_commit_diff(
2211 "c2",
2212 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 7, 3, 7, 3)],
2213 ),
2214 ];
2215 let fm = build_fragmap(&commits, true);
2216 assert!(fm.shares_cluster_with(0, 1));
2217 }
2218
2219 #[test]
2220 fn build_fragmap_far_deletion_does_not_cluster_with_unrelated_modify() {
2221 let commits = vec![
2225 make_commit_diff(
2226 "c1",
2227 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 3, 1, 5)],
2228 ),
2229 make_commit_diff(
2230 "c2",
2231 vec![FileDiff {
2232 old_path: Some("f.rs".to_string()),
2233 new_path: Some("f.rs".to_string()),
2234 status: crate::DeltaStatus::Modified,
2235 hunks: vec![Hunk {
2236 old_start: 50,
2237 old_lines: 3,
2238 new_start: 50,
2239 new_lines: 0,
2240 lines: vec![],
2241 }],
2242 }],
2243 ),
2244 ];
2245 let fm = build_fragmap(&commits, true);
2246 assert!(!fm.shares_cluster_with(0, 1));
2247 }
2248
2249 #[test]
2250 fn build_fragmap_file_rename_cluster_uses_new_path() {
2251 let c1 = CommitDiff {
2253 commit: make_commit_info_with_oid("c1"),
2254 files: vec![FileDiff {
2255 old_path: Some("foo.rs".to_string()),
2256 new_path: Some("bar.rs".to_string()),
2257 status: crate::DeltaStatus::Modified,
2258 hunks: vec![Hunk {
2259 old_start: 5,
2260 old_lines: 3,
2261 new_start: 5,
2262 new_lines: 4,
2263 lines: vec![],
2264 }],
2265 }],
2266 };
2267 let fm = build_fragmap(&[c1], true);
2268 assert_eq!(fm.clusters.len(), 1);
2269 assert_eq!(fm.clusters[0].spans[0].path, "bar.rs");
2270 }
2271
2272 #[test]
2273 fn build_fragmap_single_commit_two_regions_deduped_to_one_column() {
2274 let mut c1 = make_commit_diff(
2277 "c1",
2278 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 0, 1, 5)],
2279 );
2280 c1.files
2281 .push(make_file_diff(Some("f.rs"), Some("f.rs"), 100, 0, 100, 5));
2282 let fm = build_fragmap(&[c1], true);
2283 assert_eq!(fm.clusters.len(), 1);
2284 assert_ne!(fm.matrix[0][0], TouchKind::None);
2285 }
2286
2287 #[test]
2288 fn build_fragmap_no_dedup_keeps_identical_activation_pattern_columns() {
2289 let mut c1 = make_commit_diff(
2293 "c1",
2294 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 0, 1, 5)],
2295 );
2296 c1.files
2297 .push(make_file_diff(Some("f.rs"), Some("f.rs"), 100, 0, 100, 5));
2298 let fm = build_fragmap(&[c1], false);
2299 assert_eq!(fm.clusters.len(), 2);
2300 assert_ne!(fm.matrix[0][0], TouchKind::None);
2301 assert_ne!(fm.matrix[0][1], TouchKind::None);
2302 }
2303
2304 #[test]
2305 fn build_fragmap_two_commits_separate_regions_not_deduped() {
2306 let commits = vec![
2308 make_commit_diff(
2309 "c1",
2310 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 0, 1, 5)],
2311 ),
2312 make_commit_diff(
2313 "c2",
2314 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 100, 0, 100, 5)],
2315 ),
2316 ];
2317 let fm = build_fragmap(&commits, true);
2318 assert_eq!(fm.clusters.len(), 2);
2319 }
2320
2321 #[test]
2322 fn build_fragmap_three_commits_sequential_on_same_region() {
2323 let commits = vec![
2326 make_commit_diff(
2327 "c1",
2328 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 10, 5, 10, 10)],
2329 ),
2330 make_commit_diff(
2331 "c2",
2332 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 12, 3, 12, 3)],
2333 ),
2334 make_commit_diff(
2335 "c3",
2336 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 11, 2, 11, 2)],
2337 ),
2338 ];
2339 let fm = build_fragmap(&commits, true);
2340 assert!(fm.shares_cluster_with(0, 1));
2341 assert!(fm.shares_cluster_with(0, 2));
2342 assert!(fm.shares_cluster_with(1, 2));
2343 }
2344
2345 #[test]
2346 fn build_fragmap_empty_span_does_not_panic() {
2347 let commits = vec![
2351 make_commit_diff(
2352 "c1",
2353 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 10, 1, 10, 1)],
2354 ),
2355 make_commit_diff(
2356 "c2",
2357 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 20, 1, 20, 1)],
2358 ),
2359 ];
2360 let fm = build_fragmap(&commits, true);
2361 assert_eq!(fm.commits.len(), 2);
2362 }
2363}