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::{
24 AnalysisResults, CircularDependencyFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
25 ReExportCycleFinding, SecurityFinding, UnlistedDependencyFinding,
26};
27use fallow_types::output_dead_code::PropDrillingChainFinding;
28
29pub type ChangedFilesSpawnHook = fn(&mut std::process::Command) -> std::io::Result<Output>;
36
37static SPAWN_HOOK: OnceLock<ChangedFilesSpawnHook> = OnceLock::new();
38
39pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
46 let _ = SPAWN_HOOK.set(hook);
47}
48
49fn spawn_output(command: &mut std::process::Command) -> std::io::Result<Output> {
50 if let Some(hook) = SPAWN_HOOK.get() {
51 hook(command)
52 } else {
53 command.output()
54 }
55}
56
57pub fn validate_git_ref(s: &str) -> Result<&str, String> {
70 if s.is_empty() {
71 return Err("git ref cannot be empty".to_string());
72 }
73 if s.starts_with('-') {
74 return Err("git ref cannot start with '-'".to_string());
75 }
76 let mut in_braces = false;
77 for c in s.chars() {
78 match c {
79 '{' => in_braces = true,
80 '}' => in_braces = false,
81 ':' | ' ' if in_braces => {}
82 c if c.is_ascii_alphanumeric()
83 || matches!(c, '.' | '_' | '-' | '/' | '~' | '^' | '@' | '{' | '}') => {}
84 _ => return Err(format!("git ref contains disallowed character: '{c}'")),
85 }
86 }
87 if in_braces {
88 return Err("git ref has unclosed '{'".to_string());
89 }
90 Ok(s)
91}
92
93#[derive(Debug)]
96pub enum ChangedFilesError {
97 InvalidRef(String),
99 GitMissing(String),
101 NotARepository,
103 GitFailed(String),
105}
106
107impl ChangedFilesError {
108 pub fn describe(&self) -> String {
112 match self {
113 Self::InvalidRef(e) => format!("invalid git ref: {e}"),
114 Self::GitMissing(e) => format!("failed to run git: {e}"),
115 Self::NotARepository => "not a git repository".to_owned(),
116 Self::GitFailed(stderr) => augment_git_failed(stderr),
117 }
118 }
119}
120
121fn augment_git_failed(stderr: &str) -> String {
127 let lower = stderr.to_ascii_lowercase();
128 if lower.contains("not a valid object name")
129 || lower.contains("unknown revision")
130 || lower.contains("ambiguous argument")
131 {
132 format!(
133 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
134 )
135 } else {
136 stderr.to_owned()
137 }
138}
139
140pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
151 let output = spawn_output(&mut git_command(cwd, &["rev-parse", "--show-toplevel"]))
152 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
153
154 if !output.status.success() {
155 let stderr = String::from_utf8_lossy(&output.stderr);
156 return Err(if stderr.contains("not a git repository") {
157 ChangedFilesError::NotARepository
158 } else {
159 ChangedFilesError::GitFailed(stderr.trim().to_owned())
160 });
161 }
162
163 let raw = String::from_utf8_lossy(&output.stdout);
164 let trimmed = raw.trim();
165 if trimmed.is_empty() {
166 return Err(ChangedFilesError::GitFailed(
167 "git rev-parse --show-toplevel returned empty output".to_owned(),
168 ));
169 }
170
171 let path = PathBuf::from(trimmed);
172 Ok(dunce::canonicalize(&path).unwrap_or(path))
173}
174
175pub fn resolve_git_common_dir(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
189 let output = spawn_output(&mut git_command(
190 cwd,
191 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
192 ))
193 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
194
195 if !output.status.success() {
196 let stderr = String::from_utf8_lossy(&output.stderr);
197 return Err(if stderr.contains("not a git repository") {
198 ChangedFilesError::NotARepository
199 } else {
200 ChangedFilesError::GitFailed(stderr.trim().to_owned())
201 });
202 }
203
204 let raw = String::from_utf8_lossy(&output.stdout);
205 let trimmed = raw.trim();
206 if trimmed.is_empty() {
207 return Err(ChangedFilesError::GitFailed(
208 "git rev-parse --git-common-dir returned empty output".to_owned(),
209 ));
210 }
211
212 let path = PathBuf::from(trimmed);
213 Ok(dunce::canonicalize(&path).unwrap_or(path))
214}
215
216fn collect_git_paths(
217 cwd: &Path,
218 toplevel: &Path,
219 args: &[&str],
220) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
221 let output = spawn_output(&mut git_command(cwd, args))
222 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
223
224 if !output.status.success() {
225 let stderr = String::from_utf8_lossy(&output.stderr);
226 return Err(if stderr.contains("not a git repository") {
227 ChangedFilesError::NotARepository
228 } else {
229 ChangedFilesError::GitFailed(stderr.trim().to_owned())
230 });
231 }
232
233 #[cfg(windows)]
234 let normalise_segment = |line: &str| line.replace('/', "\\");
235 #[cfg(not(windows))]
236 let normalise_segment = |line: &str| line.to_owned();
237
238 let files: FxHashSet<PathBuf> = String::from_utf8_lossy(&output.stdout)
239 .lines()
240 .filter(|line| !line.is_empty())
241 .map(|line| toplevel.join(normalise_segment(line)))
242 .collect();
243
244 Ok(files)
245}
246
247fn git_command(cwd: &Path, args: &[&str]) -> std::process::Command {
248 let mut command = crate::spawn::git();
249 command.args(args).current_dir(cwd);
250 command
251}
252
253pub fn try_get_changed_files(
271 root: &Path,
272 git_ref: &str,
273) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
274 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
275 let toplevel = resolve_git_toplevel(root)?;
276 try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
277}
278
279pub fn try_get_changed_files_with_toplevel(
287 cwd: &Path,
288 toplevel: &Path,
289 git_ref: &str,
290) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
291 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
292
293 let mut files = collect_git_paths(
294 cwd,
295 toplevel,
296 &[
297 "diff",
298 "--name-only",
299 "--end-of-options",
300 &format!("{git_ref}...HEAD"),
301 ],
302 )?;
303 files.extend(collect_git_paths(
304 cwd,
305 toplevel,
306 &["diff", "--name-only", "HEAD"],
307 )?);
308 files.extend(collect_git_paths(
309 cwd,
310 toplevel,
311 &["ls-files", "--full-name", "--others", "--exclude-standard"],
312 )?);
313 Ok(files)
314}
315
316pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
332 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
333 let output = spawn_output(&mut git_command(
334 root,
335 &[
336 "diff",
337 "--relative",
338 "--unified=0",
339 "--end-of-options",
340 &format!("{git_ref}...HEAD"),
341 ],
342 ))
343 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
344
345 if !output.status.success() {
346 let stderr = String::from_utf8_lossy(&output.stderr);
347 return Err(if stderr.contains("not a git repository") {
348 ChangedFilesError::NotARepository
349 } else {
350 ChangedFilesError::GitFailed(stderr.trim().to_owned())
351 });
352 }
353
354 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
355}
356
357#[expect(
361 clippy::print_stderr,
362 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; LSP callers use try_get_changed_files instead"
363)]
364pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
365 match try_get_changed_files(root, git_ref) {
366 Ok(files) => Some(files),
367 Err(ChangedFilesError::InvalidRef(e)) => {
368 eprintln!("Warning: --changed-since ignored: invalid git ref: {e}");
369 None
370 }
371 Err(ChangedFilesError::GitMissing(e)) => {
372 eprintln!("Warning: --changed-since ignored: failed to run git: {e}");
373 None
374 }
375 Err(ChangedFilesError::NotARepository) => {
376 eprintln!("Warning: --changed-since ignored: not a git repository");
377 None
378 }
379 Err(ChangedFilesError::GitFailed(stderr)) => {
380 eprintln!("Warning: --changed-since failed for ref '{git_ref}': {stderr}");
381 None
382 }
383 }
384}
385
386#[expect(
399 clippy::implicit_hasher,
400 reason = "fallow standardizes on FxHashSet across the workspace"
401)]
402pub fn filter_results_by_changed_files(
403 results: &mut AnalysisResults,
404 changed_files: &FxHashSet<PathBuf>,
405) {
406 let cf = normalize_changed_files_set(changed_files);
407 classify_changed_file_filter_fields(results);
408 retain_basic_issue_findings_by_changed_path(results, &cf);
409 retain_graph_findings_by_changed_files(results, &cf);
410 retain_boundary_policy_and_suppression_findings(results, &cf);
411 retain_security_and_workspace_findings(results, &cf);
412 retain_framework_findings_by_changed_files(results, &cf);
413}
414
415fn classify_changed_file_filter_fields(results: &AnalysisResults) {
416 let AnalysisResults {
417 unused_files: _unused_files,
418 unused_exports: _unused_exports,
419 unused_types: _unused_types,
420 private_type_leaks: _private_type_leaks,
421 unused_dependencies: _unused_dependencies,
425 unused_dev_dependencies: _unused_dev_dependencies,
426 unused_optional_dependencies: _unused_optional_dependencies,
427 unused_enum_members: _unused_enum_members,
428 unused_class_members: _unused_class_members,
429 unused_store_members: _unused_store_members,
430 unresolved_imports: _unresolved_imports,
431 unlisted_dependencies: _unlisted_dependencies,
432 duplicate_exports: _duplicate_exports,
433 type_only_dependencies: _type_only_dependencies,
436 test_only_dependencies: _test_only_dependencies,
437 circular_dependencies: _circular_dependencies,
438 re_export_cycles: _re_export_cycles,
439 boundary_violations: _boundary_violations,
440 boundary_coverage_violations: _boundary_coverage_violations,
441 boundary_call_violations: _boundary_call_violations,
442 policy_violations: _policy_violations,
443 stale_suppressions: _stale_suppressions,
444 unused_catalog_entries: _unused_catalog_entries,
448 empty_catalog_groups: _empty_catalog_groups,
449 unresolved_catalog_references: _unresolved_catalog_references,
450 unused_dependency_overrides: _unused_dependency_overrides,
451 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
452 invalid_client_exports: _invalid_client_exports,
453 mixed_client_server_barrels: _mixed_client_server_barrels,
454 misplaced_directives: _misplaced_directives,
455 unprovided_injects: _unprovided_injects,
456 unrendered_components: _unrendered_components,
457 route_collisions: _route_collisions,
458 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
459 unused_component_props: _unused_component_props,
460 unused_component_emits: _unused_component_emits,
461 unused_component_inputs: _unused_component_inputs,
462 unused_component_outputs: _unused_component_outputs,
463 unused_svelte_events: _unused_svelte_events,
464 unused_server_actions: _unused_server_actions,
465 unused_load_data_keys: _unused_load_data_keys,
466 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
468 prop_drilling_chains: _prop_drilling_chains,
469 thin_wrappers: _thin_wrappers,
470 duplicate_prop_shapes: _duplicate_prop_shapes,
471 suppression_count: _suppression_count,
473 active_suppressions: _active_suppressions,
474 feature_flags: _feature_flags,
475 security_findings: _security_findings,
476 security_unresolved_edge_files: _security_unresolved_edge_files,
477 security_unresolved_callee_sites: _security_unresolved_callee_sites,
478 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
479 export_usages: _export_usages,
482 entry_point_summary: _entry_point_summary,
483 render_fan_in: _render_fan_in,
487 } = results;
488}
489
490fn retain_basic_issue_findings_by_changed_path(
491 results: &mut AnalysisResults,
492 changed_files: &FxHashSet<PathBuf>,
493) {
494 retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
495 retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
496 &e.export.path
497 });
498 retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
499 retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
500 &e.leak.path
501 });
502 retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
503 &m.member.path
504 });
505 retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
506 &m.member.path
507 });
508 retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
509 &m.member.path
510 });
511 retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
512 &i.import.path
513 });
514}
515
516fn retain_graph_findings_by_changed_files(
517 results: &mut AnalysisResults,
518 changed_files: &FxHashSet<PathBuf>,
519) {
520 retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
521 retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
522 retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
523 retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
524}
525
526fn retain_boundary_policy_and_suppression_findings(
527 results: &mut AnalysisResults,
528 changed_files: &FxHashSet<PathBuf>,
529) {
530 retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
531 &v.violation.from_path
532 });
533 retain_by_changed_path(
534 &mut results.boundary_coverage_violations,
535 changed_files,
536 |v| &v.violation.path,
537 );
538 retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
539 &v.violation.path
540 });
541 retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
542 &v.violation.path
543 });
544 retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
545}
546
547fn retain_security_and_workspace_findings(
548 results: &mut AnalysisResults,
549 changed_files: &FxHashSet<PathBuf>,
550) {
551 retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
552 retain_by_changed_path(
553 &mut results.security_unresolved_callee_diagnostics,
554 changed_files,
555 |d| &d.path,
556 );
557 retain_by_changed_path(
558 &mut results.unresolved_catalog_references,
559 changed_files,
560 |r| &r.reference.path,
561 );
562 results
563 .empty_catalog_groups
564 .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
565 retain_by_changed_path(
566 &mut results.unused_dependency_overrides,
567 changed_files,
568 |o| &o.entry.path,
569 );
570 retain_by_changed_path(
571 &mut results.misconfigured_dependency_overrides,
572 changed_files,
573 |o| &o.entry.path,
574 );
575}
576
577fn retain_framework_findings_by_changed_files(
578 results: &mut AnalysisResults,
579 changed_files: &FxHashSet<PathBuf>,
580) {
581 retain_client_boundary_findings_by_changed_files(results, changed_files);
582 retain_component_contract_findings_by_changed_files(results, changed_files);
583 retain_react_health_findings_by_changed_files(results, changed_files);
584 retain_nextjs_findings_by_changed_files(results, changed_files);
585}
586
587fn retain_client_boundary_findings_by_changed_files(
588 results: &mut AnalysisResults,
589 changed_files: &FxHashSet<PathBuf>,
590) {
591 let AnalysisResults {
592 invalid_client_exports,
593 mixed_client_server_barrels,
594 misplaced_directives,
595 ..
596 } = results;
597
598 retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
599 retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
600 &b.barrel.path
601 });
602 retain_by_changed_path(misplaced_directives, changed_files, |d| {
603 &d.directive_site.path
604 });
605}
606
607fn retain_component_contract_findings_by_changed_files(
608 results: &mut AnalysisResults,
609 changed_files: &FxHashSet<PathBuf>,
610) {
611 let AnalysisResults {
612 unprovided_injects,
613 unrendered_components,
614 unused_component_props,
615 unused_component_emits,
616 unused_component_inputs,
617 unused_component_outputs,
618 unused_svelte_events,
619 unused_server_actions,
620 unused_load_data_keys,
621 ..
622 } = results;
623
624 retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
625 retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
626 retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
627 retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
628 retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
629 retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
630 retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
631 retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
632 retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
633}
634
635fn retain_react_health_findings_by_changed_files(
636 results: &mut AnalysisResults,
637 changed_files: &FxHashSet<PathBuf>,
638) {
639 let AnalysisResults {
640 prop_drilling_chains,
641 thin_wrappers,
642 duplicate_prop_shapes,
643 ..
644 } = results;
645
646 retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
647 retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
648 retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
649}
650
651fn retain_nextjs_findings_by_changed_files(
652 results: &mut AnalysisResults,
653 changed_files: &FxHashSet<PathBuf>,
654) {
655 let AnalysisResults {
656 route_collisions,
657 dynamic_segment_name_conflicts,
658 ..
659 } = results;
660
661 retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
662 retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
663 &c.conflict.path
664 });
665}
666
667fn retain_unlisted_dependencies_by_import_site(
668 dependencies: &mut Vec<UnlistedDependencyFinding>,
669 changed_files: &FxHashSet<PathBuf>,
670) {
671 dependencies.retain(|dependency| {
672 dependency
673 .dep
674 .imported_from
675 .iter()
676 .any(|site| contains_normalized(changed_files, &site.path))
677 });
678}
679
680fn retain_duplicate_exports_by_changed_locations(
681 duplicate_exports: &mut Vec<DuplicateExportFinding>,
682 changed_files: &FxHashSet<PathBuf>,
683) {
684 for duplicate in &mut *duplicate_exports {
685 duplicate
686 .export
687 .locations
688 .retain(|location| contains_normalized(changed_files, &location.path));
689 }
690 duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
691}
692
693fn retain_circular_dependencies_by_changed_file(
694 cycles: &mut Vec<CircularDependencyFinding>,
695 changed_files: &FxHashSet<PathBuf>,
696) {
697 cycles.retain(|cycle| {
698 cycle
699 .cycle
700 .files
701 .iter()
702 .any(|file| contains_normalized(changed_files, file))
703 });
704}
705
706fn retain_re_export_cycles_by_changed_file(
707 cycles: &mut Vec<ReExportCycleFinding>,
708 changed_files: &FxHashSet<PathBuf>,
709) {
710 cycles.retain(|cycle| {
711 cycle
712 .cycle
713 .files
714 .iter()
715 .any(|file| contains_normalized(changed_files, file))
716 });
717}
718
719fn retain_security_findings_by_changed_path(
720 findings: &mut Vec<SecurityFinding>,
721 changed_files: &FxHashSet<PathBuf>,
722) {
723 findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
724}
725
726fn retain_prop_drilling_chains_by_anchor(
727 chains: &mut Vec<PropDrillingChainFinding>,
728 changed_files: &FxHashSet<PathBuf>,
729) {
730 chains.retain(|chain| {
732 chain
733 .chain
734 .hops
735 .first()
736 .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
737 });
738}
739
740fn retain_duplicate_prop_shapes_by_anchor(
741 shapes: &mut Vec<DuplicatePropShapeFinding>,
742 changed_files: &FxHashSet<PathBuf>,
743) {
744 retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
746}
747
748fn retain_by_changed_path<T>(
749 items: &mut Vec<T>,
750 changed_files: &FxHashSet<PathBuf>,
751 path: impl Fn(&T) -> &Path,
752) {
753 items.retain(|item| contains_normalized(changed_files, path(item)));
754}
755
756fn security_finding_touches_changed_path(
757 finding: &SecurityFinding,
758 changed_files: &FxHashSet<PathBuf>,
759) -> bool {
760 contains_normalized(changed_files, &finding.path)
761 || finding
762 .trace
763 .iter()
764 .any(|hop| contains_normalized(changed_files, &hop.path))
765 || finding.reachability.as_ref().is_some_and(|reachability| {
766 reachability
767 .untrusted_source_trace
768 .iter()
769 .any(|hop| contains_normalized(changed_files, &hop.path))
770 })
771}
772
773fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
786 changed_files
787 .iter()
788 .map(|p| dunce::simplified(p).to_path_buf())
789 .collect()
790}
791
792fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
793 normalized.contains(dunce::simplified(path))
794}
795
796fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
797 contains_normalized(normalized, path)
798 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
799}
800
801fn recompute_duplication_stats(report: &DuplicationReport) -> DuplicationStats {
807 let mut files_with_clones: FxHashSet<&Path> = FxHashSet::default();
808 let mut file_dup_lines: FxHashMap<&Path, FxHashSet<usize>> = FxHashMap::default();
809 let mut duplicated_tokens = 0_usize;
810 let mut clone_instances = 0_usize;
811
812 for group in &report.clone_groups {
813 for instance in &group.instances {
814 files_with_clones.insert(&instance.file);
815 clone_instances += 1;
816 let lines = file_dup_lines.entry(&instance.file).or_default();
817 for line in instance.start_line..=instance.end_line {
818 lines.insert(line);
819 }
820 }
821 duplicated_tokens += group.token_count * group.instances.len();
822 }
823
824 let duplicated_lines: usize = file_dup_lines.values().map(FxHashSet::len).sum();
825
826 DuplicationStats {
827 total_files: report.stats.total_files,
828 files_with_clones: files_with_clones.len(),
829 total_lines: report.stats.total_lines,
830 duplicated_lines,
831 total_tokens: report.stats.total_tokens,
832 duplicated_tokens,
833 clone_groups: report.clone_groups.len(),
834 clone_instances,
835 #[expect(
836 clippy::cast_precision_loss,
837 reason = "stat percentages are display-only; precision loss at usize::MAX line counts is acceptable"
838 )]
839 duplication_percentage: if report.stats.total_lines > 0 {
840 (duplicated_lines as f64 / report.stats.total_lines as f64) * 100.0
841 } else {
842 0.0
843 },
844 clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
845 }
846}
847
848#[expect(
853 clippy::implicit_hasher,
854 reason = "fallow standardizes on FxHashSet across the workspace"
855)]
856pub fn filter_duplication_by_changed_files(
857 report: &mut DuplicationReport,
858 changed_files: &FxHashSet<PathBuf>,
859 root: &Path,
860) {
861 let cf = normalize_changed_files_set(changed_files);
862 report.clone_groups.retain(|g| {
863 g.instances
864 .iter()
865 .any(|i| contains_normalized(&cf, &i.file))
866 });
867 report.clone_families = families::group_into_families(&report.clone_groups, root);
868 report.mirrored_directories =
869 families::detect_mirrored_directories(&report.clone_families, root);
870 report.stats = recompute_duplication_stats(report);
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876 use crate::duplicates::{CloneGroup, CloneInstance};
877 use crate::results::{
878 BoundaryViolation, CircularDependency, EmptyCatalogGroup, SecurityFinding,
879 SecurityFindingKind, SecurityUnresolvedCalleeDiagnostic, TraceHop, TraceHopRole,
880 UnusedExport, UnusedFile,
881 };
882 use fallow_types::extract::{SkippedSecurityCalleeExpressionKind, SkippedSecurityCalleeReason};
883 use fallow_types::output_dead_code::{
884 BoundaryViolationFinding, CircularDependencyFinding, EmptyCatalogGroupFinding,
885 UnusedExportFinding, UnusedFileFinding,
886 };
887 use fallow_types::results::{SecurityReachability, SecuritySeverity};
888
889 #[test]
890 fn changed_files_error_describe_variants() {
891 assert!(
892 ChangedFilesError::InvalidRef("bad".to_owned())
893 .describe()
894 .contains("invalid git ref")
895 );
896 assert!(
897 ChangedFilesError::GitMissing("oops".to_owned())
898 .describe()
899 .contains("oops")
900 );
901 assert_eq!(
902 ChangedFilesError::NotARepository.describe(),
903 "not a git repository"
904 );
905 assert!(
906 ChangedFilesError::GitFailed("bad ref".to_owned())
907 .describe()
908 .contains("bad ref")
909 );
910 }
911
912 #[test]
913 fn augment_git_failed_appends_shallow_clone_hint_for_unknown_revision() {
914 let stderr = "fatal: ambiguous argument 'fallow-baseline...HEAD': unknown revision or path not in the working tree.";
915 let described = ChangedFilesError::GitFailed(stderr.to_owned()).describe();
916 assert!(described.contains(stderr), "original stderr preserved");
917 assert!(
918 described.contains("shallow clone"),
919 "hint surfaced: {described}"
920 );
921 assert!(
922 described.contains("fetch-depth: 0") || described.contains("git fetch --unshallow"),
923 "hint actionable: {described}"
924 );
925 }
926
927 #[test]
928 fn augment_git_failed_passthrough_for_other_errors() {
929 let stderr = "fatal: refusing to merge unrelated histories";
930 let described = ChangedFilesError::GitFailed(stderr.to_owned()).describe();
931 assert_eq!(described, stderr);
932 }
933
934 #[test]
935 fn validate_git_ref_rejects_leading_dash() {
936 assert!(validate_git_ref("--upload-pack=evil").is_err());
937 assert!(validate_git_ref("-flag").is_err());
938 }
939
940 #[test]
941 fn validate_git_ref_accepts_baseline_tag() {
942 assert_eq!(
943 validate_git_ref("fallow-baseline").unwrap(),
944 "fallow-baseline"
945 );
946 }
947
948 #[test]
949 fn changed_files_filter_scopes_unresolved_callee_diagnostics() {
950 let mut results = AnalysisResults::default();
951 results
952 .security_unresolved_callee_diagnostics
953 .push(SecurityUnresolvedCalleeDiagnostic {
954 path: PathBuf::from("/repo/src/changed.ts"),
955 line: 4,
956 col: 0,
957 reason: SkippedSecurityCalleeReason::DynamicDispatch,
958 expression_kind: SkippedSecurityCalleeExpressionKind::Other,
959 });
960 results
961 .security_unresolved_callee_diagnostics
962 .push(SecurityUnresolvedCalleeDiagnostic {
963 path: PathBuf::from("/repo/src/unchanged.ts"),
964 line: 4,
965 col: 0,
966 reason: SkippedSecurityCalleeReason::ComputedMember,
967 expression_kind: SkippedSecurityCalleeExpressionKind::ComputedMemberExpression,
968 });
969
970 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
971 changed.insert(PathBuf::from("/repo/src/changed.ts"));
972
973 filter_results_by_changed_files(&mut results, &changed);
974
975 assert_eq!(results.security_unresolved_callee_diagnostics.len(), 1);
976 assert_eq!(
977 results.security_unresolved_callee_diagnostics[0].path,
978 PathBuf::from("/repo/src/changed.ts")
979 );
980 }
981
982 #[test]
983 fn try_get_changed_files_rejects_invalid_ref() {
984 let err = try_get_changed_files(Path::new("/"), "--evil")
985 .expect_err("leading-dash ref must be rejected");
986 assert!(matches!(err, ChangedFilesError::InvalidRef(_)));
987 assert!(err.describe().contains("cannot start with"));
988 }
989
990 #[test]
991 fn validate_git_ref_rejects_option_like_ref() {
992 assert!(validate_git_ref("--output=/tmp/fallow-proof").is_err());
993 }
994
995 #[test]
996 fn validate_git_ref_allows_reflog_relative_date() {
997 assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
998 }
999
1000 #[test]
1001 fn try_get_changed_files_rejects_option_like_ref_before_git() {
1002 let root = tempfile::tempdir().expect("create temp dir");
1003 let proof_path = root.path().join("proof");
1004
1005 let result = try_get_changed_files(
1006 root.path(),
1007 &format!("--output={}", proof_path.to_string_lossy()),
1008 );
1009
1010 assert!(matches!(result, Err(ChangedFilesError::InvalidRef(_))));
1011 assert!(
1012 !proof_path.exists(),
1013 "invalid changedSince ref must not be passed through to git as an option"
1014 );
1015 }
1016
1017 #[test]
1018 fn git_command_clears_parent_git_environment() {
1019 let command = git_command(Path::new("."), &["status", "--short"]);
1020 let overrides: Vec<_> = command.get_envs().collect();
1021
1022 for var in crate::git_env::AMBIENT_GIT_ENV_VARS {
1023 assert!(
1024 overrides
1025 .iter()
1026 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
1027 "git helper must clear inherited {var}",
1028 );
1029 }
1030 }
1031
1032 #[test]
1033 fn filter_results_keeps_only_changed_files() {
1034 let mut results = AnalysisResults::default();
1035 results
1036 .unused_files
1037 .push(UnusedFileFinding::with_actions(UnusedFile {
1038 path: "/a.ts".into(),
1039 }));
1040 results
1041 .unused_files
1042 .push(UnusedFileFinding::with_actions(UnusedFile {
1043 path: "/b.ts".into(),
1044 }));
1045 results
1046 .unused_exports
1047 .push(UnusedExportFinding::with_actions(UnusedExport {
1048 path: "/a.ts".into(),
1049 export_name: "foo".into(),
1050 is_type_only: false,
1051 line: 1,
1052 col: 0,
1053 span_start: 0,
1054 is_re_export: false,
1055 }));
1056
1057 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1058 changed.insert("/a.ts".into());
1059
1060 filter_results_by_changed_files(&mut results, &changed);
1061
1062 assert_eq!(results.unused_files.len(), 1);
1063 assert_eq!(results.unused_files[0].file.path, PathBuf::from("/a.ts"));
1064 assert_eq!(results.unused_exports.len(), 1);
1065 }
1066
1067 #[test]
1068 fn filter_results_preserves_dependency_level_issues() {
1069 let mut results = AnalysisResults::default();
1070 results.unused_dependencies.push(
1071 fallow_types::output_dead_code::UnusedDependencyFinding::with_actions(
1072 crate::results::UnusedDependency {
1073 package_name: "lodash".into(),
1074 location: crate::results::DependencyLocation::Dependencies,
1075 path: "/pkg.json".into(),
1076 line: 3,
1077 used_in_workspaces: Vec::new(),
1078 },
1079 ),
1080 );
1081
1082 let changed: FxHashSet<PathBuf> = FxHashSet::default();
1083 filter_results_by_changed_files(&mut results, &changed);
1084
1085 assert_eq!(results.unused_dependencies.len(), 1);
1086 }
1087
1088 #[test]
1089 fn filter_results_keeps_circular_dep_when_any_file_changed() {
1090 let mut results = AnalysisResults::default();
1091 results
1092 .circular_dependencies
1093 .push(CircularDependencyFinding::with_actions(
1094 CircularDependency {
1095 files: vec!["/a.ts".into(), "/b.ts".into()],
1096 length: 2,
1097 line: 1,
1098 col: 0,
1099 edges: Vec::new(),
1100 is_cross_package: false,
1101 },
1102 ));
1103
1104 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1105 changed.insert("/b.ts".into());
1106
1107 filter_results_by_changed_files(&mut results, &changed);
1108 assert_eq!(results.circular_dependencies.len(), 1);
1109 }
1110
1111 #[test]
1112 fn filter_results_drops_circular_dep_when_no_file_changed() {
1113 let mut results = AnalysisResults::default();
1114 results
1115 .circular_dependencies
1116 .push(CircularDependencyFinding::with_actions(
1117 CircularDependency {
1118 files: vec!["/a.ts".into(), "/b.ts".into()],
1119 length: 2,
1120 line: 1,
1121 col: 0,
1122 edges: Vec::new(),
1123 is_cross_package: false,
1124 },
1125 ));
1126
1127 let changed: FxHashSet<PathBuf> = FxHashSet::default();
1128 filter_results_by_changed_files(&mut results, &changed);
1129 assert!(results.circular_dependencies.is_empty());
1130 }
1131
1132 #[test]
1133 fn filter_results_drops_boundary_violation_when_importer_unchanged() {
1134 let mut results = AnalysisResults::default();
1135 results
1136 .boundary_violations
1137 .push(BoundaryViolationFinding::with_actions(BoundaryViolation {
1138 from_path: "/a.ts".into(),
1139 to_path: "/b.ts".into(),
1140 from_zone: "ui".into(),
1141 to_zone: "data".into(),
1142 import_specifier: "../data/db".into(),
1143 line: 1,
1144 col: 0,
1145 }));
1146
1147 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1148 changed.insert("/b.ts".into());
1149
1150 filter_results_by_changed_files(&mut results, &changed);
1151 assert!(results.boundary_violations.is_empty());
1152 }
1153
1154 #[test]
1155 fn filter_results_keeps_security_finding_when_trace_file_changed() {
1156 let mut results = AnalysisResults::default();
1157 results.security_findings.push(SecurityFinding {
1158 finding_id: String::new(),
1159 candidate: fallow_types::results::SecurityCandidate::default(),
1160 taint_flow: None,
1161 attack_surface: None,
1162 kind: SecurityFindingKind::ClientServerLeak,
1163 category: None,
1164 cwe: None,
1165 path: "/project/src/client.tsx".into(),
1166 line: 2,
1167 col: 0,
1168 evidence: "candidate".into(),
1169 source_backed: false,
1170 source_read: None,
1171 severity: SecuritySeverity::Low,
1172 trace: vec![
1173 TraceHop {
1174 path: "/project/src/client.tsx".into(),
1175 line: 2,
1176 col: 0,
1177 role: TraceHopRole::ClientBoundary,
1178 },
1179 TraceHop {
1180 path: "/project/src/server.ts".into(),
1181 line: 1,
1182 col: 0,
1183 role: TraceHopRole::SecretSource,
1184 },
1185 ],
1186 actions: Vec::new(),
1187 dead_code: None,
1188 reachability: None,
1189 runtime: None,
1190 });
1191
1192 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1193 changed.insert("/project/src/server.ts".into());
1194
1195 filter_results_by_changed_files(&mut results, &changed);
1196
1197 assert_eq!(results.security_findings.len(), 1);
1198 }
1199
1200 #[test]
1201 fn filter_results_keeps_security_finding_when_untrusted_source_trace_file_changed() {
1202 let mut results = AnalysisResults::default();
1203 results.security_findings.push(SecurityFinding {
1204 finding_id: String::new(),
1205 candidate: fallow_types::results::SecurityCandidate::default(),
1206 taint_flow: None,
1207 attack_surface: None,
1208 kind: SecurityFindingKind::TaintedSink,
1209 category: Some("command-injection".into()),
1210 cwe: Some(78),
1211 path: "/project/src/runner.ts".into(),
1212 line: 4,
1213 col: 2,
1214 evidence: "candidate".into(),
1215 source_backed: false,
1216 source_read: None,
1217 severity: SecuritySeverity::Low,
1218 trace: Vec::new(),
1219 actions: Vec::new(),
1220 dead_code: None,
1221 reachability: Some(SecurityReachability {
1222 reachable_from_entry: false,
1223 reachable_from_untrusted_source: true,
1224 taint_confidence: Some(fallow_types::results::TaintConfidence::ModuleLevel),
1225 untrusted_source_hop_count: Some(1),
1226 untrusted_source_trace: vec![
1227 TraceHop {
1228 path: "/project/src/route.ts".into(),
1229 line: 1,
1230 col: 0,
1231 role: TraceHopRole::UntrustedSource,
1232 },
1233 TraceHop {
1234 path: "/project/src/runner.ts".into(),
1235 line: 4,
1236 col: 2,
1237 role: TraceHopRole::Sink,
1238 },
1239 ],
1240 blast_radius: 0,
1241 crosses_boundary: false,
1242 }),
1243 runtime: None,
1244 });
1245
1246 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1247 changed.insert("/project/src/route.ts".into());
1248
1249 filter_results_by_changed_files(&mut results, &changed);
1250
1251 assert_eq!(results.security_findings.len(), 1);
1252 }
1253
1254 #[test]
1255 fn filter_results_keeps_relative_empty_catalog_group_when_manifest_changed() {
1256 let mut results = AnalysisResults::default();
1257 results
1258 .empty_catalog_groups
1259 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1260 catalog_name: "legacy".into(),
1261 path: PathBuf::from("pnpm-workspace.yaml"),
1262 line: 4,
1263 }));
1264
1265 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1266 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1267
1268 filter_results_by_changed_files(&mut results, &changed);
1269
1270 assert_eq!(results.empty_catalog_groups.len(), 1);
1271 assert_eq!(results.empty_catalog_groups[0].group.catalog_name, "legacy");
1272 }
1273
1274 #[test]
1275 fn filter_duplication_keeps_groups_with_at_least_one_changed_instance() {
1276 let mut report = DuplicationReport {
1277 clone_groups: vec![CloneGroup {
1278 instances: vec![
1279 CloneInstance {
1280 file: "/a.ts".into(),
1281 start_line: 1,
1282 end_line: 5,
1283 start_col: 0,
1284 end_col: 10,
1285 fragment: "code".into(),
1286 },
1287 CloneInstance {
1288 file: "/b.ts".into(),
1289 start_line: 1,
1290 end_line: 5,
1291 start_col: 0,
1292 end_col: 10,
1293 fragment: "code".into(),
1294 },
1295 ],
1296 token_count: 20,
1297 line_count: 5,
1298 }],
1299 clone_families: vec![],
1300 mirrored_directories: vec![],
1301 stats: DuplicationStats {
1302 total_files: 2,
1303 files_with_clones: 2,
1304 total_lines: 100,
1305 duplicated_lines: 10,
1306 total_tokens: 200,
1307 duplicated_tokens: 40,
1308 clone_groups: 1,
1309 clone_instances: 2,
1310 duplication_percentage: 10.0,
1311 clone_groups_below_min_occurrences: 0,
1312 },
1313 };
1314
1315 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1316 changed.insert("/a.ts".into());
1317
1318 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1319 assert_eq!(report.clone_groups.len(), 1);
1320 assert_eq!(report.stats.clone_groups, 1);
1321 assert_eq!(report.stats.clone_instances, 2);
1322 }
1323
1324 #[cfg(windows)]
1332 #[test]
1333 fn filter_duplication_normalises_verbatim_prefix_mismatch() {
1334 let mut report = DuplicationReport {
1335 clone_groups: vec![CloneGroup {
1336 instances: vec![
1337 CloneInstance {
1338 file: PathBuf::from(r"\\?\C:\repo\src\changed.ts"),
1339 start_line: 1,
1340 end_line: 5,
1341 start_col: 0,
1342 end_col: 10,
1343 fragment: "code".into(),
1344 },
1345 CloneInstance {
1346 file: PathBuf::from(r"\\?\C:\repo\src\focused-copy.ts"),
1347 start_line: 1,
1348 end_line: 5,
1349 start_col: 0,
1350 end_col: 10,
1351 fragment: "code".into(),
1352 },
1353 ],
1354 token_count: 20,
1355 line_count: 5,
1356 }],
1357 clone_families: vec![],
1358 mirrored_directories: vec![],
1359 stats: DuplicationStats {
1360 total_files: 2,
1361 files_with_clones: 2,
1362 total_lines: 100,
1363 duplicated_lines: 10,
1364 total_tokens: 200,
1365 duplicated_tokens: 40,
1366 clone_groups: 1,
1367 clone_instances: 2,
1368 duplication_percentage: 10.0,
1369 clone_groups_below_min_occurrences: 0,
1370 },
1371 };
1372
1373 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1374 changed.insert(PathBuf::from(r"C:\repo\src\changed.ts"));
1375
1376 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1377 assert_eq!(
1378 report.clone_groups.len(),
1379 1,
1380 "verbatim instance path must match non-verbatim changed-file entry"
1381 );
1382 }
1383
1384 #[cfg(windows)]
1385 #[test]
1386 fn filter_results_normalises_verbatim_prefix_mismatch() {
1387 let mut results = AnalysisResults::default();
1388 results
1389 .unused_exports
1390 .push(UnusedExportFinding::with_actions(UnusedExport {
1391 path: PathBuf::from(r"\\?\C:\repo\src\a.ts"),
1392 export_name: "foo".into(),
1393 is_type_only: false,
1394 line: 1,
1395 col: 0,
1396 span_start: 0,
1397 is_re_export: false,
1398 }));
1399
1400 let mut changed: FxHashSet<PathBuf> = FxHashSet::default();
1401 changed.insert(PathBuf::from(r"C:\repo\src\a.ts"));
1402
1403 filter_results_by_changed_files(&mut results, &changed);
1404 assert_eq!(
1405 results.unused_exports.len(),
1406 1,
1407 "verbatim finding path must match non-verbatim changed-file entry"
1408 );
1409 }
1410
1411 fn init_repo(repo: &Path) -> PathBuf {
1423 run_git(repo, &["init", "--quiet", "--initial-branch=main"]);
1424 run_git(repo, &["config", "user.email", "test@example.com"]);
1425 run_git(repo, &["config", "user.name", "test"]);
1426 run_git(repo, &["config", "commit.gpgsign", "false"]);
1427 std::fs::write(repo.join("seed.txt"), "seed\n").unwrap();
1428 run_git(repo, &["add", "seed.txt"]);
1429 run_git(repo, &["commit", "--quiet", "-m", "initial"]);
1430 run_git(repo, &["tag", "fallow-baseline"]);
1431 dunce::canonicalize(repo).unwrap()
1432 }
1433
1434 fn run_git(cwd: &Path, args: &[&str]) {
1435 let output = std::process::Command::new("git")
1436 .args(args)
1437 .current_dir(cwd)
1438 .output()
1439 .expect("git available");
1440 assert!(
1441 output.status.success(),
1442 "git {args:?} failed: {}",
1443 String::from_utf8_lossy(&output.stderr)
1444 );
1445 }
1446
1447 #[test]
1450 fn try_get_changed_files_workspace_at_repo_root() {
1451 let tmp = tempfile::tempdir().unwrap();
1452 let repo = init_repo(tmp.path());
1453 std::fs::create_dir_all(repo.join("src")).unwrap();
1454 std::fs::write(repo.join("src/new.ts"), "export const x = 1;\n").unwrap();
1455
1456 let changed = try_get_changed_files(&repo, "fallow-baseline").unwrap();
1457
1458 let expected = repo.join("src/new.ts");
1459 assert!(
1460 changed.contains(&expected),
1461 "changed set should contain {expected:?}; actual: {changed:?}"
1462 );
1463 }
1464
1465 #[test]
1473 fn try_get_changed_files_workspace_in_subdirectory() {
1474 let tmp = tempfile::tempdir().unwrap();
1475 let repo = init_repo(tmp.path());
1476 let frontend = repo.join("frontend");
1477 std::fs::create_dir_all(frontend.join("src")).unwrap();
1478 std::fs::write(frontend.join("src/new.ts"), "export const x = 1;\n").unwrap();
1479
1480 let changed = try_get_changed_files(&frontend, "fallow-baseline").unwrap();
1481
1482 let expected = repo.join("frontend/src/new.ts");
1483 assert!(
1484 changed.contains(&expected),
1485 "changed set should contain canonical {expected:?}; actual: {changed:?}"
1486 );
1487 let bogus = frontend.join("frontend/src/new.ts");
1488 assert!(
1489 !changed.contains(&bogus),
1490 "changed set must not contain double-frontend path {bogus:?}"
1491 );
1492 }
1493
1494 #[test]
1509 fn try_get_changed_files_includes_committed_sibling_changes() {
1510 let tmp = tempfile::tempdir().unwrap();
1511 let repo = init_repo(tmp.path());
1512 let backend = repo.join("backend");
1513 std::fs::create_dir_all(&backend).unwrap();
1514 std::fs::write(backend.join("server.py"), "print('hi')\n").unwrap();
1515 run_git(&repo, &["add", "."]);
1516 run_git(&repo, &["commit", "--quiet", "-m", "add backend"]);
1517
1518 let frontend = repo.join("frontend");
1519 std::fs::create_dir_all(&frontend).unwrap();
1520
1521 let changed = try_get_changed_files(&frontend, "fallow-baseline").unwrap();
1522
1523 let expected = repo.join("backend/server.py");
1524 assert!(
1525 changed.contains(&expected),
1526 "committed sibling backend/server.py should be in the set: {changed:?}"
1527 );
1528 }
1529
1530 #[test]
1534 fn try_get_changed_files_includes_modified_tracked_file() {
1535 let tmp = tempfile::tempdir().unwrap();
1536 let repo = init_repo(tmp.path());
1537 let frontend = repo.join("frontend");
1538 std::fs::create_dir_all(frontend.join("src")).unwrap();
1539 std::fs::write(frontend.join("src/old.ts"), "export const x = 1;\n").unwrap();
1540 run_git(&repo, &["add", "."]);
1541 run_git(&repo, &["commit", "--quiet", "-m", "add old"]);
1542 run_git(&repo, &["tag", "fallow-baseline-v2"]);
1543 std::fs::write(frontend.join("src/old.ts"), "export const x = 2;\n").unwrap();
1544
1545 let changed = try_get_changed_files(&frontend, "fallow-baseline-v2").unwrap();
1546
1547 let expected = repo.join("frontend/src/old.ts");
1548 assert!(
1549 changed.contains(&expected),
1550 "modified tracked file {expected:?} missing from set: {changed:?}"
1551 );
1552 }
1553
1554 #[test]
1560 fn resolve_git_toplevel_returns_canonical_path() {
1561 let tmp = tempfile::tempdir().unwrap();
1562 let repo = init_repo(tmp.path());
1563 let frontend = repo.join("frontend");
1564 std::fs::create_dir_all(&frontend).unwrap();
1565
1566 let toplevel = resolve_git_toplevel(&frontend).unwrap();
1567 assert_eq!(toplevel, repo, "toplevel should equal canonical repo root");
1568 assert_eq!(
1569 toplevel,
1570 dunce::canonicalize(&toplevel).unwrap(),
1571 "resolved toplevel should already be canonical"
1572 );
1573 }
1574
1575 #[test]
1579 fn resolve_git_toplevel_not_a_repository() {
1580 let tmp = tempfile::tempdir().unwrap();
1581 let result = resolve_git_toplevel(tmp.path());
1582 assert!(
1583 matches!(result, Err(ChangedFilesError::NotARepository)),
1584 "expected NotARepository, got {result:?}"
1585 );
1586 }
1587
1588 #[test]
1593 fn resolve_git_common_dir_collapses_worktrees() {
1594 let tmp = tempfile::tempdir().unwrap();
1595 let repo = init_repo(tmp.path());
1596 let linked = tmp.path().join("linked-worktree");
1597 run_git(
1598 &repo,
1599 &[
1600 "worktree",
1601 "add",
1602 "--quiet",
1603 linked.to_str().unwrap(),
1604 "-b",
1605 "feat",
1606 ],
1607 );
1608
1609 let main_common = resolve_git_common_dir(&repo).unwrap();
1610 let linked_common = resolve_git_common_dir(&linked).unwrap();
1611 assert_eq!(
1612 main_common, linked_common,
1613 "worktrees of one repo must share a common dir"
1614 );
1615
1616 let main_top = resolve_git_toplevel(&repo).unwrap();
1618 let linked_top = resolve_git_toplevel(&linked).unwrap();
1619 assert_ne!(
1620 main_top, linked_top,
1621 "the two worktrees should have distinct toplevels"
1622 );
1623 }
1624
1625 #[test]
1628 fn resolve_git_common_dir_not_a_repository() {
1629 let tmp = tempfile::tempdir().unwrap();
1630 let result = resolve_git_common_dir(tmp.path());
1631 assert!(
1632 matches!(result, Err(ChangedFilesError::NotARepository)),
1633 "expected NotARepository, got {result:?}"
1634 );
1635 }
1636
1637 #[test]
1640 fn try_get_changed_files_not_a_repository() {
1641 let tmp = tempfile::tempdir().unwrap();
1642 let result = try_get_changed_files(tmp.path(), "main");
1643 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1644 }
1645
1646 #[test]
1647 fn filter_duplication_drops_groups_with_no_changed_instance() {
1648 let mut report = DuplicationReport {
1649 clone_groups: vec![CloneGroup {
1650 instances: vec![CloneInstance {
1651 file: "/a.ts".into(),
1652 start_line: 1,
1653 end_line: 5,
1654 start_col: 0,
1655 end_col: 10,
1656 fragment: "code".into(),
1657 }],
1658 token_count: 20,
1659 line_count: 5,
1660 }],
1661 clone_families: vec![],
1662 mirrored_directories: vec![],
1663 stats: DuplicationStats {
1664 total_files: 1,
1665 files_with_clones: 1,
1666 total_lines: 100,
1667 duplicated_lines: 5,
1668 total_tokens: 100,
1669 duplicated_tokens: 20,
1670 clone_groups: 1,
1671 clone_instances: 1,
1672 duplication_percentage: 5.0,
1673 clone_groups_below_min_occurrences: 0,
1674 },
1675 };
1676
1677 let changed: FxHashSet<PathBuf> = FxHashSet::default();
1678 filter_duplication_by_changed_files(&mut report, &changed, Path::new(""));
1679 assert!(report.clone_groups.is_empty());
1680 assert_eq!(report.stats.clone_groups, 0);
1681 assert_eq!(report.stats.clone_instances, 0);
1682 assert!((report.stats.duplication_percentage - 0.0).abs() < f64::EPSILON);
1683 }
1684}