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
30pub(crate) fn build_rename_map(commit_diffs: &[CommitDiff]) -> HashMap<String, String> {
37 let mut canonical: HashMap<String, String> = HashMap::new();
38 for diff in commit_diffs {
39 for file in &diff.files {
40 if let (Some(old), Some(new)) = (&file.old_path, &file.new_path)
41 && old != new
42 {
43 let root = canonical.get(old).cloned().unwrap_or_else(|| old.clone());
44 canonical.insert(new.clone(), root);
45 }
46 }
47 }
48 canonical
49}
50
51fn canonical_path<'a>(path: &'a str, rename_map: &'a HashMap<String, String>) -> &'a str {
53 rename_map.get(path).map(|s| s.as_str()).unwrap_or(path)
54}
55
56pub(crate) fn collect_file_commits(
62 commit_diffs: &[CommitDiff],
63 rename_map: &HashMap<String, String>,
64) -> HashMap<String, Vec<(usize, Vec<HunkInfo>)>> {
65 let mut file_commits: HashMap<String, Vec<(usize, Vec<HunkInfo>)>> = HashMap::new();
66 for (commit_idx, diff) in commit_diffs.iter().enumerate() {
67 for file in &diff.files {
68 let path = match &file.new_path {
69 Some(p) => p.clone(),
70 None => continue,
71 };
72 let key = canonical_path(&path, rename_map).to_owned();
73 let hunks: Vec<HunkInfo> = file
74 .hunks
75 .iter()
76 .map(|h| HunkInfo {
77 old_start: h.old_start,
78 old_lines: h.old_lines,
79 new_start: h.new_start,
80 new_lines: h.new_lines,
81 })
82 .collect();
83 if !hunks.is_empty() {
84 let entry = file_commits.entry(key).or_default();
85 if let Some(last) = entry.last_mut()
86 && last.0 == commit_idx
87 {
88 last.1.extend(hunks);
89 continue;
90 }
91 entry.push((commit_idx, hunks));
92 }
93 }
94 }
95 file_commits
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct FileSpan {
106 pub path: String,
108 pub start_line: u32,
110 pub end_line: u32,
112}
113
114pub fn extract_spans_propagated(commit_diffs: &[CommitDiff]) -> Vec<(String, Vec<FileSpan>)> {
123 let rename_map = build_rename_map(commit_diffs);
124 let file_commits = collect_file_commits(commit_diffs, &rename_map);
125
126 let mut all_spans: Vec<(usize, FileSpan)> = Vec::new();
128
129 for (path, commits) in &file_commits {
130 for (ci, (commit_idx, hunks)) in commits.iter().enumerate() {
131 for hunk in hunks {
132 if hunk.new_lines == 0 {
133 continue;
134 }
135
136 let mut spans = vec![(hunk.new_start, hunk.new_start + hunk.new_lines)];
138
139 for (_, later_hunks) in &commits[ci + 1..] {
143 spans = spans
144 .into_iter()
145 .flat_map(|(s, e)| split_and_propagate(s, e, later_hunks))
146 .collect();
147 }
148
149 for (start, end) in spans {
151 if end > start {
152 all_spans.push((
153 *commit_idx,
154 FileSpan {
155 path: path.clone(),
156 start_line: start,
157 end_line: end - 1,
158 },
159 ));
160 }
161 }
162 }
163 }
164 }
165
166 let mut result: Vec<(String, Vec<FileSpan>)> = commit_diffs
168 .iter()
169 .map(|d| (d.commit.oid.clone(), Vec::new()))
170 .collect();
171
172 for (commit_idx, span) in all_spans {
173 result[commit_idx].1.push(span);
174 }
175
176 result
177}
178
179#[derive(Debug, Clone)]
181pub(crate) struct HunkInfo {
182 old_start: u32,
183 old_lines: u32,
184 new_start: u32,
185 new_lines: u32,
186}
187
188fn map_line_forward(line: u32, hunks: &[HunkInfo]) -> u32 {
198 let mut cumulative_delta: i64 = 0;
199
200 for hunk in hunks {
201 if line <= hunk.old_start {
205 return (line as i64 + cumulative_delta) as u32;
206 }
207
208 cumulative_delta += hunk.new_lines as i64 - hunk.old_lines as i64;
209 }
210
211 (line as i64 + cumulative_delta) as u32
212}
213
214fn split_and_propagate(start: u32, end: u32, hunks: &[HunkInfo]) -> Vec<(u32, u32)> {
223 let mut remaining = vec![(start, end)];
225
226 for hunk in hunks {
227 let old_start = hunk.old_start;
228 let old_end = hunk.old_start + hunk.old_lines;
229
230 let mut next = Vec::new();
231 for (s, e) in remaining {
232 if e <= old_start || s >= old_end {
233 next.push((s, e));
234 } else {
235 if s < old_start {
236 next.push((s, old_start));
237 }
238 if e > old_end {
239 next.push((old_end, e));
240 }
241 }
242 }
243 remaining = next;
244 }
245
246 remaining
248 .into_iter()
249 .filter(|(s, e)| e > s)
250 .map(|(s, e)| (map_line_forward(s, hunks), map_line_forward(e, hunks)))
251 .filter(|(s, e)| e > s)
252 .collect()
253}
254
255pub fn extract_spans(commit_diff: &CommitDiff) -> Vec<FileSpan> {
258 let mut spans = Vec::new();
259
260 for file in &commit_diff.files {
261 let path = match &file.new_path {
262 Some(p) => p.clone(),
263 None => continue,
264 };
265
266 for hunk in &file.hunks {
267 if hunk.new_lines == 0 {
268 continue;
269 }
270
271 spans.push(FileSpan {
272 path: path.to_string(),
273 start_line: hunk.new_start,
274 end_line: hunk.new_start + hunk.new_lines - 1,
275 });
276 }
277 }
278
279 spans
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
286pub enum TouchKind {
287 Added,
289 Modified,
291 Deleted,
293 None,
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct SpanCluster {
303 pub spans: Vec<FileSpan>,
305 pub commit_oids: Vec<String>,
307}
308
309#[derive(Debug, Clone)]
315pub struct FragMap {
316 pub commits: Vec<String>,
318 pub clusters: Vec<SpanCluster>,
320 pub matrix: Vec<Vec<TouchKind>>,
322}
323
324pub fn build_fragmap(commit_diffs: &[CommitDiff], deduplicate: bool) -> FragMap {
338 let rename_map = build_rename_map(commit_diffs);
339 let file_commits = collect_file_commits(commit_diffs, &rename_map);
340
341 let mut clusters: Vec<SpanCluster> = Vec::new();
342
343 let mut sorted_paths: Vec<&String> = file_commits.keys().collect();
344 sorted_paths.sort();
345
346 for path in sorted_paths {
347 let commits_for_file = &file_commits[path];
348 clusters.extend(build_file_clusters(path, commits_for_file, commit_diffs));
349 }
350
351 if deduplicate {
352 deduplicate_clusters(&mut clusters);
353 }
354
355 let commits: Vec<String> = commit_diffs.iter().map(|d| d.commit.oid.clone()).collect();
356 let matrix = build_matrix(&commits, &clusters, commit_diffs, &rename_map);
357
358 FragMap {
359 commits,
360 clusters,
361 matrix,
362 }
363}
364
365pub fn assign_hunk_groups(
379 commit_diffs: &[CommitDiff],
380 commit_oid: &str,
381) -> Option<(usize, HashMap<String, Vec<usize>>)> {
382 let k_idx = commit_diffs
383 .iter()
384 .position(|d| d.commit.oid == commit_oid)?;
385
386 let rename_map = build_rename_map(commit_diffs);
387 let file_commits = collect_file_commits(commit_diffs, &rename_map);
388
389 let mut sorted_paths: Vec<&String> = file_commits.keys().collect();
390 sorted_paths.sort();
391
392 let mut pre_dedup_clusters: Vec<SpanCluster> = Vec::new();
399 let mut k_hunk_prededup: HashMap<String, Vec<Vec<usize>>> = HashMap::new();
400
401 for path in &sorted_paths {
402 let commits_for_file = &file_commits[*path];
403 let file_cluster_offset = pre_dedup_clusters.len();
404
405 let has_k = commits_for_file.iter().any(|(idx, _)| *idx == k_idx);
406 let (file_clusters, hunk_to_local) = if has_k {
407 build_file_clusters_and_assign_hunks(path, commits_for_file, commit_diffs, k_idx)
408 } else {
409 (
410 build_file_clusters(path, commits_for_file, commit_diffs),
411 vec![],
412 )
413 };
414
415 if has_k && !hunk_to_local.is_empty() {
416 let global_indices: Vec<Vec<usize>> = hunk_to_local
417 .iter()
418 .map(|locals| {
419 locals
420 .iter()
421 .map(|&local| file_cluster_offset + local)
422 .collect()
423 })
424 .collect();
425 k_hunk_prededup.insert((*path).clone(), global_indices);
426 }
427
428 pre_dedup_clusters.extend(file_clusters);
429 }
430
431 for cluster in &mut pre_dedup_clusters {
434 cluster.commit_oids.sort();
435 }
436 let mut seen_patterns: Vec<Vec<String>> = Vec::new();
437 let mut group_idx_for_prededup: Vec<usize> = Vec::with_capacity(pre_dedup_clusters.len());
438 for cluster in &pre_dedup_clusters {
439 if let Some(pos) = seen_patterns.iter().position(|p| p == &cluster.commit_oids) {
440 group_idx_for_prededup.push(pos);
441 } else {
442 let new_idx = seen_patterns.len();
443 seen_patterns.push(cluster.commit_oids.clone());
444 group_idx_for_prededup.push(new_idx);
445 }
446 }
447 let group_count = seen_patterns.len();
448
449 let mut candidates: Vec<(String, usize, Vec<usize>)> = Vec::new();
465 for (path, prededup_multi) in &k_hunk_prededup {
466 for (h, prededup_indices) in prededup_multi.iter().enumerate() {
467 let mut groups: Vec<usize> = prededup_indices
468 .iter()
469 .map(|&pre_idx| group_idx_for_prededup[pre_idx])
470 .collect();
471 groups.sort();
472 groups.dedup();
473 if groups.is_empty() {
474 groups.push(0);
475 }
476 candidates.push((path.clone(), h, groups));
477 }
478 }
479
480 let all_touched: std::collections::BTreeSet<usize> = candidates
482 .iter()
483 .flat_map(|(_, _, gs)| gs.iter().copied())
484 .collect();
485
486 let mut assigned: Vec<Option<usize>> = vec![None; candidates.len()];
487
488 for (i, (_, _, groups)) in candidates.iter().enumerate() {
490 if groups.len() == 1 {
491 assigned[i] = Some(groups[0]);
492 }
493 }
494
495 let mut covered: std::collections::HashSet<usize> =
497 assigned.iter().filter_map(|a| *a).collect();
498 for &g in &all_touched {
499 if covered.contains(&g) {
500 continue;
501 }
502 let best = candidates
503 .iter()
504 .enumerate()
505 .filter(|(i, (_, _, groups))| assigned[*i].is_none() && groups.contains(&g))
506 .min_by_key(|(_, (_, _, groups))| groups.len())
507 .map(|(i, _)| i);
508 if let Some(i) = best {
509 assigned[i] = Some(g);
510 covered.insert(g);
511 }
512 }
513
514 for (i, (_, _, groups)) in candidates.iter().enumerate() {
516 if assigned[i].is_none() {
517 assigned[i] = Some(groups[0]);
518 }
519 }
520
521 let mut assignment: HashMap<String, Vec<usize>> = HashMap::new();
523 for (i, (path, h, _)) in candidates.iter().enumerate() {
524 let entry = assignment
525 .entry(path.clone())
526 .or_insert_with(|| vec![0; k_hunk_prededup[path].len()]);
527 entry[*h] = assigned[i].unwrap_or(0);
528 }
529
530 Some((group_count, assignment))
531}
532
533impl FragMap {
534 pub fn squash_target(&self, commit_idx: usize) -> Option<usize> {
540 let mut target: Option<usize> = None;
541
542 for cluster_idx in 0..self.clusters.len() {
543 if self.matrix[commit_idx][cluster_idx] == TouchKind::None {
544 continue;
545 }
546
547 let earlier = (0..commit_idx).find(|&i| self.matrix[i][cluster_idx] != TouchKind::None);
548
549 let earlier_idx = earlier?;
550
551 match self.cluster_relation(earlier_idx, commit_idx, cluster_idx) {
552 SquashRelation::Squashable => match target {
553 None => target = Some(earlier_idx),
554 Some(t) if t == earlier_idx => {}
555 Some(_) => return None,
556 },
557 _ => return None,
558 }
559 }
560
561 target
562 }
563
564 pub fn is_fully_squashable(&self, commit_idx: usize) -> bool {
566 self.squash_target(commit_idx).is_some()
567 }
568
569 pub fn shares_cluster_with(&self, a: usize, b: usize) -> bool {
571 if a == b {
572 return false;
573 }
574 (0..self.clusters.len())
575 .any(|c| self.matrix[a][c] != TouchKind::None && self.matrix[b][c] != TouchKind::None)
576 }
577
578 pub fn pairwise_squash_relation(&self, a: usize, b: usize) -> SquashRelation {
586 if a == b || a >= self.commits.len() || b >= self.commits.len() {
587 return SquashRelation::NoRelation;
588 }
589 let (earlier, later) = if a < b { (a, b) } else { (b, a) };
590
591 let mut has_shared = false;
592 for c in 0..self.clusters.len() {
593 if self.matrix[earlier][c] == TouchKind::None
594 || self.matrix[later][c] == TouchKind::None
595 {
596 continue;
597 }
598 has_shared = true;
599 if self.cluster_relation(earlier, later, c) == SquashRelation::Conflicting {
600 return SquashRelation::Conflicting;
601 }
602 }
603
604 if has_shared {
605 SquashRelation::Squashable
606 } else {
607 SquashRelation::NoRelation
608 }
609 }
610
611 pub fn cluster_relation(
617 &self,
618 earlier_commit_idx: usize,
619 later_commit_idx: usize,
620 cluster_idx: usize,
621 ) -> SquashRelation {
622 if earlier_commit_idx >= self.commits.len()
623 || later_commit_idx >= self.commits.len()
624 || cluster_idx >= self.clusters.len()
625 {
626 return SquashRelation::NoRelation;
627 }
628
629 if earlier_commit_idx >= later_commit_idx {
630 return SquashRelation::NoRelation;
631 }
632
633 let earlier_touches = self.matrix[earlier_commit_idx][cluster_idx] != TouchKind::None;
634 let later_touches = self.matrix[later_commit_idx][cluster_idx] != TouchKind::None;
635
636 if !earlier_touches || !later_touches {
637 return SquashRelation::NoRelation;
638 }
639
640 for commit_idx in (earlier_commit_idx + 1)..later_commit_idx {
641 if self.matrix[commit_idx][cluster_idx] != TouchKind::None {
642 return SquashRelation::Conflicting;
643 }
644 }
645
646 SquashRelation::Squashable
647 }
648
649 pub fn connector_squashable(
660 &self,
661 commit_idx: usize,
662 cluster_idx: usize,
663 scope: SquashableScope,
664 ) -> Option<bool> {
665 let earlier = (0..commit_idx).find(|&i| self.matrix[i][cluster_idx] != TouchKind::None)?;
666
667 match scope {
668 SquashableScope::Group => Some(
669 self.cluster_relation(earlier, commit_idx, cluster_idx)
670 == SquashRelation::Squashable,
671 ),
672 SquashableScope::Commit => {
673 Some(self.squash_target(commit_idx).is_some_and(|t| t == earlier))
674 }
675 }
676 }
677}
678
679fn build_matrix(
684 commits: &[String],
685 clusters: &[SpanCluster],
686 commit_diffs: &[CommitDiff],
687 rename_map: &HashMap<String, String>,
688) -> Vec<Vec<TouchKind>> {
689 let mut matrix = vec![vec![TouchKind::None; clusters.len()]; commits.len()];
690
691 for (commit_idx, commit_oid) in commits.iter().enumerate() {
692 let commit_diff = &commit_diffs[commit_idx];
693
694 for (cluster_idx, cluster) in clusters.iter().enumerate() {
695 if cluster.commit_oids.contains(commit_oid) {
696 matrix[commit_idx][cluster_idx] =
697 determine_touch_kind(commit_diff, cluster, rename_map);
698 }
699 }
700 }
701
702 matrix
703}
704
705fn determine_touch_kind(
711 commit_diff: &CommitDiff,
712 cluster: &SpanCluster,
713 rename_map: &HashMap<String, String>,
714) -> TouchKind {
715 for cluster_span in &cluster.spans {
716 let cluster_canonical = canonical_path(&cluster_span.path, rename_map);
717 for file in &commit_diff.files {
718 let file_path = file.new_path.as_ref().or(file.old_path.as_ref());
719 let matches = file_path
720 .map(|p| canonical_path(p, rename_map) == cluster_canonical)
721 .unwrap_or(false);
722 if matches {
723 if file.old_path.is_none() && file.new_path.is_some() {
724 return TouchKind::Added;
725 } else if file.old_path.is_some() && file.new_path.is_none() {
726 return TouchKind::Deleted;
727 } else {
728 return TouchKind::Modified;
729 }
730 }
731 }
732 }
733
734 TouchKind::None
735}
736
737#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
741pub enum SquashableScope {
742 #[default]
746 Group,
747 Commit,
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
759pub enum SquashRelation {
760 NoRelation,
762 Squashable,
765 Conflicting,
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773 use crate::{CommitDiff, CommitInfo, FileDiff, Hunk};
774
775 fn make_commit_info() -> CommitInfo {
776 CommitInfo {
777 oid: "abc123".to_string(),
778 summary: "Test commit".to_string(),
779 author: Some("Test Author".to_string()),
780 date: Some("123456789".to_string()),
781 parent_oids: vec![],
782 message: "Test commit".to_string(),
783 author_email: Some("test@example.com".to_string()),
784 author_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
785 committer: Some("Test Committer".to_string()),
786 committer_email: Some("committer@example.com".to_string()),
787 commit_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
788 }
789 }
790
791 #[test]
792 fn test_extract_spans_single_hunk() {
793 let commit_diff = CommitDiff {
794 commit: make_commit_info(),
795 files: vec![FileDiff {
796 old_path: Some("file.txt".to_string()),
797 new_path: Some("file.txt".to_string()),
798 status: crate::DeltaStatus::Modified,
799 hunks: vec![Hunk {
800 old_start: 10,
801 old_lines: 3,
802 new_start: 10,
803 new_lines: 5,
804 lines: vec![],
805 }],
806 }],
807 };
808
809 let spans = extract_spans(&commit_diff);
810
811 assert_eq!(spans.len(), 1);
812 assert_eq!(spans[0].path, "file.txt");
813 assert_eq!(spans[0].start_line, 10);
814 assert_eq!(spans[0].end_line, 14); }
816
817 #[test]
818 fn test_extract_spans_multiple_hunks() {
819 let commit_diff = CommitDiff {
820 commit: make_commit_info(),
821 files: vec![FileDiff {
822 old_path: Some("file.txt".to_string()),
823 new_path: Some("file.txt".to_string()),
824 status: crate::DeltaStatus::Modified,
825 hunks: vec![
826 Hunk {
827 old_start: 5,
828 old_lines: 2,
829 new_start: 5,
830 new_lines: 3,
831 lines: vec![],
832 },
833 Hunk {
834 old_start: 20,
835 old_lines: 1,
836 new_start: 21,
837 new_lines: 2,
838 lines: vec![],
839 },
840 ],
841 }],
842 };
843
844 let spans = extract_spans(&commit_diff);
845
846 assert_eq!(spans.len(), 2);
847 assert_eq!(spans[0].path, "file.txt");
848 assert_eq!(spans[0].start_line, 5);
849 assert_eq!(spans[0].end_line, 7); assert_eq!(spans[1].path, "file.txt");
852 assert_eq!(spans[1].start_line, 21);
853 assert_eq!(spans[1].end_line, 22); }
855
856 #[test]
857 fn test_extract_spans_multiple_files() {
858 let commit_diff = CommitDiff {
859 commit: make_commit_info(),
860 files: vec![
861 FileDiff {
862 old_path: Some("a.txt".to_string()),
863 new_path: Some("a.txt".to_string()),
864 status: crate::DeltaStatus::Modified,
865 hunks: vec![Hunk {
866 old_start: 1,
867 old_lines: 1,
868 new_start: 1,
869 new_lines: 2,
870 lines: vec![],
871 }],
872 },
873 FileDiff {
874 old_path: Some("b.txt".to_string()),
875 new_path: Some("b.txt".to_string()),
876 status: crate::DeltaStatus::Modified,
877 hunks: vec![Hunk {
878 old_start: 10,
879 old_lines: 3,
880 new_start: 10,
881 new_lines: 4,
882 lines: vec![],
883 }],
884 },
885 ],
886 };
887
888 let spans = extract_spans(&commit_diff);
889
890 assert_eq!(spans.len(), 2);
891 assert_eq!(spans[0].path, "a.txt");
892 assert_eq!(spans[0].start_line, 1);
893 assert_eq!(spans[0].end_line, 2);
894
895 assert_eq!(spans[1].path, "b.txt");
896 assert_eq!(spans[1].start_line, 10);
897 assert_eq!(spans[1].end_line, 13);
898 }
899
900 #[test]
901 fn test_extract_spans_skips_deleted_files() {
902 let commit_diff = CommitDiff {
903 commit: make_commit_info(),
904 files: vec![
905 FileDiff {
906 old_path: Some("file.txt".to_string()),
907 new_path: Some("file.txt".to_string()),
908 status: crate::DeltaStatus::Modified,
909 hunks: vec![Hunk {
910 old_start: 1,
911 old_lines: 1,
912 new_start: 1,
913 new_lines: 2,
914 lines: vec![],
915 }],
916 },
917 FileDiff {
918 old_path: Some("deleted.txt".to_string()),
919 new_path: None, status: crate::DeltaStatus::Deleted,
921 hunks: vec![Hunk {
922 old_start: 1,
923 old_lines: 5,
924 new_start: 0,
925 new_lines: 0,
926 lines: vec![],
927 }],
928 },
929 ],
930 };
931
932 let spans = extract_spans(&commit_diff);
933
934 assert_eq!(spans.len(), 1);
936 assert_eq!(spans[0].path, "file.txt");
937 }
938
939 #[test]
940 fn test_extract_spans_skips_empty_hunks() {
941 let commit_diff = CommitDiff {
942 commit: make_commit_info(),
943 files: vec![FileDiff {
944 old_path: Some("file.txt".to_string()),
945 new_path: Some("file.txt".to_string()),
946 status: crate::DeltaStatus::Modified,
947 hunks: vec![
948 Hunk {
949 old_start: 5,
950 old_lines: 2,
951 new_start: 5,
952 new_lines: 3,
953 lines: vec![],
954 },
955 Hunk {
956 old_start: 10,
957 old_lines: 1,
958 new_start: 8,
959 new_lines: 0, lines: vec![],
961 },
962 ],
963 }],
964 };
965
966 let spans = extract_spans(&commit_diff);
967
968 assert_eq!(spans.len(), 1);
970 assert_eq!(spans[0].start_line, 5);
971 assert_eq!(spans[0].end_line, 7);
972 }
973
974 #[test]
975 fn test_extract_spans_added_file() {
976 let commit_diff = CommitDiff {
977 commit: make_commit_info(),
978 files: vec![FileDiff {
979 old_path: None, new_path: Some("new_file.txt".to_string()),
981 status: crate::DeltaStatus::Added,
982 hunks: vec![Hunk {
983 old_start: 0,
984 old_lines: 0,
985 new_start: 1,
986 new_lines: 10,
987 lines: vec![],
988 }],
989 }],
990 };
991
992 let spans = extract_spans(&commit_diff);
993
994 assert_eq!(spans.len(), 1);
995 assert_eq!(spans[0].path, "new_file.txt");
996 assert_eq!(spans[0].start_line, 1);
997 assert_eq!(spans[0].end_line, 10);
998 }
999
1000 #[test]
1001 fn test_extract_spans_single_line_change() {
1002 let commit_diff = CommitDiff {
1003 commit: make_commit_info(),
1004 files: vec![FileDiff {
1005 old_path: Some("file.txt".to_string()),
1006 new_path: Some("file.txt".to_string()),
1007 status: crate::DeltaStatus::Modified,
1008 hunks: vec![Hunk {
1009 old_start: 42,
1010 old_lines: 1,
1011 new_start: 42,
1012 new_lines: 1,
1013 lines: vec![],
1014 }],
1015 }],
1016 };
1017
1018 let spans = extract_spans(&commit_diff);
1019
1020 assert_eq!(spans.len(), 1);
1021 assert_eq!(spans[0].start_line, 42);
1022 assert_eq!(spans[0].end_line, 42); }
1024
1025 #[test]
1026 fn test_extract_spans_empty_commit() {
1027 let commit_diff = CommitDiff {
1028 commit: make_commit_info(),
1029 files: vec![],
1030 };
1031
1032 let spans = extract_spans(&commit_diff);
1033
1034 assert_eq!(spans.len(), 0);
1035 }
1036
1037 #[test]
1038 fn test_map_line_forward_before_hunk() {
1039 let hunks = vec![HunkInfo {
1040 old_start: 10,
1041 old_lines: 5,
1042 new_start: 10,
1043 new_lines: 8,
1044 }];
1045 assert_eq!(map_line_forward(5, &hunks), 5);
1046 }
1047
1048 #[test]
1049 fn test_map_line_forward_after_hunk() {
1050 let hunks = vec![HunkInfo {
1051 old_start: 10,
1052 old_lines: 5,
1053 new_start: 10,
1054 new_lines: 8,
1055 }];
1056 assert_eq!(map_line_forward(20, &hunks), 23);
1058 }
1059
1060 #[test]
1061 fn test_split_and_propagate_overlap() {
1062 let hunks = vec![HunkInfo {
1063 old_start: 10,
1064 old_lines: 5,
1065 new_start: 10,
1066 new_lines: 8,
1067 }];
1068 let result = split_and_propagate(8, 20, &hunks);
1073 assert_eq!(result, vec![(8, 10), (18, 23)]);
1074 }
1075
1076 #[test]
1077 fn test_map_line_forward_two_hunks() {
1078 let hunks = vec![
1079 HunkInfo {
1080 old_start: 10,
1081 old_lines: 5,
1082 new_start: 10,
1083 new_lines: 8,
1084 },
1085 HunkInfo {
1086 old_start: 30,
1087 old_lines: 3,
1088 new_start: 33,
1089 new_lines: 5,
1090 },
1091 ];
1092 assert_eq!(map_line_forward(25, &hunks), 28);
1094 assert_eq!(map_line_forward(40, &hunks), 45);
1096 }
1097
1098 #[test]
1099 fn test_propagation_sequential_commits_same_file() {
1100 let commits = vec![
1103 CommitDiff {
1104 commit: make_commit_info_with_oid("c1"),
1105 files: vec![FileDiff {
1106 old_path: Some("f.rs".to_string()),
1107 new_path: Some("f.rs".to_string()),
1108 status: crate::DeltaStatus::Modified,
1109 hunks: vec![Hunk {
1110 old_start: 10,
1111 old_lines: 5,
1112 new_start: 10,
1113 new_lines: 8,
1114 lines: vec![],
1115 }],
1116 }],
1117 },
1118 CommitDiff {
1119 commit: make_commit_info_with_oid("c2"),
1120 files: vec![FileDiff {
1121 old_path: Some("f.rs".to_string()),
1122 new_path: Some("f.rs".to_string()),
1123 status: crate::DeltaStatus::Modified,
1124 hunks: vec![Hunk {
1125 old_start: 50,
1126 old_lines: 5,
1127 new_start: 50,
1128 new_lines: 5,
1129 lines: vec![],
1130 }],
1131 }],
1132 },
1133 ];
1134
1135 let fm = build_fragmap(&commits, true);
1136 assert!(!fm.shares_cluster_with(0, 1));
1137 }
1138
1139 #[test]
1140 fn test_propagation_overlapping_hunks_are_related() {
1141 let commits = vec![
1144 CommitDiff {
1145 commit: make_commit_info_with_oid("c1"),
1146 files: vec![FileDiff {
1147 old_path: Some("f.rs".to_string()),
1148 new_path: Some("f.rs".to_string()),
1149 status: crate::DeltaStatus::Modified,
1150 hunks: vec![Hunk {
1151 old_start: 10,
1152 old_lines: 5,
1153 new_start: 10,
1154 new_lines: 55,
1155 lines: vec![],
1156 }],
1157 }],
1158 },
1159 CommitDiff {
1160 commit: make_commit_info_with_oid("c2"),
1161 files: vec![FileDiff {
1162 old_path: Some("f.rs".to_string()),
1163 new_path: Some("f.rs".to_string()),
1164 status: crate::DeltaStatus::Modified,
1165 hunks: vec![Hunk {
1166 old_start: 30,
1167 old_lines: 10,
1168 new_start: 30,
1169 new_lines: 10,
1170 lines: vec![],
1171 }],
1172 }],
1173 },
1174 ];
1175
1176 let fm = build_fragmap(&commits, true);
1177 assert!(fm.shares_cluster_with(0, 1));
1178 }
1179
1180 #[test]
1181 fn test_propagation_distant_changes_not_related() {
1182 let commits = vec![
1184 CommitDiff {
1185 commit: make_commit_info_with_oid("c1"),
1186 files: vec![FileDiff {
1187 old_path: Some("f.rs".to_string()),
1188 new_path: Some("f.rs".to_string()),
1189 status: crate::DeltaStatus::Modified,
1190 hunks: vec![Hunk {
1191 old_start: 10,
1192 old_lines: 3,
1193 new_start: 10,
1194 new_lines: 5,
1195 lines: vec![],
1196 }],
1197 }],
1198 },
1199 CommitDiff {
1200 commit: make_commit_info_with_oid("c2"),
1201 files: vec![FileDiff {
1202 old_path: Some("f.rs".to_string()),
1203 new_path: Some("f.rs".to_string()),
1204 status: crate::DeltaStatus::Modified,
1205 hunks: vec![Hunk {
1206 old_start: 200,
1207 old_lines: 5,
1208 new_start: 202,
1209 new_lines: 5,
1210 lines: vec![],
1211 }],
1212 }],
1213 },
1214 ];
1215
1216 let fm = build_fragmap(&commits, true);
1217 assert!(!fm.shares_cluster_with(0, 1));
1218 }
1219
1220 fn make_commit_info_with_oid(oid: &str) -> CommitInfo {
1223 CommitInfo {
1224 oid: oid.to_string(),
1225 summary: format!("Commit {}", oid),
1226 author: Some("Test Author".to_string()),
1227 date: Some("123456789".to_string()),
1228 parent_oids: vec![],
1229 message: format!("Commit {}", oid),
1230 author_email: Some("test@example.com".to_string()),
1231 author_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
1232 committer: Some("Test Committer".to_string()),
1233 committer_email: Some("committer@example.com".to_string()),
1234 commit_date: Some(time::OffsetDateTime::from_unix_timestamp(123456789).unwrap()),
1235 }
1236 }
1237
1238 fn make_file_diff(
1239 old_path: Option<&str>,
1240 new_path: Option<&str>,
1241 old_start: u32,
1242 old_lines: u32,
1243 new_start: u32,
1244 new_lines: u32,
1245 ) -> FileDiff {
1246 FileDiff {
1247 old_path: old_path.map(|s| s.to_string()),
1248 new_path: new_path.map(|s| s.to_string()),
1249 status: crate::DeltaStatus::Modified,
1250 hunks: vec![Hunk {
1251 old_start,
1252 old_lines,
1253 new_start,
1254 new_lines,
1255 lines: vec![],
1256 }],
1257 }
1258 }
1259
1260 fn make_commit_diff(oid: &str, files: Vec<FileDiff>) -> CommitDiff {
1261 CommitDiff {
1262 commit: make_commit_info_with_oid(oid),
1263 files,
1264 }
1265 }
1266
1267 #[test]
1270 fn test_build_fragmap_empty_commits() {
1271 let fragmap = build_fragmap(&[], true);
1272
1273 assert_eq!(fragmap.commits.len(), 0);
1274 assert_eq!(fragmap.clusters.len(), 0);
1275 assert_eq!(fragmap.matrix.len(), 0);
1276 }
1277
1278 #[test]
1279 fn test_build_fragmap_single_commit() {
1280 let commits = vec![make_commit_diff(
1281 "c1",
1282 vec![make_file_diff(
1283 None, Some("file.txt"),
1285 0,
1286 0,
1287 1,
1288 3,
1289 )],
1290 )];
1291
1292 let fragmap = build_fragmap(&commits, true);
1293
1294 assert_eq!(fragmap.commits.len(), 1);
1295 assert_eq!(fragmap.commits[0], "c1");
1296
1297 assert_eq!(fragmap.clusters.len(), 1);
1299 assert_eq!(fragmap.clusters[0].spans.len(), 1);
1300 assert_eq!(fragmap.clusters[0].spans[0].path, "file.txt");
1301 assert_eq!(fragmap.clusters[0].commit_oids, vec!["c1"]);
1302
1303 assert_eq!(fragmap.matrix.len(), 1);
1305 assert_eq!(fragmap.matrix[0].len(), 1);
1306 assert_eq!(fragmap.matrix[0][0], TouchKind::Added);
1307 }
1308
1309 #[test]
1310 fn test_build_fragmap_overlapping_spans_merge() {
1311 let commits = vec![
1313 make_commit_diff(
1314 "c1",
1315 vec![make_file_diff(
1316 Some("file.txt"),
1317 Some("file.txt"),
1318 1,
1319 0,
1320 1,
1321 5, )],
1323 ),
1324 make_commit_diff(
1325 "c2",
1326 vec![make_file_diff(
1327 Some("file.txt"),
1328 Some("file.txt"),
1329 3,
1330 3,
1331 3,
1332 4, )],
1334 ),
1335 ];
1336
1337 let fragmap = build_fragmap(&commits, true);
1338
1339 assert_eq!(fragmap.commits.len(), 2);
1340
1341 assert!(fragmap.shares_cluster_with(0, 1));
1343
1344 let shared = fragmap.clusters.iter().any(|c| {
1346 c.commit_oids.contains(&"c1".to_string()) && c.commit_oids.contains(&"c2".to_string())
1347 });
1348 assert!(shared);
1349
1350 let shared_idx = fragmap
1352 .clusters
1353 .iter()
1354 .position(|c| {
1355 c.commit_oids.contains(&"c1".to_string())
1356 && c.commit_oids.contains(&"c2".to_string())
1357 })
1358 .unwrap();
1359 assert_ne!(fragmap.matrix[0][shared_idx], TouchKind::None);
1360 assert_ne!(fragmap.matrix[1][shared_idx], TouchKind::None);
1361 }
1362
1363 #[test]
1364 fn test_build_fragmap_non_overlapping_separate_clusters() {
1365 let commits = vec![
1367 make_commit_diff(
1368 "c1",
1369 vec![make_file_diff(
1370 Some("file.txt"),
1371 Some("file.txt"),
1372 1,
1373 0,
1374 1,
1375 5, )],
1377 ),
1378 make_commit_diff(
1379 "c2",
1380 vec![make_file_diff(
1381 Some("file.txt"),
1382 Some("file.txt"),
1383 10,
1384 3,
1385 10,
1386 4, )],
1388 ),
1389 ];
1390
1391 let fragmap = build_fragmap(&commits, true);
1392
1393 assert_eq!(fragmap.commits.len(), 2);
1394
1395 assert_eq!(fragmap.clusters.len(), 2);
1397
1398 assert_eq!(fragmap.matrix.len(), 2);
1400 assert_eq!(fragmap.matrix[0].len(), 2);
1401 assert_eq!(fragmap.matrix[1].len(), 2);
1402
1403 assert_ne!(fragmap.matrix[0][0], TouchKind::None);
1405 assert_eq!(fragmap.matrix[0][1], TouchKind::None);
1406
1407 assert_eq!(fragmap.matrix[1][0], TouchKind::None);
1409 assert_ne!(fragmap.matrix[1][1], TouchKind::None);
1410 }
1411
1412 #[test]
1413 fn test_build_fragmap_adjacent_spans_stay_separate() {
1414 let commits = vec![
1417 make_commit_diff(
1418 "c1",
1419 vec![make_file_diff(
1420 Some("file.txt"),
1421 Some("file.txt"),
1422 1,
1423 0,
1424 1,
1425 5, )],
1427 ),
1428 make_commit_diff(
1429 "c2",
1430 vec![make_file_diff(
1431 Some("file.txt"),
1432 Some("file.txt"),
1433 6,
1434 2,
1435 6,
1436 3, )],
1438 ),
1439 ];
1440
1441 let fragmap = build_fragmap(&commits, true);
1442
1443 assert_eq!(fragmap.commits.len(), 2);
1444
1445 assert_eq!(fragmap.clusters.len(), 2);
1447 }
1448
1449 #[test]
1450 fn test_no_snowball_effect_on_cluster_ranges() {
1451 let commits = vec![
1456 make_commit_diff(
1457 "c1",
1458 vec![make_file_diff(
1459 Some("file.txt"),
1460 Some("file.txt"),
1461 1,
1462 0,
1463 1,
1464 5, )],
1466 ),
1467 make_commit_diff(
1468 "c2",
1469 vec![make_file_diff(
1470 Some("file.txt"),
1471 Some("file.txt"),
1472 3,
1473 5,
1474 3,
1475 10, )],
1477 ),
1478 make_commit_diff(
1479 "c3",
1480 vec![make_file_diff(
1481 Some("file.txt"),
1482 Some("file.txt"),
1483 50,
1484 3,
1485 50,
1486 4, )],
1488 ),
1489 ];
1490
1491 let fragmap = build_fragmap(&commits, true);
1492
1493 assert!(fragmap.shares_cluster_with(0, 1));
1495 assert!(!fragmap.shares_cluster_with(0, 2));
1496 assert!(!fragmap.shares_cluster_with(1, 2));
1497 }
1498
1499 #[test]
1500 fn test_different_functions_same_file_separate_clusters() {
1501 let commits = vec![
1505 make_commit_diff(
1506 "c1",
1507 vec![make_file_diff(
1508 Some("lib.rs"),
1509 Some("lib.rs"),
1510 10,
1511 3,
1512 10,
1513 5, )],
1515 ),
1516 make_commit_diff(
1517 "c2",
1518 vec![make_file_diff(
1519 Some("lib.rs"),
1520 Some("lib.rs"),
1521 80,
1522 2,
1523 80,
1524 4, )],
1526 ),
1527 ];
1528
1529 let fragmap = build_fragmap(&commits, true);
1530
1531 assert_eq!(fragmap.clusters.len(), 2);
1533
1534 assert!(!fragmap.is_fully_squashable(0));
1536 assert!(!fragmap.is_fully_squashable(1));
1537
1538 assert!(!fragmap.shares_cluster_with(0, 1));
1540 }
1541
1542 #[test]
1543 fn test_build_fragmap_touchkind_added() {
1544 let commits = vec![make_commit_diff(
1546 "c1",
1547 vec![make_file_diff(
1548 None, Some("new_file.txt"),
1550 0,
1551 0,
1552 1,
1553 10,
1554 )],
1555 )];
1556
1557 let fragmap = build_fragmap(&commits, true);
1558
1559 assert_eq!(fragmap.matrix[0][0], TouchKind::Added);
1560 }
1561
1562 #[test]
1563 fn test_build_fragmap_touchkind_modified() {
1564 let commits = vec![make_commit_diff(
1566 "c1",
1567 vec![make_file_diff(
1568 Some("file.txt"),
1569 Some("file.txt"),
1570 10,
1571 5, 10,
1573 6, )],
1575 )];
1576
1577 let fragmap = build_fragmap(&commits, true);
1578
1579 assert_eq!(fragmap.matrix[0][0], TouchKind::Modified);
1580 }
1581
1582 #[test]
1583 fn test_build_fragmap_touchkind_deleted() {
1584 let commits = vec![make_commit_diff(
1590 "c1",
1591 vec![make_file_diff(
1592 Some("file.txt"),
1593 Some("file.txt"),
1594 10,
1595 5,
1596 10,
1597 2, )],
1599 )];
1600
1601 let fragmap = build_fragmap(&commits, true);
1602
1603 assert_eq!(fragmap.commits.len(), 1);
1605 assert_eq!(fragmap.clusters.len(), 1);
1606 }
1607
1608 #[test]
1609 fn test_build_fragmap_multiple_files_separate_clusters() {
1610 let commits = vec![
1612 make_commit_diff(
1613 "c1",
1614 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1615 ),
1616 make_commit_diff(
1617 "c2",
1618 vec![make_file_diff(
1619 Some("b.txt"),
1620 Some("b.txt"),
1621 1,
1622 0,
1623 1,
1624 5, )],
1626 ),
1627 ];
1628
1629 let fragmap = build_fragmap(&commits, true);
1630
1631 assert_eq!(fragmap.commits.len(), 2);
1632
1633 assert_eq!(fragmap.clusters.len(), 2);
1635
1636 assert_ne!(fragmap.matrix[0][0], TouchKind::None);
1638 assert_eq!(fragmap.matrix[0][1], TouchKind::None);
1639
1640 assert_eq!(fragmap.matrix[1][0], TouchKind::None);
1641 assert_ne!(fragmap.matrix[1][1], TouchKind::None);
1642 }
1643
1644 #[test]
1645 fn test_build_fragmap_commit_touches_multiple_clusters() {
1646 let mut c1 = make_commit_diff(
1650 "c1",
1651 vec![make_file_diff(
1652 Some("file.txt"),
1653 Some("file.txt"),
1654 1,
1655 0,
1656 1,
1657 5, )],
1659 );
1660
1661 c1.files.push(make_file_diff(
1662 Some("file.txt"),
1663 Some("file.txt"),
1664 20,
1665 0,
1666 20,
1667 3, ));
1669
1670 let fragmap = build_fragmap(&[c1], true);
1671
1672 assert_eq!(fragmap.commits.len(), 1);
1673
1674 assert_eq!(fragmap.clusters.len(), 1);
1677 assert_ne!(fragmap.matrix[0][0], TouchKind::None);
1678 }
1679
1680 #[test]
1683 fn test_cluster_relation_no_relation_neither_touches() {
1684 let commits = vec![
1686 make_commit_diff(
1687 "c1",
1688 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1689 ),
1690 make_commit_diff(
1691 "c2",
1692 vec![make_file_diff(Some("b.txt"), Some("b.txt"), 1, 0, 1, 5)],
1693 ),
1694 ];
1695
1696 let fragmap = build_fragmap(&commits, true);
1697
1698 assert_eq!(fragmap.clusters.len(), 2);
1700
1701 let relation = fragmap.cluster_relation(0, 1, 0);
1702 assert_eq!(relation, SquashRelation::NoRelation);
1703 }
1704
1705 #[test]
1706 fn test_cluster_relation_no_relation_only_one_touches() {
1707 let commits = vec![
1709 make_commit_diff(
1710 "c1",
1711 vec![make_file_diff(
1712 Some("file.txt"),
1713 Some("file.txt"),
1714 1,
1715 0,
1716 1,
1717 5,
1718 )],
1719 ),
1720 make_commit_diff(
1721 "c2",
1722 vec![make_file_diff(
1723 Some("file.txt"),
1724 Some("file.txt"),
1725 100,
1726 0,
1727 100,
1728 5, )],
1730 ),
1731 ];
1732
1733 let fragmap = build_fragmap(&commits, true);
1734
1735 assert_eq!(fragmap.clusters.len(), 2);
1736
1737 let relation = fragmap.cluster_relation(0, 1, 0);
1739 assert_eq!(relation, SquashRelation::NoRelation);
1740 }
1741
1742 #[test]
1743 fn test_cluster_relation_squashable_no_collisions() {
1744 let commits = vec![
1746 make_commit_diff(
1747 "c1",
1748 vec![make_file_diff(
1749 Some("file.txt"),
1750 Some("file.txt"),
1751 1,
1752 0,
1753 1,
1754 5,
1755 )],
1756 ),
1757 make_commit_diff(
1758 "c2",
1759 vec![make_file_diff(
1760 Some("file.txt"),
1761 Some("file.txt"),
1762 3,
1763 3,
1764 3,
1765 4, )],
1767 ),
1768 ];
1769
1770 let fragmap = build_fragmap(&commits, true);
1771
1772 let shared_idx = fragmap
1774 .clusters
1775 .iter()
1776 .position(|c| {
1777 c.commit_oids.contains(&"c1".to_string())
1778 && c.commit_oids.contains(&"c2".to_string())
1779 })
1780 .expect("should have a shared cluster");
1781
1782 let relation = fragmap.cluster_relation(0, 1, shared_idx);
1783 assert_eq!(relation, SquashRelation::Squashable);
1784 }
1785
1786 #[test]
1787 fn test_cluster_relation_conflicting_with_collision() {
1788 let commits = vec![
1790 make_commit_diff(
1791 "c1",
1792 vec![make_file_diff(
1793 Some("file.txt"),
1794 Some("file.txt"),
1795 1,
1796 0,
1797 1,
1798 5,
1799 )],
1800 ),
1801 make_commit_diff(
1802 "c2",
1803 vec![make_file_diff(
1804 Some("file.txt"),
1805 Some("file.txt"),
1806 3,
1807 2,
1808 3,
1809 3, )],
1811 ),
1812 make_commit_diff(
1813 "c3",
1814 vec![make_file_diff(
1815 Some("file.txt"),
1816 Some("file.txt"),
1817 2,
1818 3,
1819 2,
1820 4, )],
1822 ),
1823 ];
1824
1825 let fragmap = build_fragmap(&commits, true);
1826
1827 let all_three_idx = fragmap
1829 .clusters
1830 .iter()
1831 .position(|c| {
1832 c.commit_oids.contains(&"c1".to_string())
1833 && c.commit_oids.contains(&"c2".to_string())
1834 && c.commit_oids.contains(&"c3".to_string())
1835 })
1836 .expect("should have a cluster with all three commits");
1837
1838 let relation = fragmap.cluster_relation(0, 2, all_three_idx);
1840 assert_eq!(relation, SquashRelation::Conflicting);
1841 }
1842
1843 #[test]
1844 fn test_cluster_relation_invalid_indices() {
1845 let commits = vec![
1846 make_commit_diff(
1847 "c1",
1848 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1849 ),
1850 make_commit_diff(
1851 "c2",
1852 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 3, 2, 3, 3)],
1853 ),
1854 ];
1855
1856 let fragmap = build_fragmap(&commits, true);
1857
1858 let relation = fragmap.cluster_relation(0, 10, 0);
1860 assert_eq!(relation, SquashRelation::NoRelation);
1861
1862 let relation = fragmap.cluster_relation(0, 1, 10);
1864 assert_eq!(relation, SquashRelation::NoRelation);
1865 }
1866
1867 #[test]
1868 fn test_cluster_relation_earlier_not_less_than_later() {
1869 let commits = vec![
1870 make_commit_diff(
1871 "c1",
1872 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5)],
1873 ),
1874 make_commit_diff(
1875 "c2",
1876 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 3, 2, 3, 3)],
1877 ),
1878 ];
1879
1880 let fragmap = build_fragmap(&commits, true);
1881
1882 let relation = fragmap.cluster_relation(1, 1, 0);
1884 assert_eq!(relation, SquashRelation::NoRelation);
1885
1886 let relation = fragmap.cluster_relation(1, 0, 0);
1888 assert_eq!(relation, SquashRelation::NoRelation);
1889 }
1890
1891 #[test]
1892 fn test_cluster_relation_multiple_clusters() {
1893 let commits = vec![
1895 make_commit_diff(
1896 "c1",
1897 vec![
1898 make_file_diff(Some("a.txt"), Some("a.txt"), 1, 0, 1, 5),
1899 make_file_diff(Some("b.txt"), Some("b.txt"), 1, 0, 1, 5),
1900 ],
1901 ),
1902 make_commit_diff(
1903 "c2",
1904 vec![make_file_diff(Some("a.txt"), Some("a.txt"), 3, 2, 3, 3)],
1905 ),
1906 make_commit_diff(
1907 "c3",
1908 vec![make_file_diff(Some("b.txt"), Some("b.txt"), 3, 2, 3, 3)],
1909 ),
1910 ];
1911
1912 let fragmap = build_fragmap(&commits, true);
1913
1914 let a_cluster_idx = fragmap
1916 .clusters
1917 .iter()
1918 .position(|c| {
1919 c.spans[0].path == "a.txt"
1920 && c.commit_oids.contains(&"c1".to_string())
1921 && c.commit_oids.contains(&"c2".to_string())
1922 })
1923 .expect("should have a shared a.txt cluster");
1924 let b_cluster_idx = fragmap
1925 .clusters
1926 .iter()
1927 .position(|c| {
1928 c.spans[0].path == "b.txt"
1929 && c.commit_oids.contains(&"c1".to_string())
1930 && c.commit_oids.contains(&"c3".to_string())
1931 })
1932 .expect("should have a shared b.txt cluster");
1933
1934 let relation = fragmap.cluster_relation(0, 1, a_cluster_idx);
1936 assert_eq!(relation, SquashRelation::Squashable);
1937
1938 let relation = fragmap.cluster_relation(0, 2, b_cluster_idx);
1940 assert_eq!(relation, SquashRelation::Squashable);
1941
1942 assert!(!fragmap.shares_cluster_with(1, 2));
1944 }
1945
1946 #[test]
1947 fn test_cluster_relation_squashable_with_gap() {
1948 let commits = vec![
1950 make_commit_diff(
1951 "c1",
1952 vec![make_file_diff(
1953 Some("file.txt"),
1954 Some("file.txt"),
1955 1,
1956 0,
1957 1,
1958 5,
1959 )],
1960 ),
1961 make_commit_diff(
1962 "c2",
1963 vec![make_file_diff(
1964 Some("other.txt"),
1965 Some("other.txt"),
1966 1,
1967 0,
1968 1,
1969 5,
1970 )],
1971 ),
1972 make_commit_diff(
1973 "c3",
1974 vec![make_file_diff(
1975 Some("another.txt"),
1976 Some("another.txt"),
1977 1,
1978 0,
1979 1,
1980 5,
1981 )],
1982 ),
1983 make_commit_diff(
1984 "c4",
1985 vec![make_file_diff(
1986 Some("file.txt"),
1987 Some("file.txt"),
1988 3,
1989 2,
1990 3,
1991 3,
1992 )],
1993 ),
1994 ];
1995
1996 let fragmap = build_fragmap(&commits, true);
1997
1998 let file_cluster_idx = fragmap
2000 .clusters
2001 .iter()
2002 .position(|c| {
2003 c.spans[0].path == "file.txt"
2004 && c.commit_oids.contains(&"c1".to_string())
2005 && c.commit_oids.contains(&"c4".to_string())
2006 })
2007 .expect("should have a shared file.txt cluster");
2008
2009 let relation = fragmap.cluster_relation(0, 3, file_cluster_idx);
2011 assert_eq!(relation, SquashRelation::Squashable);
2012 }
2013
2014 fn make_fragmap(commit_ids: &[&str], n_clusters: usize, touches: &[(usize, usize)]) -> FragMap {
2016 let commits: Vec<String> = commit_ids.iter().map(|s| s.to_string()).collect();
2017 let clusters = (0..n_clusters)
2018 .map(|_| SpanCluster {
2019 spans: vec![FileSpan {
2020 path: "f.txt".to_string(),
2021 start_line: 1,
2022 end_line: 1,
2023 }],
2024 commit_oids: vec![],
2025 })
2026 .collect();
2027 let mut matrix = vec![vec![TouchKind::None; n_clusters]; commit_ids.len()];
2028 for &(c, cl) in touches {
2029 matrix[c][cl] = TouchKind::Modified;
2030 }
2031 FragMap {
2032 commits,
2033 clusters,
2034 matrix,
2035 }
2036 }
2037
2038 #[test]
2041 fn squash_target_no_shared_clusters() {
2042 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (1, 1)]);
2044 assert_eq!(fm.squash_target(1), None);
2045 }
2046
2047 #[test]
2048 fn squash_target_adjacent() {
2049 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2051 assert_eq!(fm.squash_target(1), Some(0));
2052 }
2053
2054 #[test]
2055 fn squash_target_with_gap() {
2056 let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (2, 0)]);
2058 assert_eq!(fm.squash_target(2), Some(0));
2059 }
2060
2061 #[test]
2062 fn squash_target_conflicting_returns_none() {
2063 let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (1, 0), (2, 0)]);
2065 assert_eq!(fm.squash_target(2), None);
2066 }
2067
2068 #[test]
2069 fn squash_target_multiple_clusters_same_target() {
2070 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (0, 1), (1, 0), (1, 1)]);
2072 assert_eq!(fm.squash_target(1), Some(0));
2073 }
2074
2075 #[test]
2076 fn squash_target_multiple_clusters_different_targets() {
2077 let fm = make_fragmap(&["c0", "c1", "c2"], 2, &[(0, 0), (1, 1), (2, 0), (2, 1)]);
2081 assert_eq!(fm.squash_target(2), None);
2082 }
2083
2084 #[test]
2085 fn squash_target_earliest_commit_returns_none() {
2086 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2088 assert_eq!(fm.squash_target(0), None);
2089 }
2090
2091 #[test]
2092 fn squash_target_no_clusters_touched() {
2093 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0)]);
2095 assert_eq!(fm.squash_target(1), None);
2096 }
2097
2098 #[test]
2101 fn is_fully_squashable_single_cluster_adjacent() {
2102 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2104 assert!(fm.is_fully_squashable(1));
2105 }
2106
2107 #[test]
2108 fn is_fully_squashable_first_commit_not_squashable() {
2109 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2111 assert!(!fm.is_fully_squashable(0));
2112 }
2113
2114 #[test]
2115 fn is_fully_squashable_multiple_clusters_same_target() {
2116 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (0, 1), (1, 0), (1, 1)]);
2118 assert!(fm.is_fully_squashable(1));
2119 }
2120
2121 #[test]
2122 fn is_fully_squashable_multiple_clusters_different_targets() {
2123 let fm = make_fragmap(&["c0", "c1", "c2"], 2, &[(0, 0), (1, 1), (2, 0), (2, 1)]);
2127 assert!(!fm.is_fully_squashable(2));
2128 }
2129
2130 #[test]
2131 fn is_fully_squashable_conflicting_cluster() {
2132 let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (1, 0), (2, 0)]);
2134 assert!(!fm.is_fully_squashable(2));
2135 }
2136
2137 #[test]
2138 fn is_fully_squashable_no_clusters_touched() {
2139 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0)]);
2141 assert!(!fm.is_fully_squashable(1));
2142 }
2143
2144 #[test]
2147 fn shares_cluster_with_no_shared_cluster() {
2148 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (1, 1)]);
2149 assert!(!fm.shares_cluster_with(0, 1));
2150 }
2151
2152 #[test]
2153 fn shares_cluster_with_adjacent_pair() {
2154 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2155 assert!(fm.shares_cluster_with(0, 1));
2156 }
2157
2158 #[test]
2159 fn shares_cluster_with_blocked_by_middle_commit() {
2160 let fm = make_fragmap(&["c0", "c1", "c2"], 1, &[(0, 0), (1, 0), (2, 0)]);
2161 assert!(fm.shares_cluster_with(0, 2));
2162 }
2163
2164 #[test]
2165 fn shares_cluster_with_is_symmetric() {
2166 let fm = make_fragmap(&["c0", "c1"], 1, &[(0, 0), (1, 0)]);
2167 assert_eq!(fm.shares_cluster_with(0, 1), fm.shares_cluster_with(1, 0));
2168 }
2169
2170 #[test]
2171 fn shares_cluster_with_same_commit() {
2172 let fm = make_fragmap(&["c0"], 1, &[(0, 0)]);
2173 assert!(!fm.shares_cluster_with(0, 0));
2174 }
2175
2176 #[test]
2177 fn shares_cluster_with_one_shared_is_enough() {
2178 let fm = make_fragmap(&["c0", "c1"], 2, &[(0, 0), (0, 1), (1, 1)]);
2180 assert!(fm.shares_cluster_with(0, 1));
2181 }
2182
2183 #[test]
2188 fn build_fragmap_pure_insertion_clusters_with_later_modifier() {
2189 let commits = vec![
2193 make_commit_diff(
2194 "c1",
2195 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 5, 0, 5, 10)],
2196 ),
2197 make_commit_diff(
2198 "c2",
2199 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 7, 3, 7, 3)],
2200 ),
2201 ];
2202 let fm = build_fragmap(&commits, true);
2203 assert!(fm.shares_cluster_with(0, 1));
2204 }
2205
2206 #[test]
2207 fn build_fragmap_far_deletion_does_not_cluster_with_unrelated_modify() {
2208 let commits = vec![
2212 make_commit_diff(
2213 "c1",
2214 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 3, 1, 5)],
2215 ),
2216 make_commit_diff(
2217 "c2",
2218 vec![FileDiff {
2219 old_path: Some("f.rs".to_string()),
2220 new_path: Some("f.rs".to_string()),
2221 status: crate::DeltaStatus::Modified,
2222 hunks: vec![Hunk {
2223 old_start: 50,
2224 old_lines: 3,
2225 new_start: 50,
2226 new_lines: 0,
2227 lines: vec![],
2228 }],
2229 }],
2230 ),
2231 ];
2232 let fm = build_fragmap(&commits, true);
2233 assert!(!fm.shares_cluster_with(0, 1));
2234 }
2235
2236 #[test]
2237 fn build_fragmap_file_rename_cluster_uses_canonical_path() {
2238 let c1 = CommitDiff {
2241 commit: make_commit_info_with_oid("c1"),
2242 files: vec![FileDiff {
2243 old_path: Some("foo.rs".to_string()),
2244 new_path: Some("bar.rs".to_string()),
2245 status: crate::DeltaStatus::Modified,
2246 hunks: vec![Hunk {
2247 old_start: 5,
2248 old_lines: 3,
2249 new_start: 5,
2250 new_lines: 4,
2251 lines: vec![],
2252 }],
2253 }],
2254 };
2255 let fm = build_fragmap(&[c1], true);
2256 assert_eq!(fm.clusters.len(), 1);
2257 assert_eq!(fm.clusters[0].spans[0].path, "foo.rs");
2258 }
2259
2260 #[test]
2261 fn build_fragmap_rename_groups_old_and_new_in_same_cluster() {
2262 let c0 = make_commit_diff(
2267 "c0",
2268 vec![make_file_diff(Some("foo.rs"), Some("foo.rs"), 1, 0, 1, 10)],
2269 );
2270 let c1 = CommitDiff {
2271 commit: make_commit_info_with_oid("c1"),
2272 files: vec![FileDiff {
2273 old_path: Some("foo.rs".to_string()),
2274 new_path: Some("bar.rs".to_string()),
2275 status: crate::DeltaStatus::Modified,
2276 hunks: vec![Hunk {
2277 old_start: 5,
2278 old_lines: 6,
2279 new_start: 5,
2280 new_lines: 8,
2281 lines: vec![],
2282 }],
2283 }],
2284 };
2285 let fm = build_fragmap(&[c0, c1], true);
2286 assert!(
2288 fm.clusters.iter().any(|cl| {
2289 cl.commit_oids.contains(&"c0".to_string())
2290 && cl.commit_oids.contains(&"c1".to_string())
2291 }),
2292 "expected c0 and c1 to share a cluster via rename tracking"
2293 );
2294 }
2295
2296 #[test]
2297 fn build_fragmap_single_commit_two_regions_deduped_to_one_column() {
2298 let mut c1 = make_commit_diff(
2301 "c1",
2302 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 0, 1, 5)],
2303 );
2304 c1.files
2305 .push(make_file_diff(Some("f.rs"), Some("f.rs"), 100, 0, 100, 5));
2306 let fm = build_fragmap(&[c1], true);
2307 assert_eq!(fm.clusters.len(), 1);
2308 assert_ne!(fm.matrix[0][0], TouchKind::None);
2309 }
2310
2311 #[test]
2312 fn build_fragmap_no_dedup_keeps_identical_activation_pattern_columns() {
2313 let mut c1 = make_commit_diff(
2317 "c1",
2318 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 0, 1, 5)],
2319 );
2320 c1.files
2321 .push(make_file_diff(Some("f.rs"), Some("f.rs"), 100, 0, 100, 5));
2322 let fm = build_fragmap(&[c1], false);
2323 assert_eq!(fm.clusters.len(), 2);
2324 assert_ne!(fm.matrix[0][0], TouchKind::None);
2325 assert_ne!(fm.matrix[0][1], TouchKind::None);
2326 }
2327
2328 #[test]
2329 fn build_fragmap_two_commits_separate_regions_not_deduped() {
2330 let commits = vec![
2332 make_commit_diff(
2333 "c1",
2334 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 1, 0, 1, 5)],
2335 ),
2336 make_commit_diff(
2337 "c2",
2338 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 100, 0, 100, 5)],
2339 ),
2340 ];
2341 let fm = build_fragmap(&commits, true);
2342 assert_eq!(fm.clusters.len(), 2);
2343 }
2344
2345 #[test]
2346 fn build_fragmap_three_commits_sequential_on_same_region() {
2347 let commits = vec![
2350 make_commit_diff(
2351 "c1",
2352 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 10, 5, 10, 10)],
2353 ),
2354 make_commit_diff(
2355 "c2",
2356 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 12, 3, 12, 3)],
2357 ),
2358 make_commit_diff(
2359 "c3",
2360 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 11, 2, 11, 2)],
2361 ),
2362 ];
2363 let fm = build_fragmap(&commits, true);
2364 assert!(fm.shares_cluster_with(0, 1));
2365 assert!(fm.shares_cluster_with(0, 2));
2366 assert!(fm.shares_cluster_with(1, 2));
2367 }
2368
2369 #[test]
2370 fn build_fragmap_empty_span_does_not_panic() {
2371 let commits = vec![
2375 make_commit_diff(
2376 "c1",
2377 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 10, 1, 10, 1)],
2378 ),
2379 make_commit_diff(
2380 "c2",
2381 vec![make_file_diff(Some("f.rs"), Some("f.rs"), 20, 1, 20, 1)],
2382 ),
2383 ];
2384 let fm = build_fragmap(&commits, true);
2385 assert_eq!(fm.commits.len(), 2);
2386 }
2387}