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> {
206 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
207 let toplevel = resolve_git_toplevel(root)?;
208 let merge_base_output = spawn_output(&mut git_command(root, &["merge-base", git_ref, "HEAD"]))
209 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
210 if !merge_base_output.status.success() {
211 return Err(changed_files_error_from_output(&merge_base_output));
212 }
213 let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
214 .trim()
215 .to_owned();
216 if merge_base.is_empty() {
217 return Err(ChangedFilesError::GitFailed(
218 "git merge-base returned empty output".to_owned(),
219 ));
220 }
221
222 let output = spawn_output(&mut git_command(
223 root,
224 &[
225 "diff",
226 "--relative",
227 "--unified=0",
228 "--end-of-options",
229 &merge_base,
230 ],
231 ))
232 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
233
234 if !output.status.success() {
235 return Err(changed_files_error_from_output(&output));
236 }
237
238 let mut diff = String::from_utf8_lossy(&output.stdout).into_owned();
239 append_untracked_diffs(root, &toplevel, &mut diff)?;
240 Ok(diff)
241}
242
243fn append_untracked_diffs(
244 root: &Path,
245 toplevel: &Path,
246 diff: &mut String,
247) -> Result<(), ChangedFilesError> {
248 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
249 let mut untracked: Vec<PathBuf> = collect_git_paths(
250 root,
251 toplevel,
252 &["ls-files", "--full-name", "--others", "--exclude-standard"],
253 )?
254 .into_iter()
255 .filter_map(|path| {
256 path.strip_prefix(&canonical_root)
257 .ok()
258 .map(Path::to_path_buf)
259 })
260 .collect();
261 untracked.sort_unstable();
262
263 #[cfg(windows)]
264 let empty_file = "NUL";
265 #[cfg(not(windows))]
266 let empty_file = "/dev/null";
267
268 for path in untracked {
269 let mut command = git_command(root, &["diff", "--no-index", "--unified=0", "--"]);
270 command.arg(empty_file).arg(&path);
271 let output =
272 spawn_output(&mut command).map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
273 if !output.status.success() && output.status.code() != Some(1) {
274 return Err(changed_files_error_from_output(&output));
275 }
276 if !diff.is_empty() && !diff.ends_with('\n') {
277 diff.push('\n');
278 }
279 diff.push_str(&String::from_utf8_lossy(&output.stdout));
280 }
281 Ok(())
282}
283
284fn changed_files_error_from_output(output: &Output) -> ChangedFilesError {
285 let stderr = String::from_utf8_lossy(&output.stderr);
286 if stderr.contains("not a git repository") {
287 ChangedFilesError::NotARepository
288 } else {
289 ChangedFilesError::GitFailed(stderr.trim().to_owned())
290 }
291}
292
293#[must_use]
295#[expect(
296 clippy::print_stderr,
297 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; typed callers use try_get_changed_files instead"
298)]
299pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
300 match try_get_changed_files(root, git_ref) {
301 Ok(files) => Some(files),
302 Err(ChangedFilesError::InvalidRef(e)) => {
303 eprintln!("Warning: --changed-since ignored: invalid git ref: {e}");
304 None
305 }
306 Err(ChangedFilesError::GitMissing(e)) => {
307 eprintln!("Warning: --changed-since ignored: failed to run git: {e}");
308 None
309 }
310 Err(ChangedFilesError::NotARepository) => {
311 eprintln!("Warning: --changed-since ignored: not a git repository");
312 None
313 }
314 Err(ChangedFilesError::GitFailed(stderr)) => {
315 eprintln!("Warning: --changed-since failed for ref '{git_ref}': {stderr}");
316 None
317 }
318 }
319}
320
321fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
322 if let Some(hook) = SPAWN_HOOK.get() {
323 hook(command)
324 } else {
325 command.output()
326 }
327}
328
329fn collect_git_paths(
330 cwd: &Path,
331 toplevel: &Path,
332 args: &[&str],
333) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
334 let output = spawn_output(&mut git_command(cwd, args))
335 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
336
337 if !output.status.success() {
338 let stderr = String::from_utf8_lossy(&output.stderr);
339 return Err(if stderr.contains("not a git repository") {
340 ChangedFilesError::NotARepository
341 } else {
342 ChangedFilesError::GitFailed(stderr.trim().to_owned())
343 });
344 }
345
346 #[cfg(windows)]
347 let normalise_segment = |line: &str| line.replace('/', "\\");
348 #[cfg(not(windows))]
349 let normalise_segment = |line: &str| line.to_owned();
350
351 let files = String::from_utf8_lossy(&output.stdout)
352 .lines()
353 .filter(|line| !line.is_empty())
354 .map(|line| toplevel.join(normalise_segment(line)))
355 .collect();
356
357 Ok(files)
358}
359
360#[expect(
361 clippy::disallowed_methods,
362 reason = "canonical engine-owned git spawn wrapper for changed-file orchestration"
363)]
364fn git_command(cwd: &Path, args: &[&str]) -> Command {
365 let mut command = Command::new("git");
366 clear_ambient_git_env(&mut command);
367 command.args(args).current_dir(cwd);
368 command
369}
370
371#[expect(
376 clippy::implicit_hasher,
377 reason = "fallow standardizes on FxHashSet across the workspace"
378)]
379pub fn filter_results_by_changed_files(
380 results: &mut AnalysisResults,
381 changed_files: &FxHashSet<PathBuf>,
382) {
383 let cf = normalize_changed_files_set(changed_files);
384 classify_changed_file_filter_fields(results);
385 retain_basic_issue_findings_by_changed_path(results, &cf);
386 retain_graph_findings_by_changed_files(results, &cf);
387 retain_boundary_policy_and_suppression_findings(results, &cf);
388 retain_security_and_workspace_findings(results, &cf);
389 retain_framework_findings_by_changed_files(results, &cf);
390}
391
392fn classify_changed_file_filter_fields(results: &AnalysisResults) {
393 let AnalysisResults {
394 unused_files: _unused_files,
395 unused_exports: _unused_exports,
396 unused_types: _unused_types,
397 private_type_leaks: _private_type_leaks,
398 unused_dependencies: _unused_dependencies,
399 unused_dev_dependencies: _unused_dev_dependencies,
400 unused_optional_dependencies: _unused_optional_dependencies,
401 unused_enum_members: _unused_enum_members,
402 unused_class_members: _unused_class_members,
403 unused_store_members: _unused_store_members,
404 unresolved_imports: _unresolved_imports,
405 unlisted_dependencies: _unlisted_dependencies,
406 duplicate_exports: _duplicate_exports,
407 type_only_dependencies: _type_only_dependencies,
408 test_only_dependencies: _test_only_dependencies,
409 dev_dependencies_in_production: _dev_dependencies_in_production,
410 circular_dependencies: _circular_dependencies,
411 re_export_cycles: _re_export_cycles,
412 boundary_violations: _boundary_violations,
413 boundary_coverage_violations: _boundary_coverage_violations,
414 boundary_call_violations: _boundary_call_violations,
415 policy_violations: _policy_violations,
416 stale_suppressions: _stale_suppressions,
417 unused_catalog_entries: _unused_catalog_entries,
418 empty_catalog_groups: _empty_catalog_groups,
419 unresolved_catalog_references: _unresolved_catalog_references,
420 unused_dependency_overrides: _unused_dependency_overrides,
421 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
422 invalid_client_exports: _invalid_client_exports,
423 mixed_client_server_barrels: _mixed_client_server_barrels,
424 misplaced_directives: _misplaced_directives,
425 unprovided_injects: _unprovided_injects,
426 unrendered_components: _unrendered_components,
427 route_collisions: _route_collisions,
428 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
429 unused_component_props: _unused_component_props,
430 unused_component_emits: _unused_component_emits,
431 unused_component_inputs: _unused_component_inputs,
432 unused_component_outputs: _unused_component_outputs,
433 unused_svelte_events: _unused_svelte_events,
434 unused_server_actions: _unused_server_actions,
435 unused_load_data_keys: _unused_load_data_keys,
436 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
437 prop_drilling_chains: _prop_drilling_chains,
438 thin_wrappers: _thin_wrappers,
439 duplicate_prop_shapes: _duplicate_prop_shapes,
440 suppression_count: _suppression_count,
441 unused_component_props_exempted: _unused_component_props_exempted,
442 active_suppressions: _active_suppressions,
443 feature_flags: _feature_flags,
444 security_findings: _security_findings,
445 security_unresolved_edge_files: _security_unresolved_edge_files,
446 security_unresolved_callee_sites: _security_unresolved_callee_sites,
447 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
448 export_usages: _export_usages,
449 entry_point_summary: _entry_point_summary,
450 render_fan_in: _render_fan_in,
451 react_component_intel: _react_component_intel,
452 } = results;
453}
454
455fn retain_basic_issue_findings_by_changed_path(
456 results: &mut AnalysisResults,
457 changed_files: &FxHashSet<PathBuf>,
458) {
459 retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
460 retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
461 &e.export.path
462 });
463 retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
464 retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
465 &e.leak.path
466 });
467 retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
468 &m.member.path
469 });
470 retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
471 &m.member.path
472 });
473 retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
474 &m.member.path
475 });
476 retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
477 &i.import.path
478 });
479}
480
481fn retain_graph_findings_by_changed_files(
482 results: &mut AnalysisResults,
483 changed_files: &FxHashSet<PathBuf>,
484) {
485 retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
486 retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
487 retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
488 retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
489}
490
491fn retain_boundary_policy_and_suppression_findings(
492 results: &mut AnalysisResults,
493 changed_files: &FxHashSet<PathBuf>,
494) {
495 retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
496 &v.violation.from_path
497 });
498 retain_by_changed_path(
499 &mut results.boundary_coverage_violations,
500 changed_files,
501 |v| &v.violation.path,
502 );
503 retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
504 &v.violation.path
505 });
506 retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
507 &v.violation.path
508 });
509 retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
510}
511
512fn retain_security_and_workspace_findings(
513 results: &mut AnalysisResults,
514 changed_files: &FxHashSet<PathBuf>,
515) {
516 retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
517 retain_by_changed_path(
518 &mut results.security_unresolved_callee_diagnostics,
519 changed_files,
520 |d| &d.path,
521 );
522 retain_by_changed_path(
523 &mut results.unresolved_catalog_references,
524 changed_files,
525 |r| &r.reference.path,
526 );
527 results
528 .empty_catalog_groups
529 .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
530 retain_by_changed_path(
531 &mut results.unused_dependency_overrides,
532 changed_files,
533 |o| &o.entry.path,
534 );
535 retain_by_changed_path(
536 &mut results.misconfigured_dependency_overrides,
537 changed_files,
538 |o| &o.entry.path,
539 );
540}
541
542fn retain_framework_findings_by_changed_files(
543 results: &mut AnalysisResults,
544 changed_files: &FxHashSet<PathBuf>,
545) {
546 retain_client_boundary_findings_by_changed_files(results, changed_files);
547 retain_component_contract_findings_by_changed_files(results, changed_files);
548 retain_react_health_findings_by_changed_files(results, changed_files);
549 retain_nextjs_findings_by_changed_files(results, changed_files);
550}
551
552fn retain_client_boundary_findings_by_changed_files(
553 results: &mut AnalysisResults,
554 changed_files: &FxHashSet<PathBuf>,
555) {
556 let AnalysisResults {
557 invalid_client_exports,
558 mixed_client_server_barrels,
559 misplaced_directives,
560 ..
561 } = results;
562
563 retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
564 retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
565 &b.barrel.path
566 });
567 retain_by_changed_path(misplaced_directives, changed_files, |d| {
568 &d.directive_site.path
569 });
570}
571
572fn retain_component_contract_findings_by_changed_files(
573 results: &mut AnalysisResults,
574 changed_files: &FxHashSet<PathBuf>,
575) {
576 let AnalysisResults {
577 unprovided_injects,
578 unrendered_components,
579 unused_component_props,
580 unused_component_emits,
581 unused_component_inputs,
582 unused_component_outputs,
583 unused_svelte_events,
584 unused_server_actions,
585 unused_load_data_keys,
586 ..
587 } = results;
588
589 retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
590 retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
591 retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
592 retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
593 retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
594 retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
595 retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
596 retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
597 retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
598}
599
600fn retain_react_health_findings_by_changed_files(
601 results: &mut AnalysisResults,
602 changed_files: &FxHashSet<PathBuf>,
603) {
604 let AnalysisResults {
605 prop_drilling_chains,
606 thin_wrappers,
607 duplicate_prop_shapes,
608 ..
609 } = results;
610
611 retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
612 retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
613 retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
614}
615
616fn retain_nextjs_findings_by_changed_files(
617 results: &mut AnalysisResults,
618 changed_files: &FxHashSet<PathBuf>,
619) {
620 let AnalysisResults {
621 route_collisions,
622 dynamic_segment_name_conflicts,
623 ..
624 } = results;
625
626 retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
627 retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
628 &c.conflict.path
629 });
630}
631
632fn retain_unlisted_dependencies_by_import_site(
633 dependencies: &mut Vec<UnlistedDependencyFinding>,
634 changed_files: &FxHashSet<PathBuf>,
635) {
636 dependencies.retain(|dependency| {
637 dependency
638 .dep
639 .imported_from
640 .iter()
641 .any(|site| contains_normalized(changed_files, &site.path))
642 });
643}
644
645fn retain_duplicate_exports_by_changed_locations(
646 duplicate_exports: &mut Vec<DuplicateExportFinding>,
647 changed_files: &FxHashSet<PathBuf>,
648) {
649 for duplicate in &mut *duplicate_exports {
650 duplicate
651 .export
652 .locations
653 .retain(|location| contains_normalized(changed_files, &location.path));
654 }
655 duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
656}
657
658fn retain_circular_dependencies_by_changed_file(
659 cycles: &mut Vec<CircularDependencyFinding>,
660 changed_files: &FxHashSet<PathBuf>,
661) {
662 cycles.retain(|cycle| {
663 cycle
664 .cycle
665 .files
666 .iter()
667 .any(|file| contains_normalized(changed_files, file))
668 });
669}
670
671fn retain_re_export_cycles_by_changed_file(
672 cycles: &mut Vec<ReExportCycleFinding>,
673 changed_files: &FxHashSet<PathBuf>,
674) {
675 cycles.retain(|cycle| {
676 cycle
677 .cycle
678 .files
679 .iter()
680 .any(|file| contains_normalized(changed_files, file))
681 });
682}
683
684fn retain_security_findings_by_changed_path(
685 findings: &mut Vec<SecurityFinding>,
686 changed_files: &FxHashSet<PathBuf>,
687) {
688 findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
689}
690
691fn retain_prop_drilling_chains_by_anchor(
692 chains: &mut Vec<PropDrillingChainFinding>,
693 changed_files: &FxHashSet<PathBuf>,
694) {
695 chains.retain(|chain| {
696 chain
697 .chain
698 .hops
699 .first()
700 .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
701 });
702}
703
704fn retain_duplicate_prop_shapes_by_anchor(
705 shapes: &mut Vec<DuplicatePropShapeFinding>,
706 changed_files: &FxHashSet<PathBuf>,
707) {
708 retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
709}
710
711fn retain_by_changed_path<T>(
712 items: &mut Vec<T>,
713 changed_files: &FxHashSet<PathBuf>,
714 path: impl Fn(&T) -> &Path,
715) {
716 items.retain(|item| contains_normalized(changed_files, path(item)));
717}
718
719fn security_finding_touches_changed_path(
720 finding: &SecurityFinding,
721 changed_files: &FxHashSet<PathBuf>,
722) -> bool {
723 contains_normalized(changed_files, &finding.path)
724 || finding
725 .trace
726 .iter()
727 .any(|hop| contains_normalized(changed_files, &hop.path))
728 || finding.reachability.as_ref().is_some_and(|reachability| {
729 reachability
730 .untrusted_source_trace
731 .iter()
732 .any(|hop| contains_normalized(changed_files, &hop.path))
733 })
734}
735
736fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
737 changed_files
738 .iter()
739 .map(|p| dunce::simplified(p).to_path_buf())
740 .collect()
741}
742
743fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
744 normalized.contains(dunce::simplified(path))
745}
746
747fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
748 contains_normalized(normalized, path)
749 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
750}
751
752#[expect(
754 clippy::implicit_hasher,
755 reason = "fallow standardizes on FxHashSet across the workspace"
756)]
757pub fn filter_duplication_by_changed_files(
758 report: &mut DuplicationReport,
759 changed_files: &FxHashSet<PathBuf>,
760 root: &Path,
761) {
762 let cf = normalize_changed_files_set(changed_files);
763 report.clone_groups.retain(|group| {
764 group
765 .instances
766 .iter()
767 .any(|instance| contains_normalized(&cf, &instance.file))
768 });
769 duplicates::refresh_clone_families(report, root);
770 report.stats = duplicates::recompute_stats(report);
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776 use fallow_types::{
777 duplicates::{CloneGroup, CloneInstance, DuplicationStats},
778 output_dead_code::{
779 EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
780 UnusedFileFinding,
781 },
782 results::{
783 DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
784 },
785 };
786
787 #[test]
788 fn validate_git_ref_rejects_option_like_ref() {
789 assert!(validate_git_ref("--upload-pack=evil").is_err());
790 assert!(validate_git_ref("-flag").is_err());
791 }
792
793 #[test]
794 fn validate_git_ref_allows_reflog_relative_date() {
795 assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
796 }
797
798 #[test]
799 fn git_command_clears_parent_git_environment() {
800 let command = git_command(Path::new("."), &["status"]);
801 let envs: Vec<_> = command.get_envs().collect();
802
803 for var in AMBIENT_GIT_ENV_VARS {
804 assert!(
805 envs.iter()
806 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
807 "{var} should be cleared from the command env",
808 );
809 }
810 }
811
812 #[test]
813 fn try_get_changed_files_not_a_repository() {
814 let temp = tempfile::tempdir().expect("tempdir");
815 let result = try_get_changed_files(temp.path(), "main");
816 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
817 }
818
819 #[test]
820 fn changed_diff_covers_staged_unstaged_and_untracked_files() {
821 let repo = tempfile::tempdir().expect("tempdir");
822 for args in [
823 &["init", "--quiet"][..],
824 &["config", "user.email", "test@example.com"][..],
825 &["config", "user.name", "Test User"][..],
826 ] {
827 run_git(repo.path(), args);
828 }
829 std::fs::write(repo.path().join("staged.ts"), "old\n").expect("staged fixture");
830 std::fs::write(repo.path().join("unstaged.ts"), "old\n").expect("unstaged fixture");
831 run_git(repo.path(), &["add", "."]);
832 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
833 run_git(repo.path(), &["tag", "base"]);
834
835 std::fs::write(repo.path().join("committed.ts"), "committed\n").expect("committed fixture");
836 run_git(repo.path(), &["add", "committed.ts"]);
837 run_git(
838 repo.path(),
839 &["commit", "--quiet", "-m", "committed change"],
840 );
841
842 std::fs::write(repo.path().join("staged.ts"), "staged\n").expect("staged edit");
843 run_git(repo.path(), &["add", "staged.ts"]);
844 std::fs::write(repo.path().join("unstaged.ts"), "unstaged\n").expect("unstaged edit");
845 std::fs::write(repo.path().join("untracked.ts"), "untracked\n").expect("untracked edit");
846
847 let diff = try_get_changed_diff(repo.path(), "base").expect("complete changeset diff");
848 let index = fallow_output::DiffIndex::from_unified_diff(&diff);
849
850 assert!(diff.contains("b/committed.ts"), "{diff}");
851 assert!(diff.contains("b/staged.ts"), "{diff}");
852 assert!(diff.contains("b/unstaged.ts"), "{diff}");
853 assert!(diff.contains("b/untracked.ts"), "{diff}");
854 assert_eq!(index.hunk_count(), 4);
855 assert_eq!(index.net_lines(), 2);
856 }
857
858 fn run_git(root: &Path, args: &[&str]) {
859 let output = spawn_output(&mut git_command(root, args)).expect("git command");
860 assert!(
861 output.status.success(),
862 "git {args:?} failed: {}",
863 String::from_utf8_lossy(&output.stderr)
864 );
865 }
866
867 #[test]
868 fn changed_files_error_describe_matches_core_contract() {
869 assert_eq!(
870 ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
871 "invalid git ref: bad ref"
872 );
873 assert_eq!(
874 ChangedFilesError::GitMissing("not found".to_string()).describe(),
875 "failed to run git: not found"
876 );
877 assert_eq!(
878 ChangedFilesError::NotARepository.describe(),
879 "not a git repository"
880 );
881 assert!(
882 ChangedFilesError::GitFailed("unknown revision main".to_string())
883 .describe()
884 .contains("fetch-depth: 0")
885 );
886 }
887
888 #[test]
889 fn filter_results_keeps_only_changed_file_findings() {
890 let mut results = AnalysisResults::default();
891 results
892 .unused_files
893 .push(UnusedFileFinding::with_actions(UnusedFile {
894 path: PathBuf::from("/repo/a.ts"),
895 }));
896 results
897 .unused_files
898 .push(UnusedFileFinding::with_actions(UnusedFile {
899 path: PathBuf::from("/repo/b.ts"),
900 }));
901 results
902 .unused_exports
903 .push(UnusedExportFinding::with_actions(UnusedExport {
904 path: PathBuf::from("/repo/a.ts"),
905 export_name: "foo".to_owned(),
906 is_type_only: false,
907 line: 1,
908 col: 0,
909 span_start: 0,
910 is_re_export: false,
911 }));
912
913 let mut changed = FxHashSet::default();
914 changed.insert(PathBuf::from("/repo/a.ts"));
915
916 filter_results_by_changed_files(&mut results, &changed);
917
918 assert_eq!(results.unused_files.len(), 1);
919 assert_eq!(
920 results.unused_files[0].file.path,
921 PathBuf::from("/repo/a.ts")
922 );
923 assert_eq!(results.unused_exports.len(), 1);
924 }
925
926 #[test]
927 fn filter_results_preserves_graph_global_dependency_findings() {
928 let mut results = AnalysisResults::default();
929 results
930 .unused_dependencies
931 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
932 package_name: "lodash".to_owned(),
933 location: DependencyLocation::Dependencies,
934 path: PathBuf::from("/repo/package.json"),
935 line: 3,
936 used_in_workspaces: Vec::new(),
937 }));
938
939 let changed = FxHashSet::default();
940 filter_results_by_changed_files(&mut results, &changed);
941
942 assert_eq!(results.unused_dependencies.len(), 1);
943 }
944
945 #[test]
946 fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
947 let mut results = AnalysisResults::default();
948 results
949 .empty_catalog_groups
950 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
951 catalog_name: "legacy".to_owned(),
952 path: PathBuf::from("pnpm-workspace.yaml"),
953 line: 4,
954 }));
955
956 let mut changed = FxHashSet::default();
957 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
958
959 filter_results_by_changed_files(&mut results, &changed);
960
961 assert_eq!(results.empty_catalog_groups.len(), 1);
962 }
963
964 #[test]
965 fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
966 let mut report = DuplicationReport {
967 clone_groups: vec![
968 CloneGroup {
969 instances: vec![
970 CloneInstance {
971 file: PathBuf::from("/repo/a.ts"),
972 start_line: 1,
973 end_line: 5,
974 start_col: 0,
975 end_col: 10,
976 fragment: "code".to_owned(),
977 },
978 CloneInstance {
979 file: PathBuf::from("/repo/b.ts"),
980 start_line: 1,
981 end_line: 5,
982 start_col: 0,
983 end_col: 10,
984 fragment: "code".to_owned(),
985 },
986 ],
987 token_count: 20,
988 line_count: 5,
989 },
990 CloneGroup {
991 instances: vec![
992 CloneInstance {
993 file: PathBuf::from("/repo/c.ts"),
994 start_line: 1,
995 end_line: 5,
996 start_col: 0,
997 end_col: 10,
998 fragment: "other".to_owned(),
999 },
1000 CloneInstance {
1001 file: PathBuf::from("/repo/d.ts"),
1002 start_line: 1,
1003 end_line: 5,
1004 start_col: 0,
1005 end_col: 10,
1006 fragment: "other".to_owned(),
1007 },
1008 ],
1009 token_count: 20,
1010 line_count: 5,
1011 },
1012 ],
1013 clone_families: Vec::new(),
1014 mirrored_directories: Vec::new(),
1015 stats: DuplicationStats {
1016 total_files: 4,
1017 files_with_clones: 4,
1018 total_lines: 100,
1019 duplicated_lines: 20,
1020 total_tokens: 200,
1021 duplicated_tokens: 80,
1022 clone_groups: 2,
1023 clone_instances: 4,
1024 duplication_percentage: 20.0,
1025 clone_groups_below_min_occurrences: 0,
1026 },
1027 };
1028
1029 let mut changed = FxHashSet::default();
1030 changed.insert(PathBuf::from("/repo/a.ts"));
1031
1032 filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1033
1034 assert_eq!(report.clone_groups.len(), 1);
1035 assert_eq!(report.stats.clone_groups, 1);
1036 assert_eq!(report.stats.clone_instances, 2);
1037 }
1038}