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