1use std::path::{Path, PathBuf};
4use std::process::{Command, Output};
5use std::sync::OnceLock;
6
7use fallow_types::{
8 output_dead_code::{
9 CircularDependencyFinding, DuplicateExportFinding, DuplicatePropShapeFinding,
10 PropDrillingChainFinding, ReExportCycleFinding, UnlistedDependencyFinding,
11 },
12 results::{AnalysisResults, SecurityFinding},
13};
14use rustc_hash::FxHashSet;
15
16use crate::duplicates::{self, DuplicationReport};
17
18pub use crate::git_env::{AMBIENT_GIT_ENV_VARS, clear_ambient_git_env};
19
20pub type ChangedFilesSpawnHook = fn(&mut std::process::Command) -> std::io::Result<Output>;
23
24static SPAWN_HOOK: OnceLock<ChangedFilesSpawnHook> = OnceLock::new();
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ChangedFilesError {
29 InvalidRef(String),
31 GitMissing(String),
33 NotARepository,
35 GitFailed(String),
37}
38
39impl ChangedFilesError {
40 #[must_use]
42 pub fn describe(&self) -> String {
43 match self {
44 Self::InvalidRef(err) => format!("invalid git ref: {err}"),
45 Self::GitMissing(err) => format!("failed to run git: {err}"),
46 Self::NotARepository => "not a git repository".to_owned(),
47 Self::GitFailed(stderr) => augment_git_failed(stderr),
48 }
49 }
50}
51
52fn augment_git_failed(stderr: &str) -> String {
53 let lower = stderr.to_ascii_lowercase();
54 if lower.contains("not a valid object name")
55 || lower.contains("unknown revision")
56 || lower.contains("ambiguous argument")
57 {
58 format!(
59 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
60 )
61 } else {
62 stderr.to_owned()
63 }
64}
65
66pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
68 let _ = SPAWN_HOOK.set(hook);
69}
70
71pub fn validate_git_ref(s: &str) -> Result<&str, String> {
73 if s.is_empty() {
74 return Err("git ref cannot be empty".to_string());
75 }
76 if s.starts_with('-') {
77 return Err("git ref cannot start with '-'".to_string());
78 }
79 let mut in_braces = false;
80 for c in s.chars() {
81 match c {
82 '{' => in_braces = true,
83 '}' => in_braces = false,
84 ':' | ' ' if in_braces => {}
85 c if c.is_ascii_alphanumeric()
86 || matches!(c, '.' | '_' | '-' | '/' | '~' | '^' | '@' | '{' | '}') => {}
87 _ => return Err(format!("git ref contains disallowed character: '{c}'")),
88 }
89 }
90 if in_braces {
91 return Err("git ref has unclosed '{'".to_string());
92 }
93 Ok(s)
94}
95
96pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
98 let output = spawn_output(&mut git_command(cwd, &["rev-parse", "--show-toplevel"]))
99 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
100
101 if !output.status.success() {
102 let stderr = String::from_utf8_lossy(&output.stderr);
103 return Err(if stderr.contains("not a git repository") {
104 ChangedFilesError::NotARepository
105 } else {
106 ChangedFilesError::GitFailed(stderr.trim().to_owned())
107 });
108 }
109
110 let raw = String::from_utf8_lossy(&output.stdout);
111 let trimmed = raw.trim();
112 if trimmed.is_empty() {
113 return Err(ChangedFilesError::GitFailed(
114 "git rev-parse --show-toplevel returned empty output".to_owned(),
115 ));
116 }
117
118 let path = PathBuf::from(trimmed);
119 Ok(dunce::canonicalize(&path).unwrap_or(path))
120}
121
122pub fn resolve_git_common_dir(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
124 let output = spawn_output(&mut git_command(
125 cwd,
126 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
127 ))
128 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
129
130 if !output.status.success() {
131 let stderr = String::from_utf8_lossy(&output.stderr);
132 return Err(if stderr.contains("not a git repository") {
133 ChangedFilesError::NotARepository
134 } else {
135 ChangedFilesError::GitFailed(stderr.trim().to_owned())
136 });
137 }
138
139 let raw = String::from_utf8_lossy(&output.stdout);
140 let trimmed = raw.trim();
141 if trimmed.is_empty() {
142 return Err(ChangedFilesError::GitFailed(
143 "git rev-parse --git-common-dir returned empty output".to_owned(),
144 ));
145 }
146
147 let path = PathBuf::from(trimmed);
148 Ok(dunce::canonicalize(&path).unwrap_or(path))
149}
150
151pub fn try_get_changed_files(
153 root: &Path,
154 git_ref: &str,
155) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
156 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
157 let toplevel = resolve_git_toplevel(root)?;
158 try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
159}
160
161pub fn changed_files(root: &Path, git_ref: &str) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
167 try_get_changed_files(root, git_ref)
168}
169
170pub fn try_get_changed_files_with_toplevel(
172 cwd: &Path,
173 toplevel: &Path,
174 git_ref: &str,
175) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
176 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
177
178 let mut files = collect_git_paths(
179 cwd,
180 toplevel,
181 &[
182 "diff",
183 "--name-only",
184 "--end-of-options",
185 &format!("{git_ref}...HEAD"),
186 ],
187 )?;
188 files.extend(collect_git_paths(
189 cwd,
190 toplevel,
191 &["diff", "--name-only", "HEAD"],
192 )?);
193 files.extend(collect_git_paths(
194 cwd,
195 toplevel,
196 &["ls-files", "--full-name", "--others", "--exclude-standard"],
197 )?);
198 Ok(files)
199}
200
201pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
203 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
204 let output = spawn_output(&mut git_command(
205 root,
206 &[
207 "diff",
208 "--relative",
209 "--unified=0",
210 "--end-of-options",
211 &format!("{git_ref}...HEAD"),
212 ],
213 ))
214 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
215
216 if !output.status.success() {
217 let stderr = String::from_utf8_lossy(&output.stderr);
218 return Err(if stderr.contains("not a git repository") {
219 ChangedFilesError::NotARepository
220 } else {
221 ChangedFilesError::GitFailed(stderr.trim().to_owned())
222 });
223 }
224
225 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
226}
227
228#[must_use]
230#[expect(
231 clippy::print_stderr,
232 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; typed callers use try_get_changed_files instead"
233)]
234pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
235 match try_get_changed_files(root, git_ref) {
236 Ok(files) => Some(files),
237 Err(ChangedFilesError::InvalidRef(e)) => {
238 eprintln!("Warning: --changed-since ignored: invalid git ref: {e}");
239 None
240 }
241 Err(ChangedFilesError::GitMissing(e)) => {
242 eprintln!("Warning: --changed-since ignored: failed to run git: {e}");
243 None
244 }
245 Err(ChangedFilesError::NotARepository) => {
246 eprintln!("Warning: --changed-since ignored: not a git repository");
247 None
248 }
249 Err(ChangedFilesError::GitFailed(stderr)) => {
250 eprintln!("Warning: --changed-since failed for ref '{git_ref}': {stderr}");
251 None
252 }
253 }
254}
255
256fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
257 if let Some(hook) = SPAWN_HOOK.get() {
258 hook(command)
259 } else {
260 command.output()
261 }
262}
263
264fn collect_git_paths(
265 cwd: &Path,
266 toplevel: &Path,
267 args: &[&str],
268) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
269 let output = spawn_output(&mut git_command(cwd, args))
270 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
271
272 if !output.status.success() {
273 let stderr = String::from_utf8_lossy(&output.stderr);
274 return Err(if stderr.contains("not a git repository") {
275 ChangedFilesError::NotARepository
276 } else {
277 ChangedFilesError::GitFailed(stderr.trim().to_owned())
278 });
279 }
280
281 #[cfg(windows)]
282 let normalise_segment = |line: &str| line.replace('/', "\\");
283 #[cfg(not(windows))]
284 let normalise_segment = |line: &str| line.to_owned();
285
286 let files = String::from_utf8_lossy(&output.stdout)
287 .lines()
288 .filter(|line| !line.is_empty())
289 .map(|line| toplevel.join(normalise_segment(line)))
290 .collect();
291
292 Ok(files)
293}
294
295#[expect(
296 clippy::disallowed_methods,
297 reason = "canonical engine-owned git spawn wrapper for changed-file orchestration"
298)]
299fn git_command(cwd: &Path, args: &[&str]) -> Command {
300 let mut command = Command::new("git");
301 clear_ambient_git_env(&mut command);
302 command.args(args).current_dir(cwd);
303 command
304}
305
306#[expect(
311 clippy::implicit_hasher,
312 reason = "fallow standardizes on FxHashSet across the workspace"
313)]
314pub fn filter_results_by_changed_files(
315 results: &mut AnalysisResults,
316 changed_files: &FxHashSet<PathBuf>,
317) {
318 let cf = normalize_changed_files_set(changed_files);
319 classify_changed_file_filter_fields(results);
320 retain_basic_issue_findings_by_changed_path(results, &cf);
321 retain_graph_findings_by_changed_files(results, &cf);
322 retain_boundary_policy_and_suppression_findings(results, &cf);
323 retain_security_and_workspace_findings(results, &cf);
324 retain_framework_findings_by_changed_files(results, &cf);
325}
326
327fn classify_changed_file_filter_fields(results: &AnalysisResults) {
328 let AnalysisResults {
329 unused_files: _unused_files,
330 unused_exports: _unused_exports,
331 unused_types: _unused_types,
332 private_type_leaks: _private_type_leaks,
333 unused_dependencies: _unused_dependencies,
334 unused_dev_dependencies: _unused_dev_dependencies,
335 unused_optional_dependencies: _unused_optional_dependencies,
336 unused_enum_members: _unused_enum_members,
337 unused_class_members: _unused_class_members,
338 unused_store_members: _unused_store_members,
339 unresolved_imports: _unresolved_imports,
340 unlisted_dependencies: _unlisted_dependencies,
341 duplicate_exports: _duplicate_exports,
342 type_only_dependencies: _type_only_dependencies,
343 test_only_dependencies: _test_only_dependencies,
344 dev_dependencies_in_production: _dev_dependencies_in_production,
345 circular_dependencies: _circular_dependencies,
346 re_export_cycles: _re_export_cycles,
347 boundary_violations: _boundary_violations,
348 boundary_coverage_violations: _boundary_coverage_violations,
349 boundary_call_violations: _boundary_call_violations,
350 policy_violations: _policy_violations,
351 stale_suppressions: _stale_suppressions,
352 unused_catalog_entries: _unused_catalog_entries,
353 empty_catalog_groups: _empty_catalog_groups,
354 unresolved_catalog_references: _unresolved_catalog_references,
355 unused_dependency_overrides: _unused_dependency_overrides,
356 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
357 invalid_client_exports: _invalid_client_exports,
358 mixed_client_server_barrels: _mixed_client_server_barrels,
359 misplaced_directives: _misplaced_directives,
360 unprovided_injects: _unprovided_injects,
361 unrendered_components: _unrendered_components,
362 route_collisions: _route_collisions,
363 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
364 unused_component_props: _unused_component_props,
365 unused_component_emits: _unused_component_emits,
366 unused_component_inputs: _unused_component_inputs,
367 unused_component_outputs: _unused_component_outputs,
368 unused_svelte_events: _unused_svelte_events,
369 unused_server_actions: _unused_server_actions,
370 unused_load_data_keys: _unused_load_data_keys,
371 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
372 prop_drilling_chains: _prop_drilling_chains,
373 thin_wrappers: _thin_wrappers,
374 duplicate_prop_shapes: _duplicate_prop_shapes,
375 suppression_count: _suppression_count,
376 unused_component_props_exempted: _unused_component_props_exempted,
377 active_suppressions: _active_suppressions,
378 feature_flags: _feature_flags,
379 security_findings: _security_findings,
380 security_unresolved_edge_files: _security_unresolved_edge_files,
381 security_unresolved_callee_sites: _security_unresolved_callee_sites,
382 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
383 export_usages: _export_usages,
384 entry_point_summary: _entry_point_summary,
385 render_fan_in: _render_fan_in,
386 react_component_intel: _react_component_intel,
387 } = results;
388}
389
390fn retain_basic_issue_findings_by_changed_path(
391 results: &mut AnalysisResults,
392 changed_files: &FxHashSet<PathBuf>,
393) {
394 retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
395 retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
396 &e.export.path
397 });
398 retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
399 retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
400 &e.leak.path
401 });
402 retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
403 &m.member.path
404 });
405 retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
406 &m.member.path
407 });
408 retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
409 &m.member.path
410 });
411 retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
412 &i.import.path
413 });
414}
415
416fn retain_graph_findings_by_changed_files(
417 results: &mut AnalysisResults,
418 changed_files: &FxHashSet<PathBuf>,
419) {
420 retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
421 retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
422 retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
423 retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
424}
425
426fn retain_boundary_policy_and_suppression_findings(
427 results: &mut AnalysisResults,
428 changed_files: &FxHashSet<PathBuf>,
429) {
430 retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
431 &v.violation.from_path
432 });
433 retain_by_changed_path(
434 &mut results.boundary_coverage_violations,
435 changed_files,
436 |v| &v.violation.path,
437 );
438 retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
439 &v.violation.path
440 });
441 retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
442 &v.violation.path
443 });
444 retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
445}
446
447fn retain_security_and_workspace_findings(
448 results: &mut AnalysisResults,
449 changed_files: &FxHashSet<PathBuf>,
450) {
451 retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
452 retain_by_changed_path(
453 &mut results.security_unresolved_callee_diagnostics,
454 changed_files,
455 |d| &d.path,
456 );
457 retain_by_changed_path(
458 &mut results.unresolved_catalog_references,
459 changed_files,
460 |r| &r.reference.path,
461 );
462 results
463 .empty_catalog_groups
464 .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
465 retain_by_changed_path(
466 &mut results.unused_dependency_overrides,
467 changed_files,
468 |o| &o.entry.path,
469 );
470 retain_by_changed_path(
471 &mut results.misconfigured_dependency_overrides,
472 changed_files,
473 |o| &o.entry.path,
474 );
475}
476
477fn retain_framework_findings_by_changed_files(
478 results: &mut AnalysisResults,
479 changed_files: &FxHashSet<PathBuf>,
480) {
481 retain_client_boundary_findings_by_changed_files(results, changed_files);
482 retain_component_contract_findings_by_changed_files(results, changed_files);
483 retain_react_health_findings_by_changed_files(results, changed_files);
484 retain_nextjs_findings_by_changed_files(results, changed_files);
485}
486
487fn retain_client_boundary_findings_by_changed_files(
488 results: &mut AnalysisResults,
489 changed_files: &FxHashSet<PathBuf>,
490) {
491 let AnalysisResults {
492 invalid_client_exports,
493 mixed_client_server_barrels,
494 misplaced_directives,
495 ..
496 } = results;
497
498 retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
499 retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
500 &b.barrel.path
501 });
502 retain_by_changed_path(misplaced_directives, changed_files, |d| {
503 &d.directive_site.path
504 });
505}
506
507fn retain_component_contract_findings_by_changed_files(
508 results: &mut AnalysisResults,
509 changed_files: &FxHashSet<PathBuf>,
510) {
511 let AnalysisResults {
512 unprovided_injects,
513 unrendered_components,
514 unused_component_props,
515 unused_component_emits,
516 unused_component_inputs,
517 unused_component_outputs,
518 unused_svelte_events,
519 unused_server_actions,
520 unused_load_data_keys,
521 ..
522 } = results;
523
524 retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
525 retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
526 retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
527 retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
528 retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
529 retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
530 retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
531 retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
532 retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
533}
534
535fn retain_react_health_findings_by_changed_files(
536 results: &mut AnalysisResults,
537 changed_files: &FxHashSet<PathBuf>,
538) {
539 let AnalysisResults {
540 prop_drilling_chains,
541 thin_wrappers,
542 duplicate_prop_shapes,
543 ..
544 } = results;
545
546 retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
547 retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
548 retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
549}
550
551fn retain_nextjs_findings_by_changed_files(
552 results: &mut AnalysisResults,
553 changed_files: &FxHashSet<PathBuf>,
554) {
555 let AnalysisResults {
556 route_collisions,
557 dynamic_segment_name_conflicts,
558 ..
559 } = results;
560
561 retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
562 retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
563 &c.conflict.path
564 });
565}
566
567fn retain_unlisted_dependencies_by_import_site(
568 dependencies: &mut Vec<UnlistedDependencyFinding>,
569 changed_files: &FxHashSet<PathBuf>,
570) {
571 dependencies.retain(|dependency| {
572 dependency
573 .dep
574 .imported_from
575 .iter()
576 .any(|site| contains_normalized(changed_files, &site.path))
577 });
578}
579
580fn retain_duplicate_exports_by_changed_locations(
581 duplicate_exports: &mut Vec<DuplicateExportFinding>,
582 changed_files: &FxHashSet<PathBuf>,
583) {
584 for duplicate in &mut *duplicate_exports {
585 duplicate
586 .export
587 .locations
588 .retain(|location| contains_normalized(changed_files, &location.path));
589 }
590 duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
591}
592
593fn retain_circular_dependencies_by_changed_file(
594 cycles: &mut Vec<CircularDependencyFinding>,
595 changed_files: &FxHashSet<PathBuf>,
596) {
597 cycles.retain(|cycle| {
598 cycle
599 .cycle
600 .files
601 .iter()
602 .any(|file| contains_normalized(changed_files, file))
603 });
604}
605
606fn retain_re_export_cycles_by_changed_file(
607 cycles: &mut Vec<ReExportCycleFinding>,
608 changed_files: &FxHashSet<PathBuf>,
609) {
610 cycles.retain(|cycle| {
611 cycle
612 .cycle
613 .files
614 .iter()
615 .any(|file| contains_normalized(changed_files, file))
616 });
617}
618
619fn retain_security_findings_by_changed_path(
620 findings: &mut Vec<SecurityFinding>,
621 changed_files: &FxHashSet<PathBuf>,
622) {
623 findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
624}
625
626fn retain_prop_drilling_chains_by_anchor(
627 chains: &mut Vec<PropDrillingChainFinding>,
628 changed_files: &FxHashSet<PathBuf>,
629) {
630 chains.retain(|chain| {
631 chain
632 .chain
633 .hops
634 .first()
635 .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
636 });
637}
638
639fn retain_duplicate_prop_shapes_by_anchor(
640 shapes: &mut Vec<DuplicatePropShapeFinding>,
641 changed_files: &FxHashSet<PathBuf>,
642) {
643 retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
644}
645
646fn retain_by_changed_path<T>(
647 items: &mut Vec<T>,
648 changed_files: &FxHashSet<PathBuf>,
649 path: impl Fn(&T) -> &Path,
650) {
651 items.retain(|item| contains_normalized(changed_files, path(item)));
652}
653
654fn security_finding_touches_changed_path(
655 finding: &SecurityFinding,
656 changed_files: &FxHashSet<PathBuf>,
657) -> bool {
658 contains_normalized(changed_files, &finding.path)
659 || finding
660 .trace
661 .iter()
662 .any(|hop| contains_normalized(changed_files, &hop.path))
663 || finding.reachability.as_ref().is_some_and(|reachability| {
664 reachability
665 .untrusted_source_trace
666 .iter()
667 .any(|hop| contains_normalized(changed_files, &hop.path))
668 })
669}
670
671fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
672 changed_files
673 .iter()
674 .map(|p| dunce::simplified(p).to_path_buf())
675 .collect()
676}
677
678fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
679 normalized.contains(dunce::simplified(path))
680}
681
682fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
683 contains_normalized(normalized, path)
684 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
685}
686
687#[expect(
689 clippy::implicit_hasher,
690 reason = "fallow standardizes on FxHashSet across the workspace"
691)]
692pub fn filter_duplication_by_changed_files(
693 report: &mut DuplicationReport,
694 changed_files: &FxHashSet<PathBuf>,
695 root: &Path,
696) {
697 let cf = normalize_changed_files_set(changed_files);
698 report.clone_groups.retain(|group| {
699 group
700 .instances
701 .iter()
702 .any(|instance| contains_normalized(&cf, &instance.file))
703 });
704 duplicates::refresh_clone_families(report, root);
705 report.stats = duplicates::recompute_stats(report);
706}
707
708#[cfg(test)]
709mod tests {
710 use super::*;
711 use fallow_types::{
712 duplicates::{CloneGroup, CloneInstance, DuplicationStats},
713 output_dead_code::{
714 EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
715 UnusedFileFinding,
716 },
717 results::{
718 DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
719 },
720 };
721
722 #[test]
723 fn validate_git_ref_rejects_option_like_ref() {
724 assert!(validate_git_ref("--upload-pack=evil").is_err());
725 assert!(validate_git_ref("-flag").is_err());
726 }
727
728 #[test]
729 fn validate_git_ref_allows_reflog_relative_date() {
730 assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
731 }
732
733 #[test]
734 fn git_command_clears_parent_git_environment() {
735 let command = git_command(Path::new("."), &["status"]);
736 let envs: Vec<_> = command.get_envs().collect();
737
738 for var in AMBIENT_GIT_ENV_VARS {
739 assert!(
740 envs.iter()
741 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
742 "{var} should be cleared from the command env",
743 );
744 }
745 }
746
747 #[test]
748 fn try_get_changed_files_not_a_repository() {
749 let temp = tempfile::tempdir().expect("tempdir");
750 let result = try_get_changed_files(temp.path(), "main");
751 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
752 }
753
754 #[test]
755 fn changed_files_error_describe_matches_core_contract() {
756 assert_eq!(
757 ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
758 "invalid git ref: bad ref"
759 );
760 assert_eq!(
761 ChangedFilesError::GitMissing("not found".to_string()).describe(),
762 "failed to run git: not found"
763 );
764 assert_eq!(
765 ChangedFilesError::NotARepository.describe(),
766 "not a git repository"
767 );
768 assert!(
769 ChangedFilesError::GitFailed("unknown revision main".to_string())
770 .describe()
771 .contains("fetch-depth: 0")
772 );
773 }
774
775 #[test]
776 fn filter_results_keeps_only_changed_file_findings() {
777 let mut results = AnalysisResults::default();
778 results
779 .unused_files
780 .push(UnusedFileFinding::with_actions(UnusedFile {
781 path: PathBuf::from("/repo/a.ts"),
782 }));
783 results
784 .unused_files
785 .push(UnusedFileFinding::with_actions(UnusedFile {
786 path: PathBuf::from("/repo/b.ts"),
787 }));
788 results
789 .unused_exports
790 .push(UnusedExportFinding::with_actions(UnusedExport {
791 path: PathBuf::from("/repo/a.ts"),
792 export_name: "foo".to_owned(),
793 is_type_only: false,
794 line: 1,
795 col: 0,
796 span_start: 0,
797 is_re_export: false,
798 }));
799
800 let mut changed = FxHashSet::default();
801 changed.insert(PathBuf::from("/repo/a.ts"));
802
803 filter_results_by_changed_files(&mut results, &changed);
804
805 assert_eq!(results.unused_files.len(), 1);
806 assert_eq!(
807 results.unused_files[0].file.path,
808 PathBuf::from("/repo/a.ts")
809 );
810 assert_eq!(results.unused_exports.len(), 1);
811 }
812
813 #[test]
814 fn filter_results_preserves_graph_global_dependency_findings() {
815 let mut results = AnalysisResults::default();
816 results
817 .unused_dependencies
818 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
819 package_name: "lodash".to_owned(),
820 location: DependencyLocation::Dependencies,
821 path: PathBuf::from("/repo/package.json"),
822 line: 3,
823 used_in_workspaces: Vec::new(),
824 }));
825
826 let changed = FxHashSet::default();
827 filter_results_by_changed_files(&mut results, &changed);
828
829 assert_eq!(results.unused_dependencies.len(), 1);
830 }
831
832 #[test]
833 fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
834 let mut results = AnalysisResults::default();
835 results
836 .empty_catalog_groups
837 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
838 catalog_name: "legacy".to_owned(),
839 path: PathBuf::from("pnpm-workspace.yaml"),
840 line: 4,
841 }));
842
843 let mut changed = FxHashSet::default();
844 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
845
846 filter_results_by_changed_files(&mut results, &changed);
847
848 assert_eq!(results.empty_catalog_groups.len(), 1);
849 }
850
851 #[test]
852 fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
853 let mut report = DuplicationReport {
854 clone_groups: vec![
855 CloneGroup {
856 instances: vec![
857 CloneInstance {
858 file: PathBuf::from("/repo/a.ts"),
859 start_line: 1,
860 end_line: 5,
861 start_col: 0,
862 end_col: 10,
863 fragment: "code".to_owned(),
864 },
865 CloneInstance {
866 file: PathBuf::from("/repo/b.ts"),
867 start_line: 1,
868 end_line: 5,
869 start_col: 0,
870 end_col: 10,
871 fragment: "code".to_owned(),
872 },
873 ],
874 token_count: 20,
875 line_count: 5,
876 },
877 CloneGroup {
878 instances: vec![
879 CloneInstance {
880 file: PathBuf::from("/repo/c.ts"),
881 start_line: 1,
882 end_line: 5,
883 start_col: 0,
884 end_col: 10,
885 fragment: "other".to_owned(),
886 },
887 CloneInstance {
888 file: PathBuf::from("/repo/d.ts"),
889 start_line: 1,
890 end_line: 5,
891 start_col: 0,
892 end_col: 10,
893 fragment: "other".to_owned(),
894 },
895 ],
896 token_count: 20,
897 line_count: 5,
898 },
899 ],
900 clone_families: Vec::new(),
901 mirrored_directories: Vec::new(),
902 stats: DuplicationStats {
903 total_files: 4,
904 files_with_clones: 4,
905 total_lines: 100,
906 duplicated_lines: 20,
907 total_tokens: 200,
908 duplicated_tokens: 80,
909 clone_groups: 2,
910 clone_instances: 4,
911 duplication_percentage: 20.0,
912 clone_groups_below_min_occurrences: 0,
913 },
914 };
915
916 let mut changed = FxHashSet::default();
917 changed.insert(PathBuf::from("/repo/a.ts"));
918
919 filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
920
921 assert_eq!(report.clone_groups.len(), 1);
922 assert_eq!(report.stats.clone_groups, 1);
923 assert_eq!(report.stats.clone_instances, 2);
924 }
925}