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 circular_dependencies: _circular_dependencies,
345 re_export_cycles: _re_export_cycles,
346 boundary_violations: _boundary_violations,
347 boundary_coverage_violations: _boundary_coverage_violations,
348 boundary_call_violations: _boundary_call_violations,
349 policy_violations: _policy_violations,
350 stale_suppressions: _stale_suppressions,
351 unused_catalog_entries: _unused_catalog_entries,
352 empty_catalog_groups: _empty_catalog_groups,
353 unresolved_catalog_references: _unresolved_catalog_references,
354 unused_dependency_overrides: _unused_dependency_overrides,
355 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
356 invalid_client_exports: _invalid_client_exports,
357 mixed_client_server_barrels: _mixed_client_server_barrels,
358 misplaced_directives: _misplaced_directives,
359 unprovided_injects: _unprovided_injects,
360 unrendered_components: _unrendered_components,
361 route_collisions: _route_collisions,
362 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
363 unused_component_props: _unused_component_props,
364 unused_component_emits: _unused_component_emits,
365 unused_component_inputs: _unused_component_inputs,
366 unused_component_outputs: _unused_component_outputs,
367 unused_svelte_events: _unused_svelte_events,
368 unused_server_actions: _unused_server_actions,
369 unused_load_data_keys: _unused_load_data_keys,
370 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
371 prop_drilling_chains: _prop_drilling_chains,
372 thin_wrappers: _thin_wrappers,
373 duplicate_prop_shapes: _duplicate_prop_shapes,
374 suppression_count: _suppression_count,
375 unused_component_props_exempted: _unused_component_props_exempted,
376 active_suppressions: _active_suppressions,
377 feature_flags: _feature_flags,
378 security_findings: _security_findings,
379 security_unresolved_edge_files: _security_unresolved_edge_files,
380 security_unresolved_callee_sites: _security_unresolved_callee_sites,
381 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
382 export_usages: _export_usages,
383 entry_point_summary: _entry_point_summary,
384 render_fan_in: _render_fan_in,
385 react_component_intel: _react_component_intel,
386 } = results;
387}
388
389fn retain_basic_issue_findings_by_changed_path(
390 results: &mut AnalysisResults,
391 changed_files: &FxHashSet<PathBuf>,
392) {
393 retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
394 retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
395 &e.export.path
396 });
397 retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
398 retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
399 &e.leak.path
400 });
401 retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
402 &m.member.path
403 });
404 retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
405 &m.member.path
406 });
407 retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
408 &m.member.path
409 });
410 retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
411 &i.import.path
412 });
413}
414
415fn retain_graph_findings_by_changed_files(
416 results: &mut AnalysisResults,
417 changed_files: &FxHashSet<PathBuf>,
418) {
419 retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
420 retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
421 retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
422 retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
423}
424
425fn retain_boundary_policy_and_suppression_findings(
426 results: &mut AnalysisResults,
427 changed_files: &FxHashSet<PathBuf>,
428) {
429 retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
430 &v.violation.from_path
431 });
432 retain_by_changed_path(
433 &mut results.boundary_coverage_violations,
434 changed_files,
435 |v| &v.violation.path,
436 );
437 retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
438 &v.violation.path
439 });
440 retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
441 &v.violation.path
442 });
443 retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
444}
445
446fn retain_security_and_workspace_findings(
447 results: &mut AnalysisResults,
448 changed_files: &FxHashSet<PathBuf>,
449) {
450 retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
451 retain_by_changed_path(
452 &mut results.security_unresolved_callee_diagnostics,
453 changed_files,
454 |d| &d.path,
455 );
456 retain_by_changed_path(
457 &mut results.unresolved_catalog_references,
458 changed_files,
459 |r| &r.reference.path,
460 );
461 results
462 .empty_catalog_groups
463 .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
464 retain_by_changed_path(
465 &mut results.unused_dependency_overrides,
466 changed_files,
467 |o| &o.entry.path,
468 );
469 retain_by_changed_path(
470 &mut results.misconfigured_dependency_overrides,
471 changed_files,
472 |o| &o.entry.path,
473 );
474}
475
476fn retain_framework_findings_by_changed_files(
477 results: &mut AnalysisResults,
478 changed_files: &FxHashSet<PathBuf>,
479) {
480 retain_client_boundary_findings_by_changed_files(results, changed_files);
481 retain_component_contract_findings_by_changed_files(results, changed_files);
482 retain_react_health_findings_by_changed_files(results, changed_files);
483 retain_nextjs_findings_by_changed_files(results, changed_files);
484}
485
486fn retain_client_boundary_findings_by_changed_files(
487 results: &mut AnalysisResults,
488 changed_files: &FxHashSet<PathBuf>,
489) {
490 let AnalysisResults {
491 invalid_client_exports,
492 mixed_client_server_barrels,
493 misplaced_directives,
494 ..
495 } = results;
496
497 retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
498 retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
499 &b.barrel.path
500 });
501 retain_by_changed_path(misplaced_directives, changed_files, |d| {
502 &d.directive_site.path
503 });
504}
505
506fn retain_component_contract_findings_by_changed_files(
507 results: &mut AnalysisResults,
508 changed_files: &FxHashSet<PathBuf>,
509) {
510 let AnalysisResults {
511 unprovided_injects,
512 unrendered_components,
513 unused_component_props,
514 unused_component_emits,
515 unused_component_inputs,
516 unused_component_outputs,
517 unused_svelte_events,
518 unused_server_actions,
519 unused_load_data_keys,
520 ..
521 } = results;
522
523 retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
524 retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
525 retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
526 retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
527 retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
528 retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
529 retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
530 retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
531 retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
532}
533
534fn retain_react_health_findings_by_changed_files(
535 results: &mut AnalysisResults,
536 changed_files: &FxHashSet<PathBuf>,
537) {
538 let AnalysisResults {
539 prop_drilling_chains,
540 thin_wrappers,
541 duplicate_prop_shapes,
542 ..
543 } = results;
544
545 retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
546 retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
547 retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
548}
549
550fn retain_nextjs_findings_by_changed_files(
551 results: &mut AnalysisResults,
552 changed_files: &FxHashSet<PathBuf>,
553) {
554 let AnalysisResults {
555 route_collisions,
556 dynamic_segment_name_conflicts,
557 ..
558 } = results;
559
560 retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
561 retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
562 &c.conflict.path
563 });
564}
565
566fn retain_unlisted_dependencies_by_import_site(
567 dependencies: &mut Vec<UnlistedDependencyFinding>,
568 changed_files: &FxHashSet<PathBuf>,
569) {
570 dependencies.retain(|dependency| {
571 dependency
572 .dep
573 .imported_from
574 .iter()
575 .any(|site| contains_normalized(changed_files, &site.path))
576 });
577}
578
579fn retain_duplicate_exports_by_changed_locations(
580 duplicate_exports: &mut Vec<DuplicateExportFinding>,
581 changed_files: &FxHashSet<PathBuf>,
582) {
583 for duplicate in &mut *duplicate_exports {
584 duplicate
585 .export
586 .locations
587 .retain(|location| contains_normalized(changed_files, &location.path));
588 }
589 duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
590}
591
592fn retain_circular_dependencies_by_changed_file(
593 cycles: &mut Vec<CircularDependencyFinding>,
594 changed_files: &FxHashSet<PathBuf>,
595) {
596 cycles.retain(|cycle| {
597 cycle
598 .cycle
599 .files
600 .iter()
601 .any(|file| contains_normalized(changed_files, file))
602 });
603}
604
605fn retain_re_export_cycles_by_changed_file(
606 cycles: &mut Vec<ReExportCycleFinding>,
607 changed_files: &FxHashSet<PathBuf>,
608) {
609 cycles.retain(|cycle| {
610 cycle
611 .cycle
612 .files
613 .iter()
614 .any(|file| contains_normalized(changed_files, file))
615 });
616}
617
618fn retain_security_findings_by_changed_path(
619 findings: &mut Vec<SecurityFinding>,
620 changed_files: &FxHashSet<PathBuf>,
621) {
622 findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
623}
624
625fn retain_prop_drilling_chains_by_anchor(
626 chains: &mut Vec<PropDrillingChainFinding>,
627 changed_files: &FxHashSet<PathBuf>,
628) {
629 chains.retain(|chain| {
630 chain
631 .chain
632 .hops
633 .first()
634 .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
635 });
636}
637
638fn retain_duplicate_prop_shapes_by_anchor(
639 shapes: &mut Vec<DuplicatePropShapeFinding>,
640 changed_files: &FxHashSet<PathBuf>,
641) {
642 retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
643}
644
645fn retain_by_changed_path<T>(
646 items: &mut Vec<T>,
647 changed_files: &FxHashSet<PathBuf>,
648 path: impl Fn(&T) -> &Path,
649) {
650 items.retain(|item| contains_normalized(changed_files, path(item)));
651}
652
653fn security_finding_touches_changed_path(
654 finding: &SecurityFinding,
655 changed_files: &FxHashSet<PathBuf>,
656) -> bool {
657 contains_normalized(changed_files, &finding.path)
658 || finding
659 .trace
660 .iter()
661 .any(|hop| contains_normalized(changed_files, &hop.path))
662 || finding.reachability.as_ref().is_some_and(|reachability| {
663 reachability
664 .untrusted_source_trace
665 .iter()
666 .any(|hop| contains_normalized(changed_files, &hop.path))
667 })
668}
669
670fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
671 changed_files
672 .iter()
673 .map(|p| dunce::simplified(p).to_path_buf())
674 .collect()
675}
676
677fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
678 normalized.contains(dunce::simplified(path))
679}
680
681fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
682 contains_normalized(normalized, path)
683 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
684}
685
686#[expect(
688 clippy::implicit_hasher,
689 reason = "fallow standardizes on FxHashSet across the workspace"
690)]
691pub fn filter_duplication_by_changed_files(
692 report: &mut DuplicationReport,
693 changed_files: &FxHashSet<PathBuf>,
694 root: &Path,
695) {
696 let cf = normalize_changed_files_set(changed_files);
697 report.clone_groups.retain(|group| {
698 group
699 .instances
700 .iter()
701 .any(|instance| contains_normalized(&cf, &instance.file))
702 });
703 duplicates::refresh_clone_families(report, root);
704 report.stats = duplicates::recompute_stats(report);
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use fallow_types::{
711 duplicates::{CloneGroup, CloneInstance, DuplicationStats},
712 output_dead_code::{
713 EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
714 UnusedFileFinding,
715 },
716 results::{
717 DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
718 },
719 };
720
721 #[test]
722 fn validate_git_ref_rejects_option_like_ref() {
723 assert!(validate_git_ref("--upload-pack=evil").is_err());
724 assert!(validate_git_ref("-flag").is_err());
725 }
726
727 #[test]
728 fn validate_git_ref_allows_reflog_relative_date() {
729 assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
730 }
731
732 #[test]
733 fn git_command_clears_parent_git_environment() {
734 let command = git_command(Path::new("."), &["status"]);
735 let envs: Vec<_> = command.get_envs().collect();
736
737 for var in AMBIENT_GIT_ENV_VARS {
738 assert!(
739 envs.iter()
740 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
741 "{var} should be cleared from the command env",
742 );
743 }
744 }
745
746 #[test]
747 fn try_get_changed_files_not_a_repository() {
748 let temp = tempfile::tempdir().expect("tempdir");
749 let result = try_get_changed_files(temp.path(), "main");
750 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
751 }
752
753 #[test]
754 fn changed_files_error_describe_matches_core_contract() {
755 assert_eq!(
756 ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
757 "invalid git ref: bad ref"
758 );
759 assert_eq!(
760 ChangedFilesError::GitMissing("not found".to_string()).describe(),
761 "failed to run git: not found"
762 );
763 assert_eq!(
764 ChangedFilesError::NotARepository.describe(),
765 "not a git repository"
766 );
767 assert!(
768 ChangedFilesError::GitFailed("unknown revision main".to_string())
769 .describe()
770 .contains("fetch-depth: 0")
771 );
772 }
773
774 #[test]
775 fn filter_results_keeps_only_changed_file_findings() {
776 let mut results = AnalysisResults::default();
777 results
778 .unused_files
779 .push(UnusedFileFinding::with_actions(UnusedFile {
780 path: PathBuf::from("/repo/a.ts"),
781 }));
782 results
783 .unused_files
784 .push(UnusedFileFinding::with_actions(UnusedFile {
785 path: PathBuf::from("/repo/b.ts"),
786 }));
787 results
788 .unused_exports
789 .push(UnusedExportFinding::with_actions(UnusedExport {
790 path: PathBuf::from("/repo/a.ts"),
791 export_name: "foo".to_owned(),
792 is_type_only: false,
793 line: 1,
794 col: 0,
795 span_start: 0,
796 is_re_export: false,
797 }));
798
799 let mut changed = FxHashSet::default();
800 changed.insert(PathBuf::from("/repo/a.ts"));
801
802 filter_results_by_changed_files(&mut results, &changed);
803
804 assert_eq!(results.unused_files.len(), 1);
805 assert_eq!(
806 results.unused_files[0].file.path,
807 PathBuf::from("/repo/a.ts")
808 );
809 assert_eq!(results.unused_exports.len(), 1);
810 }
811
812 #[test]
813 fn filter_results_preserves_graph_global_dependency_findings() {
814 let mut results = AnalysisResults::default();
815 results
816 .unused_dependencies
817 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
818 package_name: "lodash".to_owned(),
819 location: DependencyLocation::Dependencies,
820 path: PathBuf::from("/repo/package.json"),
821 line: 3,
822 used_in_workspaces: Vec::new(),
823 }));
824
825 let changed = FxHashSet::default();
826 filter_results_by_changed_files(&mut results, &changed);
827
828 assert_eq!(results.unused_dependencies.len(), 1);
829 }
830
831 #[test]
832 fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
833 let mut results = AnalysisResults::default();
834 results
835 .empty_catalog_groups
836 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
837 catalog_name: "legacy".to_owned(),
838 path: PathBuf::from("pnpm-workspace.yaml"),
839 line: 4,
840 }));
841
842 let mut changed = FxHashSet::default();
843 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
844
845 filter_results_by_changed_files(&mut results, &changed);
846
847 assert_eq!(results.empty_catalog_groups.len(), 1);
848 }
849
850 #[test]
851 fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
852 let mut report = DuplicationReport {
853 clone_groups: vec![
854 CloneGroup {
855 instances: vec![
856 CloneInstance {
857 file: PathBuf::from("/repo/a.ts"),
858 start_line: 1,
859 end_line: 5,
860 start_col: 0,
861 end_col: 10,
862 fragment: "code".to_owned(),
863 },
864 CloneInstance {
865 file: PathBuf::from("/repo/b.ts"),
866 start_line: 1,
867 end_line: 5,
868 start_col: 0,
869 end_col: 10,
870 fragment: "code".to_owned(),
871 },
872 ],
873 token_count: 20,
874 line_count: 5,
875 },
876 CloneGroup {
877 instances: vec![
878 CloneInstance {
879 file: PathBuf::from("/repo/c.ts"),
880 start_line: 1,
881 end_line: 5,
882 start_col: 0,
883 end_col: 10,
884 fragment: "other".to_owned(),
885 },
886 CloneInstance {
887 file: PathBuf::from("/repo/d.ts"),
888 start_line: 1,
889 end_line: 5,
890 start_col: 0,
891 end_col: 10,
892 fragment: "other".to_owned(),
893 },
894 ],
895 token_count: 20,
896 line_count: 5,
897 },
898 ],
899 clone_families: Vec::new(),
900 mirrored_directories: Vec::new(),
901 stats: DuplicationStats {
902 total_files: 4,
903 files_with_clones: 4,
904 total_lines: 100,
905 duplicated_lines: 20,
906 total_tokens: 200,
907 duplicated_tokens: 80,
908 clone_groups: 2,
909 clone_instances: 4,
910 duplication_percentage: 20.0,
911 clone_groups_below_min_occurrences: 0,
912 },
913 };
914
915 let mut changed = FxHashSet::default();
916 changed.insert(PathBuf::from("/repo/a.ts"));
917
918 filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
919
920 assert_eq!(report.clone_groups.len(), 1);
921 assert_eq!(report.stats.clone_groups, 1);
922 assert_eq!(report.stats.clone_instances, 2);
923 }
924}