1use std::path::{Path, PathBuf};
17use std::process::Output;
18use std::sync::OnceLock;
19
20use rustc_hash::{FxHashMap, FxHashSet};
21
22use crate::duplicates::{DuplicationReport, DuplicationStats, families};
23use crate::results::AnalysisResults;
24
25pub type ChangedFilesSpawnHook = fn(&mut std::process::Command) -> std::io::Result<Output>;
32
33static SPAWN_HOOK: OnceLock<ChangedFilesSpawnHook> = OnceLock::new();
34
35pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
42 let _ = SPAWN_HOOK.set(hook);
43}
44
45fn spawn_output(command: &mut std::process::Command) -> std::io::Result<Output> {
46 if let Some(hook) = SPAWN_HOOK.get() {
47 hook(command)
48 } else {
49 command.output()
50 }
51}
52
53pub fn validate_git_ref(s: &str) -> Result<&str, String> {
66 if s.is_empty() {
67 return Err("git ref cannot be empty".to_string());
68 }
69 if s.starts_with('-') {
70 return Err("git ref cannot start with '-'".to_string());
71 }
72 let mut in_braces = false;
73 for c in s.chars() {
74 match c {
75 '{' => in_braces = true,
76 '}' => in_braces = false,
77 ':' | ' ' if in_braces => {}
78 c if c.is_ascii_alphanumeric()
79 || matches!(c, '.' | '_' | '-' | '/' | '~' | '^' | '@' | '{' | '}') => {}
80 _ => return Err(format!("git ref contains disallowed character: '{c}'")),
81 }
82 }
83 if in_braces {
84 return Err("git ref has unclosed '{'".to_string());
85 }
86 Ok(s)
87}
88
89#[derive(Debug)]
92pub enum ChangedFilesError {
93 InvalidRef(String),
95 GitMissing(String),
97 NotARepository,
99 GitFailed(String),
101}
102
103impl ChangedFilesError {
104 pub fn describe(&self) -> String {
108 match self {
109 Self::InvalidRef(e) => format!("invalid git ref: {e}"),
110 Self::GitMissing(e) => format!("failed to run git: {e}"),
111 Self::NotARepository => "not a git repository".to_owned(),
112 Self::GitFailed(stderr) => augment_git_failed(stderr),
113 }
114 }
115}
116
117fn augment_git_failed(stderr: &str) -> String {
123 let lower = stderr.to_ascii_lowercase();
124 if lower.contains("not a valid object name")
125 || lower.contains("unknown revision")
126 || lower.contains("ambiguous argument")
127 {
128 format!(
129 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
130 )
131 } else {
132 stderr.to_owned()
133 }
134}
135
136pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
147 let output = spawn_output(&mut git_command(cwd, &["rev-parse", "--show-toplevel"]))
148 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
149
150 if !output.status.success() {
151 let stderr = String::from_utf8_lossy(&output.stderr);
152 return Err(if stderr.contains("not a git repository") {
153 ChangedFilesError::NotARepository
154 } else {
155 ChangedFilesError::GitFailed(stderr.trim().to_owned())
156 });
157 }
158
159 let raw = String::from_utf8_lossy(&output.stdout);
160 let trimmed = raw.trim();
161 if trimmed.is_empty() {
162 return Err(ChangedFilesError::GitFailed(
163 "git rev-parse --show-toplevel returned empty output".to_owned(),
164 ));
165 }
166
167 let path = PathBuf::from(trimmed);
168 Ok(dunce::canonicalize(&path).unwrap_or(path))
176}
177
178fn collect_git_paths(
179 cwd: &Path,
180 toplevel: &Path,
181 args: &[&str],
182) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
183 let output = spawn_output(&mut git_command(cwd, args))
184 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
185
186 if !output.status.success() {
187 let stderr = String::from_utf8_lossy(&output.stderr);
188 return Err(if stderr.contains("not a git repository") {
189 ChangedFilesError::NotARepository
190 } else {
191 ChangedFilesError::GitFailed(stderr.trim().to_owned())
192 });
193 }
194
195 #[cfg(windows)]
214 let normalise_segment = |line: &str| line.replace('/', "\\");
215 #[cfg(not(windows))]
216 let normalise_segment = |line: &str| line.to_owned();
217
218 let files: FxHashSet<PathBuf> = String::from_utf8_lossy(&output.stdout)
219 .lines()
220 .filter(|line| !line.is_empty())
221 .map(|line| toplevel.join(normalise_segment(line)))
222 .collect();
223
224 Ok(files)
225}
226
227fn git_command(cwd: &Path, args: &[&str]) -> std::process::Command {
228 let mut command = crate::spawn::git();
229 command.args(args).current_dir(cwd);
230 command
231}
232
233pub fn try_get_changed_files(
251 root: &Path,
252 git_ref: &str,
253) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
254 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
260 let toplevel = resolve_git_toplevel(root)?;
261 try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
262}
263
264pub fn try_get_changed_files_with_toplevel(
272 cwd: &Path,
273 toplevel: &Path,
274 git_ref: &str,
275) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
276 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
277
278 let mut files = collect_git_paths(
279 cwd,
280 toplevel,
281 &[
282 "diff",
283 "--name-only",
284 "--end-of-options",
285 &format!("{git_ref}...HEAD"),
286 ],
287 )?;
288 files.extend(collect_git_paths(
289 cwd,
290 toplevel,
291 &["diff", "--name-only", "HEAD"],
292 )?);
293 files.extend(collect_git_paths(
298 cwd,
299 toplevel,
300 &["ls-files", "--full-name", "--others", "--exclude-standard"],
301 )?);
302 Ok(files)
303}
304
305#[expect(
309 clippy::print_stderr,
310 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; LSP callers use try_get_changed_files instead"
311)]
312pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
313 match try_get_changed_files(root, git_ref) {
314 Ok(files) => Some(files),
315 Err(ChangedFilesError::InvalidRef(e)) => {
316 eprintln!("Warning: --changed-since ignored: invalid git ref: {e}");
317 None
318 }
319 Err(ChangedFilesError::GitMissing(e)) => {
320 eprintln!("Warning: --changed-since ignored: failed to run git: {e}");
321 None
322 }
323 Err(ChangedFilesError::NotARepository) => {
324 eprintln!("Warning: --changed-since ignored: not a git repository");
325 None
326 }
327 Err(ChangedFilesError::GitFailed(stderr)) => {
328 eprintln!("Warning: --changed-since failed for ref '{git_ref}': {stderr}");
329 None
330 }
331 }
332}
333
334#[expect(
342 clippy::implicit_hasher,
343 reason = "fallow standardizes on FxHashSet across the workspace"
344)]
345pub fn filter_results_by_changed_files(
346 results: &mut AnalysisResults,
347 changed_files: &FxHashSet<PathBuf>,
348) {
349 let cf = normalize_changed_files_set(changed_files);
350 results
351 .unused_files
352 .retain(|f| contains_normalized(&cf, &f.file.path));
353 results
354 .unused_exports
355 .retain(|e| contains_normalized(&cf, &e.export.path));
356 results
357 .unused_types
358 .retain(|e| contains_normalized(&cf, &e.export.path));
359 results
360 .private_type_leaks
361 .retain(|e| contains_normalized(&cf, &e.leak.path));
362 results
363 .unused_enum_members
364 .retain(|m| contains_normalized(&cf, &m.member.path));
365 results
366 .unused_class_members
367 .retain(|m| contains_normalized(&cf, &m.member.path));
368 results
369 .unresolved_imports
370 .retain(|i| contains_normalized(&cf, &i.import.path));
371
372 results.unlisted_dependencies.retain(|d| {
374 d.dep
375 .imported_from
376 .iter()
377 .any(|s| contains_normalized(&cf, &s.path))
378 });
379
380 for dup in &mut results.duplicate_exports {
382 dup.export
383 .locations
384 .retain(|loc| contains_normalized(&cf, &loc.path));
385 }
386 results
387 .duplicate_exports
388 .retain(|d| d.export.locations.len() >= 2);
389
390 results
392 .circular_dependencies
393 .retain(|c| c.cycle.files.iter().any(|f| contains_normalized(&cf, f)));
394
395 results
399 .re_export_cycles
400 .retain(|c| c.cycle.files.iter().any(|f| contains_normalized(&cf, f)));
401
402 results
404 .boundary_violations
405 .retain(|v| contains_normalized(&cf, &v.violation.from_path));
406
407 results
409 .stale_suppressions
410 .retain(|s| contains_normalized(&cf, &s.path));
411
412 results
415 .unresolved_catalog_references
416 .retain(|r| contains_normalized(&cf, &r.reference.path));
417 results
418 .empty_catalog_groups
419 .retain(|g| normalized_set_contains_path(&cf, &g.group.path));
420
421 results
425 .unused_dependency_overrides
426 .retain(|o| contains_normalized(&cf, &o.entry.path));
427 results
428 .misconfigured_dependency_overrides
429 .retain(|o| contains_normalized(&cf, &o.entry.path));
430}
431
432fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
445 changed_files
446 .iter()
447 .map(|p| dunce::simplified(p).to_path_buf())
448 .collect()
449}
450
451fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
452 normalized.contains(dunce::simplified(path))
453}
454
455fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
456 contains_normalized(normalized, path)
457 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
458}
459
460fn recompute_duplication_stats(report: &DuplicationReport) -> DuplicationStats {
466 let mut files_with_clones: FxHashSet<&Path> = FxHashSet::default();
467 let mut file_dup_lines: FxHashMap<&Path, FxHashSet<usize>> = FxHashMap::default();
468 let mut duplicated_tokens = 0_usize;
469 let mut clone_instances = 0_usize;
470
471 for group in &report.clone_groups {
472 for instance in &group.instances {
473 files_with_clones.insert(&instance.file);
474 clone_instances += 1;
475 let lines = file_dup_lines.entry(&instance.file).or_default();
476 for line in instance.start_line..=instance.end_line {
477 lines.insert(line);
478 }
479 }
480 duplicated_tokens += group.token_count * group.instances.len();
481 }
482
483 let duplicated_lines: usize = file_dup_lines.values().map(FxHashSet::len).sum();
484
485 DuplicationStats {
486 total_files: report.stats.total_files,
487 files_with_clones: files_with_clones.len(),
488 total_lines: report.stats.total_lines,
489 duplicated_lines,
490 total_tokens: report.stats.total_tokens,
491 duplicated_tokens,
492 clone_groups: report.clone_groups.len(),
493 clone_instances,
494 #[expect(
495 clippy::cast_precision_loss,
496 reason = "stat percentages are display-only; precision loss at usize::MAX line counts is acceptable"
497 )]
498 duplication_percentage: if report.stats.total_lines > 0 {
499 (duplicated_lines as f64 / report.stats.total_lines as f64) * 100.0
500 } else {
501 0.0
502 },
503 clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
504 }
505}
506
507#[expect(
512 clippy::implicit_hasher,
513 reason = "fallow standardizes on FxHashSet across the workspace"
514)]
515pub fn filter_duplication_by_changed_files(
516 report: &mut DuplicationReport,
517 changed_files: &FxHashSet<PathBuf>,
518 root: &Path,
519) {
520 let cf = normalize_changed_files_set(changed_files);
521 report.clone_groups.retain(|g| {
522 g.instances
523 .iter()
524 .any(|i| contains_normalized(&cf, &i.file))
525 });
526 report.clone_families = families::group_into_families(&report.clone_groups, root);
527 report.mirrored_directories =
528 families::detect_mirrored_directories(&report.clone_families, root);
529 report.stats = recompute_duplication_stats(report);
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::duplicates::{CloneGroup, CloneInstance};
536 use crate::results::{
537 BoundaryViolation, CircularDependency, EmptyCatalogGroup, UnusedExport, UnusedFile,
538 };
539 use fallow_types::output_dead_code::{
540 BoundaryViolationFinding, CircularDependencyFinding, EmptyCatalogGroupFinding,
541 UnusedExportFinding, UnusedFileFinding,
542 };
543
544 #[test]
545 fn changed_files_error_describe_variants() {
546 assert!(
547 ChangedFilesError::InvalidRef("bad".to_owned())
548 .describe()
549 .contains("invalid git ref")
550 );
551 assert!(
552 ChangedFilesError::GitMissing("oops".to_owned())
553 .describe()
554 .contains("oops")
555 );
556 assert_eq!(
557 ChangedFilesError::NotARepository.describe(),
558 "not a git repository"
559 );
560 assert!(
561 ChangedFilesError::GitFailed("bad ref".to_owned())
562 .describe()
563 .contains("bad ref")
564 );
565 }
566
567 #[test]
568 fn augment_git_failed_appends_shallow_clone_hint_for_unknown_revision() {
569 let stderr = "fatal: ambiguous argument 'fallow-baseline...HEAD': unknown revision or path not in the working tree.";
570 let described = ChangedFilesError::GitFailed(stderr.to_owned()).describe();
571 assert!(described.contains(stderr), "original stderr preserved");
572 assert!(
573 described.contains("shallow clone"),
574 "hint surfaced: {described}"
575 );
576 assert!(
577 described.contains("fetch-depth: 0") || described.contains("git fetch --unshallow"),
578 "hint actionable: {described}"
579 );
580 }
581
582 #[test]
583 fn augment_git_failed_passthrough_for_other_errors() {
584 let stderr = "fatal: refusing to merge unrelated histories";
586 let described = ChangedFilesError::GitFailed(stderr.to_owned()).describe();
587 assert_eq!(described, stderr);
588 }
589
590 #[test]
591 fn validate_git_ref_rejects_leading_dash() {
592 assert!(validate_git_ref("--upload-pack=evil").is_err());
593 assert!(validate_git_ref("-flag").is_err());
594 }
595
596 #[test]
597 fn validate_git_ref_accepts_baseline_tag() {
598 assert_eq!(
599 validate_git_ref("fallow-baseline").unwrap(),
600 "fallow-baseline"
601 );
602 }
603
604 #[test]
605 fn try_get_changed_files_rejects_invalid_ref() {
606 let err = try_get_changed_files(Path::new("/"), "--evil")
608 .expect_err("leading-dash ref must be rejected");
609 assert!(matches!(err, ChangedFilesError::InvalidRef(_)));
610 assert!(err.describe().contains("cannot start with"));
611 }
612
613 #[test]
614 fn validate_git_ref_rejects_option_like_ref() {
615 assert!(validate_git_ref("--output=/tmp/fallow-proof").is_err());
616 }
617
618 #[test]
619 fn validate_git_ref_allows_reflog_relative_date() {
620 assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
621 }
622
623 #[test]
624 fn try_get_changed_files_rejects_option_like_ref_before_git() {
625 let root = tempfile::tempdir().expect("create temp dir");
626 let proof_path = root.path().join("proof");
627
628 let result = try_get_changed_files(
629 root.path(),
630 &format!("--output={}", proof_path.to_string_lossy()),
631 );
632
633 assert!(matches!(result, Err(ChangedFilesError::InvalidRef(_))));
634 assert!(
635 !proof_path.exists(),
636 "invalid changedSince ref must not be passed through to git as an option"
637 );
638 }
639
640 #[test]
641 fn git_command_clears_parent_git_environment() {
642 let command = git_command(Path::new("."), &["status", "--short"]);
643 let overrides: Vec<_> = command.get_envs().collect();
644
645 for var in crate::git_env::AMBIENT_GIT_ENV_VARS {
646 assert!(
647 overrides
648 .iter()
649 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
650 "git helper must clear inherited {var}",
651 );
652 }
653 }
654
655 #[test]
656 fn filter_results_keeps_only_changed_files() {
657 let mut results = AnalysisResults::default();
658 results
659 .unused_files
660 .push(UnusedFileFinding::with_actions(UnusedFile {
661 path: "/a.ts".into(),
662 }));
663 results
664 .unused_files
665 .push(UnusedFileFinding::with_actions(UnusedFile {
666 path: "/b.ts".into(),
667 }));
668 results
669 .unused_exports
670 .push(UnusedExportFinding::with_actions(UnusedExport {
671 path: "/a.ts".into(),
672 export_name: "foo".into(),
673 is_type_only: false,
674 line: 1,
675 col: 0,
676 span_start: 0,
677 is_re_export: false,
678 }));
679
680 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
681 changed.insert("/a.ts".into());
682
683 filter_results_by_changed_files(&mut results, &changed);
684
685 assert_eq!(results.unused_files.len(), 1);
686 assert_eq!(results.unused_files[0].file.path, PathBuf::from("/a.ts"));
687 assert_eq!(results.unused_exports.len(), 1);
688 }
689
690 #[test]
691 fn filter_results_preserves_dependency_level_issues() {
692 let mut results = AnalysisResults::default();
693 results.unused_dependencies.push(
694 fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
695 crate::results::UnusedDependency {
696 package_name: "lodash".into(),
697 location: crate::results::DependencyLocation::Dependencies,
698 path: "/pkg.json".into(),
699 line: 3,
700 used_in_workspaces: Vec::new(),
701 },
702 ),
703 );
704
705 let changed: FxHashSet<PathBuf> = FxHashSet::default();
706 filter_results_by_changed_files(&mut results, &changed);
707
708 assert_eq!(results.unused_dependencies.len(), 1);
710 }
711
712 #[test]
713 fn filter_results_keeps_circular_dep_when_any_file_changed() {
714 let mut results = AnalysisResults::default();
715 results
716 .circular_dependencies
717 .push(CircularDependencyFinding::with_actions(
718 CircularDependency {
719 files: vec!["/a.ts".into(), "/b.ts".into()],
720 length: 2,
721 line: 1,
722 col: 0,
723 is_cross_package: false,
724 },
725 ));
726
727 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
728 changed.insert("/b.ts".into());
729
730 filter_results_by_changed_files(&mut results, &changed);
731 assert_eq!(results.circular_dependencies.len(), 1);
732 }
733
734 #[test]
735 fn filter_results_drops_circular_dep_when_no_file_changed() {
736 let mut results = AnalysisResults::default();
737 results
738 .circular_dependencies
739 .push(CircularDependencyFinding::with_actions(
740 CircularDependency {
741 files: vec!["/a.ts".into(), "/b.ts".into()],
742 length: 2,
743 line: 1,
744 col: 0,
745 is_cross_package: false,
746 },
747 ));
748
749 let changed: FxHashSet<PathBuf> = FxHashSet::default();
750 filter_results_by_changed_files(&mut results, &changed);
751 assert!(results.circular_dependencies.is_empty());
752 }
753
754 #[test]
755 fn filter_results_drops_boundary_violation_when_importer_unchanged() {
756 let mut results = AnalysisResults::default();
757 results
758 .boundary_violations
759 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
760 from_path: "/a.ts".into(),
761 to_path: "/b.ts".into(),
762 from_zone: "ui".into(),
763 to_zone: "data".into(),
764 import_specifier: "../data/db".into(),
765 line: 1,
766 col: 0,
767 }));
768
769 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
770 changed.insert("/b.ts".into());
772
773 filter_results_by_changed_files(&mut results, &changed);
774 assert!(results.boundary_violations.is_empty());
775 }
776
777 #[test]
778 fn filter_results_keeps_relative_empty_catalog_group_when_manifest_changed() {
779 let mut results = AnalysisResults::default();
780 results
781 .empty_catalog_groups
782 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
783 catalog_name: "legacy".into(),
784 path: PathBuf::from("pnpm-workspace.yaml"),
785 line: 4,
786 }));
787
788 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
789 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
790
791 filter_results_by_changed_files(&mut results, &changed);
792
793 assert_eq!(results.empty_catalog_groups.len(), 1);
794 assert_eq!(results.empty_catalog_groups[0].group.catalog_name, "legacy");
795 }
796
797 #[test]
798 fn filter_duplication_keeps_groups_with_at_least_one_changed_instance() {
799 let mut report = DuplicationReport {
800 clone_groups: vec![CloneGroup {
801 instances: vec![
802 CloneInstance {
803 file: "/a.ts".into(),
804 start_line: 1,
805 end_line: 5,
806 start_col: 0,
807 end_col: 10,
808 fragment: "code".into(),
809 },
810 CloneInstance {
811 file: "/b.ts".into(),
812 start_line: 1,
813 end_line: 5,
814 start_col: 0,
815 end_col: 10,
816 fragment: "code".into(),
817 },
818 ],
819 token_count: 20,
820 line_count: 5,
821 }],
822 clone_families: vec![],
823 mirrored_directories: vec![],
824 stats: DuplicationStats {
825 total_files: 2,
826 files_with_clones: 2,
827 total_lines: 100,
828 duplicated_lines: 10,
829 total_tokens: 200,
830 duplicated_tokens: 40,
831 clone_groups: 1,
832 clone_instances: 2,
833 duplication_percentage: 10.0,
834 clone_groups_below_min_occurrences: 0,
835 },
836 };
837
838 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
839 changed.insert("/a.ts".into());
840
841 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
842 assert_eq!(report.clone_groups.len(), 1);
843 assert_eq!(report.stats.clone_groups, 1);
845 assert_eq!(report.stats.clone_instances, 2);
846 }
847
848 #[cfg(windows)]
856 #[test]
857 fn filter_duplication_normalises_verbatim_prefix_mismatch() {
858 let mut report = DuplicationReport {
859 clone_groups: vec![CloneGroup {
860 instances: vec![
861 CloneInstance {
862 file: PathBuf::from(r"\\?\C:\repo\src\changed.ts"),
863 start_line: 1,
864 end_line: 5,
865 start_col: 0,
866 end_col: 10,
867 fragment: "code".into(),
868 },
869 CloneInstance {
870 file: PathBuf::from(r"\\?\C:\repo\src\focused-copy.ts"),
871 start_line: 1,
872 end_line: 5,
873 start_col: 0,
874 end_col: 10,
875 fragment: "code".into(),
876 },
877 ],
878 token_count: 20,
879 line_count: 5,
880 }],
881 clone_families: vec![],
882 mirrored_directories: vec![],
883 stats: DuplicationStats {
884 total_files: 2,
885 files_with_clones: 2,
886 total_lines: 100,
887 duplicated_lines: 10,
888 total_tokens: 200,
889 duplicated_tokens: 40,
890 clone_groups: 1,
891 clone_instances: 2,
892 duplication_percentage: 10.0,
893 clone_groups_below_min_occurrences: 0,
894 },
895 };
896
897 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
898 changed.insert(PathBuf::from(r"C:\repo\src\changed.ts"));
899
900 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
901 assert_eq!(
902 report.clone_groups.len(),
903 1,
904 "verbatim instance path must match non-verbatim changed-file entry"
905 );
906 }
907
908 #[cfg(windows)]
909 #[test]
910 fn filter_results_normalises_verbatim_prefix_mismatch() {
911 let mut results = AnalysisResults::default();
912 results
913 .unused_exports
914 .push(UnusedExportFinding::with_actions(UnusedExport {
915 path: PathBuf::from(r"\\?\C:\repo\src\a.ts"),
916 export_name: "foo".into(),
917 is_type_only: false,
918 line: 1,
919 col: 0,
920 span_start: 0,
921 is_re_export: false,
922 }));
923
924 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
925 changed.insert(PathBuf::from(r"C:\repo\src\a.ts"));
926
927 filter_results_by_changed_files(&mut results, &changed);
928 assert_eq!(
929 results.unused_exports.len(),
930 1,
931 "verbatim finding path must match non-verbatim changed-file entry"
932 );
933 }
934
935 fn init_repo(repo: &Path) -> PathBuf {
956 run_git(repo, &["init", "--quiet", "--initial-branch=main"]);
957 run_git(repo, &["config", "user.email", "test@example.com"]);
958 run_git(repo, &["config", "user.name", "test"]);
959 run_git(repo, &["config", "commit.gpgsign", "false"]);
960 std::fs::write(repo.join("seed.txt"), "seed\n").unwrap();
961 run_git(repo, &["add", "seed.txt"]);
962 run_git(repo, &["commit", "--quiet", "-m", "initial"]);
963 run_git(repo, &["tag", "fallow-baseline"]);
964 dunce::canonicalize(repo).unwrap()
965 }
966
967 fn run_git(cwd: &Path, args: &[&str]) {
968 let output = std::process::Command::new("git")
969 .args(args)
970 .current_dir(cwd)
971 .output()
972 .expect("git available");
973 assert!(
974 output.status.success(),
975 "git {args:?} failed: {}",
976 String::from_utf8_lossy(&output.stderr)
977 );
978 }
979
980 #[test]
983 fn try_get_changed_files_workspace_at_repo_root() {
984 let tmp = tempfile::tempdir().unwrap();
985 let repo = init_repo(tmp.path());
986 std::fs::create_dir_all(repo.join("src")).unwrap();
987 std::fs::write(repo.join("src/new.ts"), "export const x = 1;\n").unwrap();
988
989 let changed = try_get_changed_files(&repo, "fallow-baseline").unwrap();
990
991 let expected = repo.join("src/new.ts");
992 assert!(
993 changed.contains(&expected),
994 "changed set should contain {expected:?}; actual: {changed:?}"
995 );
996 }
997
998 #[test]
1006 fn try_get_changed_files_workspace_in_subdirectory() {
1007 let tmp = tempfile::tempdir().unwrap();
1008 let repo = init_repo(tmp.path());
1009 let frontend = repo.join("frontend");
1010 std::fs::create_dir_all(frontend.join("src")).unwrap();
1011 std::fs::write(frontend.join("src/new.ts"), "export const x = 1;\n").unwrap();
1012
1013 let changed = try_get_changed_files(&frontend, "fallow-baseline").unwrap();
1014
1015 let expected = repo.join("frontend/src/new.ts");
1016 assert!(
1017 changed.contains(&expected),
1018 "changed set should contain canonical {expected:?}; actual: {changed:?}"
1019 );
1020 let bogus = frontend.join("frontend/src/new.ts");
1022 assert!(
1023 !changed.contains(&bogus),
1024 "changed set must not contain double-frontend path {bogus:?}"
1025 );
1026 }
1027
1028 #[test]
1043 fn try_get_changed_files_includes_committed_sibling_changes() {
1044 let tmp = tempfile::tempdir().unwrap();
1045 let repo = init_repo(tmp.path());
1046 let backend = repo.join("backend");
1047 std::fs::create_dir_all(&backend).unwrap();
1048 std::fs::write(backend.join("server.py"), "print('hi')\n").unwrap();
1049 run_git(&repo, &["add", "."]);
1050 run_git(&repo, &["commit", "--quiet", "-m", "add backend"]);
1051
1052 let frontend = repo.join("frontend");
1053 std::fs::create_dir_all(&frontend).unwrap();
1054
1055 let changed = try_get_changed_files(&frontend, "fallow-baseline").unwrap();
1056
1057 let expected = repo.join("backend/server.py");
1058 assert!(
1059 changed.contains(&expected),
1060 "committed sibling backend/server.py should be in the set: {changed:?}"
1061 );
1062 }
1063
1064 #[test]
1068 fn try_get_changed_files_includes_modified_tracked_file() {
1069 let tmp = tempfile::tempdir().unwrap();
1070 let repo = init_repo(tmp.path());
1071 let frontend = repo.join("frontend");
1072 std::fs::create_dir_all(frontend.join("src")).unwrap();
1073 std::fs::write(frontend.join("src/old.ts"), "export const x = 1;\n").unwrap();
1074 run_git(&repo, &["add", "."]);
1075 run_git(&repo, &["commit", "--quiet", "-m", "add old"]);
1076 run_git(&repo, &["tag", "fallow-baseline-v2"]);
1077 std::fs::write(frontend.join("src/old.ts"), "export const x = 2;\n").unwrap();
1079
1080 let changed = try_get_changed_files(&frontend, "fallow-baseline-v2").unwrap();
1081
1082 let expected = repo.join("frontend/src/old.ts");
1083 assert!(
1084 changed.contains(&expected),
1085 "modified tracked file {expected:?} missing from set: {changed:?}"
1086 );
1087 }
1088
1089 #[test]
1095 fn resolve_git_toplevel_returns_canonical_path() {
1096 let tmp = tempfile::tempdir().unwrap();
1097 let repo = init_repo(tmp.path());
1098 let frontend = repo.join("frontend");
1099 std::fs::create_dir_all(&frontend).unwrap();
1100
1101 let toplevel = resolve_git_toplevel(&frontend).unwrap();
1102 assert_eq!(toplevel, repo, "toplevel should equal canonical repo root");
1103 assert_eq!(
1109 toplevel,
1110 dunce::canonicalize(&toplevel).unwrap(),
1111 "resolved toplevel should already be canonical"
1112 );
1113 }
1114
1115 #[test]
1119 fn resolve_git_toplevel_not_a_repository() {
1120 let tmp = tempfile::tempdir().unwrap();
1121 let result = resolve_git_toplevel(tmp.path());
1122 assert!(
1123 matches!(result, Err(ChangedFilesError::NotARepository)),
1124 "expected NotARepository, got {result:?}"
1125 );
1126 }
1127
1128 #[test]
1131 fn try_get_changed_files_not_a_repository() {
1132 let tmp = tempfile::tempdir().unwrap();
1133 let result = try_get_changed_files(tmp.path(), "main");
1134 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1135 }
1136
1137 #[test]
1138 fn filter_duplication_drops_groups_with_no_changed_instance() {
1139 let mut report = DuplicationReport {
1140 clone_groups: vec![CloneGroup {
1141 instances: vec![CloneInstance {
1142 file: "/a.ts".into(),
1143 start_line: 1,
1144 end_line: 5,
1145 start_col: 0,
1146 end_col: 10,
1147 fragment: "code".into(),
1148 }],
1149 token_count: 20,
1150 line_count: 5,
1151 }],
1152 clone_families: vec![],
1153 mirrored_directories: vec![],
1154 stats: DuplicationStats {
1155 total_files: 1,
1156 files_with_clones: 1,
1157 total_lines: 100,
1158 duplicated_lines: 5,
1159 total_tokens: 100,
1160 duplicated_tokens: 20,
1161 clone_groups: 1,
1162 clone_instances: 1,
1163 duplication_percentage: 5.0,
1164 clone_groups_below_min_occurrences: 0,
1165 },
1166 };
1167
1168 let changed: FxHashSet<PathBuf> = FxHashSet::default();
1169 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1170 assert!(report.clone_groups.is_empty());
1171 assert_eq!(report.stats.clone_groups, 0);
1172 assert_eq!(report.stats.clone_instances, 0);
1173 assert!((report.stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
1174 }
1175}