1use std::path::{Path, PathBuf};
4use std::process::{Command, Output, Stdio};
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(crate) 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
151fn 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 "-z",
185 "--end-of-options",
186 &format!("{git_ref}...HEAD"),
187 ],
188 )?;
189 files.extend(collect_git_paths(
190 cwd,
191 toplevel,
192 &["diff", "--name-only", "-z", "HEAD"],
193 )?);
194 files.extend(collect_git_paths(
195 cwd,
196 toplevel,
197 &[
198 "ls-files",
199 "--full-name",
200 "--others",
201 "--exclude-standard",
202 "-z",
203 ],
204 )?);
205 Ok(files)
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct RenamedFile {
212 pub from: PathBuf,
214 pub to: PathBuf,
216}
217
218pub fn try_get_renamed_files(
230 root: &Path,
231 git_ref: &str,
232) -> Result<Vec<RenamedFile>, ChangedFilesError> {
233 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
234 let toplevel = resolve_git_toplevel(root)?;
235 let mut renames = collect_git_rename_pairs(
236 root,
237 &toplevel,
238 &[
239 "diff",
240 "--name-status",
241 "-z",
242 "--find-renames",
243 "--end-of-options",
244 &format!("{git_ref}...HEAD"),
245 ],
246 )?;
247 let staged = collect_git_rename_pairs(
248 root,
249 &toplevel,
250 &["diff", "--name-status", "-z", "--find-renames", "HEAD"],
251 )?;
252 for pair in staged {
253 if let Some(chained) = renames.iter_mut().find(|rename| rename.to == pair.from) {
254 chained.to = pair.to;
255 } else {
256 renames.push(pair);
257 }
258 }
259 Ok(renames)
260}
261
262fn collect_git_rename_pairs(
268 cwd: &Path,
269 toplevel: &Path,
270 args: &[&str],
271) -> Result<Vec<RenamedFile>, ChangedFilesError> {
272 let output = spawn_output(&mut git_command(cwd, args))
273 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
274
275 if !output.status.success() {
276 return Err(changed_files_error_from_output(&output));
277 }
278
279 let mut fields = output
280 .stdout
281 .split(|byte| *byte == 0)
282 .filter(|field| !field.is_empty());
283 let mut renames = Vec::new();
284 while let Some(status) = fields.next() {
285 let Some(first_path) = fields.next() else {
286 break;
287 };
288 match status.first() {
289 Some(b'R') => {
290 let Some(second_path) = fields.next() else {
291 break;
292 };
293 renames.push(RenamedFile {
294 from: toplevel.join(git_path_from_bytes(first_path)),
295 to: toplevel.join(git_path_from_bytes(second_path)),
296 });
297 }
298 Some(b'C') => {
301 let _ = fields.next();
302 }
303 _ => {}
304 }
305 }
306 Ok(renames)
307}
308
309pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
314 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
315 let toplevel = resolve_git_toplevel(root)?;
316 let merge_base_output = spawn_output(&mut git_command(root, &["merge-base", git_ref, "HEAD"]))
317 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
318 if !merge_base_output.status.success() {
319 return Err(changed_files_error_from_output(&merge_base_output));
320 }
321 let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
322 .trim()
323 .to_owned();
324 if merge_base.is_empty() {
325 return Err(ChangedFilesError::GitFailed(
326 "git merge-base returned empty output".to_owned(),
327 ));
328 }
329
330 let output = spawn_output(&mut git_command(
331 root,
332 &[
333 "diff",
334 "--relative",
335 "--unified=0",
336 "--end-of-options",
337 &merge_base,
338 ],
339 ))
340 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
341
342 if !output.status.success() {
343 return Err(changed_files_error_from_output(&output));
344 }
345
346 let mut diff = String::from_utf8_lossy(&output.stdout).into_owned();
347 append_untracked_diffs(root, &toplevel, &mut diff)?;
348 Ok(diff)
349}
350
351fn append_untracked_diffs(
352 root: &Path,
353 toplevel: &Path,
354 diff: &mut String,
355) -> Result<(), ChangedFilesError> {
356 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
357 let mut untracked: Vec<PathBuf> = collect_git_paths(
358 root,
359 toplevel,
360 &[
361 "ls-files",
362 "--full-name",
363 "--others",
364 "--exclude-standard",
365 "-z",
366 ],
367 )?
368 .into_iter()
369 .filter_map(|path| {
370 path.strip_prefix(&canonical_root)
371 .ok()
372 .map(Path::to_path_buf)
373 })
374 .collect();
375 untracked.sort_unstable();
376
377 #[cfg(windows)]
378 let empty_file = "NUL";
379 #[cfg(not(windows))]
380 let empty_file = "/dev/null";
381
382 for path in untracked {
383 let mut command = git_command(root, &["diff", "--no-index", "--unified=0", "--"]);
384 command.arg(empty_file).arg(untracked_path_arg(&path));
385 let output =
386 spawn_output(&mut command).map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
387 if !output.status.success() && output.status.code() != Some(1) {
388 return Err(changed_files_error_from_output(&output));
389 }
390 if !diff.is_empty() && !diff.ends_with('\n') {
391 diff.push('\n');
392 }
393 diff.push_str(&String::from_utf8_lossy(&output.stdout));
394 }
395 Ok(())
396}
397
398fn untracked_path_arg(path: &Path) -> String {
403 path.to_string_lossy().replace('\\', "/")
404}
405
406fn changed_files_error_from_output(output: &Output) -> ChangedFilesError {
407 let stderr = String::from_utf8_lossy(&output.stderr);
408 if stderr.contains("not a git repository") {
409 ChangedFilesError::NotARepository
410 } else {
411 ChangedFilesError::GitFailed(stderr.trim().to_owned())
412 }
413}
414
415#[must_use]
417#[expect(
418 clippy::print_stderr,
419 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; typed callers use try_get_changed_files instead"
420)]
421pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
422 match try_get_changed_files(root, git_ref) {
423 Ok(files) => Some(files),
424 Err(ChangedFilesError::InvalidRef(e)) => {
425 eprintln!("Warning: --changed-since ignored: invalid git ref: {e}");
426 None
427 }
428 Err(ChangedFilesError::GitMissing(e)) => {
429 eprintln!("Warning: --changed-since ignored: failed to run git: {e}");
430 None
431 }
432 Err(ChangedFilesError::NotARepository) => {
433 eprintln!("Warning: --changed-since ignored: not a git repository");
434 None
435 }
436 Err(ChangedFilesError::GitFailed(stderr)) => {
437 eprintln!("Warning: --changed-since failed for ref '{git_ref}': {stderr}");
438 None
439 }
440 }
441}
442
443fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
444 if let Some(hook) = SPAWN_HOOK.get() {
445 hook(command)
446 } else {
447 command.output()
448 }
449}
450
451fn collect_git_paths(
452 cwd: &Path,
453 toplevel: &Path,
454 args: &[&str],
455) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
456 let output = spawn_output(&mut git_command(cwd, args))
457 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
458
459 if !output.status.success() {
460 let stderr = String::from_utf8_lossy(&output.stderr);
461 return Err(if stderr.contains("not a git repository") {
462 ChangedFilesError::NotARepository
463 } else {
464 ChangedFilesError::GitFailed(stderr.trim().to_owned())
465 });
466 }
467
468 let files = output
469 .stdout
470 .split(|byte| *byte == 0)
471 .filter(|path| !path.is_empty())
472 .map(git_path_from_bytes)
473 .map(|path| toplevel.join(path))
474 .collect();
475
476 Ok(files)
477}
478
479#[cfg(unix)]
485pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
486 use std::ffi::OsString;
487 use std::os::unix::ffi::OsStringExt;
488
489 PathBuf::from(OsString::from_vec(path.to_vec()))
490}
491
492#[cfg(windows)]
495pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
496 PathBuf::from(String::from_utf8_lossy(path).replace('/', "\\"))
497}
498
499#[expect(
500 clippy::disallowed_methods,
501 reason = "canonical engine-owned git spawn wrapper for changed-file orchestration"
502)]
503fn git_command(cwd: &Path, args: &[&str]) -> Command {
504 let mut command = Command::new("git");
505 clear_ambient_git_env(&mut command);
506 command.stdin(Stdio::null()).args(args).current_dir(cwd);
508 command
509}
510
511#[expect(
516 clippy::implicit_hasher,
517 reason = "fallow standardizes on FxHashSet across the workspace"
518)]
519pub fn filter_results_by_changed_files(
520 results: &mut AnalysisResults,
521 changed_files: &FxHashSet<PathBuf>,
522) {
523 let cf = normalize_changed_files_set(changed_files);
524 classify_changed_file_filter_fields(results);
525 retain_basic_issue_findings_by_changed_path(results, &cf);
526 retain_graph_findings_by_changed_files(results, &cf);
527 retain_boundary_policy_and_suppression_findings(results, &cf);
528 retain_security_and_workspace_findings(results, &cf);
529 retain_framework_findings_by_changed_files(results, &cf);
530}
531
532fn classify_changed_file_filter_fields(results: &AnalysisResults) {
533 let AnalysisResults {
534 unused_files: _unused_files,
535 unused_exports: _unused_exports,
536 unused_types: _unused_types,
537 private_type_leaks: _private_type_leaks,
538 unused_dependencies: _unused_dependencies,
539 unused_dev_dependencies: _unused_dev_dependencies,
540 unused_optional_dependencies: _unused_optional_dependencies,
541 unused_enum_members: _unused_enum_members,
542 unused_class_members: _unused_class_members,
543 unused_store_members: _unused_store_members,
544 unresolved_imports: _unresolved_imports,
545 unlisted_dependencies: _unlisted_dependencies,
546 duplicate_exports: _duplicate_exports,
547 type_only_dependencies: _type_only_dependencies,
548 test_only_dependencies: _test_only_dependencies,
549 dev_dependencies_in_production: _dev_dependencies_in_production,
550 circular_dependencies: _circular_dependencies,
551 re_export_cycles: _re_export_cycles,
552 boundary_violations: _boundary_violations,
553 boundary_coverage_violations: _boundary_coverage_violations,
554 boundary_call_violations: _boundary_call_violations,
555 policy_violations: _policy_violations,
556 stale_suppressions: _stale_suppressions,
557 unused_catalog_entries: _unused_catalog_entries,
558 empty_catalog_groups: _empty_catalog_groups,
559 unresolved_catalog_references: _unresolved_catalog_references,
560 unused_dependency_overrides: _unused_dependency_overrides,
561 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
562 invalid_client_exports: _invalid_client_exports,
563 mixed_client_server_barrels: _mixed_client_server_barrels,
564 misplaced_directives: _misplaced_directives,
565 unprovided_injects: _unprovided_injects,
566 unrendered_components: _unrendered_components,
567 route_collisions: _route_collisions,
568 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
569 unused_component_props: _unused_component_props,
570 unused_component_emits: _unused_component_emits,
571 unused_component_inputs: _unused_component_inputs,
572 unused_component_outputs: _unused_component_outputs,
573 unused_svelte_events: _unused_svelte_events,
574 unused_server_actions: _unused_server_actions,
575 unused_load_data_keys: _unused_load_data_keys,
576 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
577 prop_drilling_chains: _prop_drilling_chains,
578 thin_wrappers: _thin_wrappers,
579 duplicate_prop_shapes: _duplicate_prop_shapes,
580 suppression_count: _suppression_count,
581 unused_component_props_exempted: _unused_component_props_exempted,
582 active_suppressions: _active_suppressions,
583 feature_flags: _feature_flags,
584 security_findings: _security_findings,
585 security_unresolved_edge_files: _security_unresolved_edge_files,
586 security_unresolved_callee_sites: _security_unresolved_callee_sites,
587 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
588 export_usages: _export_usages,
589 entry_point_summary: _entry_point_summary,
590 render_fan_in: _render_fan_in,
591 react_component_intel: _react_component_intel,
592 semantic_framework_contracts: _semantic_framework_contracts,
593 } = results;
594}
595
596fn retain_basic_issue_findings_by_changed_path(
597 results: &mut AnalysisResults,
598 changed_files: &FxHashSet<PathBuf>,
599) {
600 retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
601 retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
602 &e.export.path
603 });
604 retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
605 retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
606 &e.leak.path
607 });
608 retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
609 &m.member.path
610 });
611 retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
612 &m.member.path
613 });
614 retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
615 &m.member.path
616 });
617 retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
618 &i.import.path
619 });
620}
621
622fn retain_graph_findings_by_changed_files(
623 results: &mut AnalysisResults,
624 changed_files: &FxHashSet<PathBuf>,
625) {
626 retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
627 retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
628 retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
629 retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
630}
631
632fn retain_boundary_policy_and_suppression_findings(
633 results: &mut AnalysisResults,
634 changed_files: &FxHashSet<PathBuf>,
635) {
636 retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
637 &v.violation.from_path
638 });
639 retain_by_changed_path(
640 &mut results.boundary_coverage_violations,
641 changed_files,
642 |v| &v.violation.path,
643 );
644 retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
645 &v.violation.path
646 });
647 retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
648 &v.violation.path
649 });
650 retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
651}
652
653fn retain_security_and_workspace_findings(
654 results: &mut AnalysisResults,
655 changed_files: &FxHashSet<PathBuf>,
656) {
657 retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
658 retain_by_changed_path(
659 &mut results.security_unresolved_callee_diagnostics,
660 changed_files,
661 |d| &d.path,
662 );
663 retain_by_changed_path(
664 &mut results.unresolved_catalog_references,
665 changed_files,
666 |r| &r.reference.path,
667 );
668 results
669 .empty_catalog_groups
670 .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
671 retain_by_changed_path(
672 &mut results.unused_dependency_overrides,
673 changed_files,
674 |o| &o.entry.path,
675 );
676 retain_by_changed_path(
677 &mut results.misconfigured_dependency_overrides,
678 changed_files,
679 |o| &o.entry.path,
680 );
681}
682
683fn retain_framework_findings_by_changed_files(
684 results: &mut AnalysisResults,
685 changed_files: &FxHashSet<PathBuf>,
686) {
687 retain_client_boundary_findings_by_changed_files(results, changed_files);
688 retain_component_contract_findings_by_changed_files(results, changed_files);
689 retain_react_health_findings_by_changed_files(results, changed_files);
690 retain_nextjs_findings_by_changed_files(results, changed_files);
691}
692
693fn retain_client_boundary_findings_by_changed_files(
694 results: &mut AnalysisResults,
695 changed_files: &FxHashSet<PathBuf>,
696) {
697 let AnalysisResults {
698 invalid_client_exports,
699 mixed_client_server_barrels,
700 misplaced_directives,
701 ..
702 } = results;
703
704 retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
705 retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
706 &b.barrel.path
707 });
708 retain_by_changed_path(misplaced_directives, changed_files, |d| {
709 &d.directive_site.path
710 });
711}
712
713fn retain_component_contract_findings_by_changed_files(
714 results: &mut AnalysisResults,
715 changed_files: &FxHashSet<PathBuf>,
716) {
717 let AnalysisResults {
718 unprovided_injects,
719 unrendered_components,
720 unused_component_props,
721 unused_component_emits,
722 unused_component_inputs,
723 unused_component_outputs,
724 unused_svelte_events,
725 unused_server_actions,
726 unused_load_data_keys,
727 ..
728 } = results;
729
730 retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
731 retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
732 retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
733 retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
734 retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
735 retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
736 retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
737 retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
738 retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
739}
740
741fn retain_react_health_findings_by_changed_files(
742 results: &mut AnalysisResults,
743 changed_files: &FxHashSet<PathBuf>,
744) {
745 let AnalysisResults {
746 prop_drilling_chains,
747 thin_wrappers,
748 duplicate_prop_shapes,
749 ..
750 } = results;
751
752 retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
753 retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
754 retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
755}
756
757fn retain_nextjs_findings_by_changed_files(
758 results: &mut AnalysisResults,
759 changed_files: &FxHashSet<PathBuf>,
760) {
761 let AnalysisResults {
762 route_collisions,
763 dynamic_segment_name_conflicts,
764 ..
765 } = results;
766
767 retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
768 retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
769 &c.conflict.path
770 });
771}
772
773fn retain_unlisted_dependencies_by_import_site(
774 dependencies: &mut Vec<UnlistedDependencyFinding>,
775 changed_files: &FxHashSet<PathBuf>,
776) {
777 dependencies.retain(|dependency| {
778 dependency
779 .dep
780 .imported_from
781 .iter()
782 .any(|site| contains_normalized(changed_files, &site.path))
783 });
784}
785
786fn retain_duplicate_exports_by_changed_locations(
787 duplicate_exports: &mut Vec<DuplicateExportFinding>,
788 changed_files: &FxHashSet<PathBuf>,
789) {
790 for duplicate in &mut *duplicate_exports {
791 duplicate
792 .export
793 .locations
794 .retain(|location| contains_normalized(changed_files, &location.path));
795 }
796 duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
797}
798
799fn retain_circular_dependencies_by_changed_file(
800 cycles: &mut Vec<CircularDependencyFinding>,
801 changed_files: &FxHashSet<PathBuf>,
802) {
803 cycles.retain(|cycle| {
804 cycle
805 .cycle
806 .files
807 .iter()
808 .any(|file| contains_normalized(changed_files, file))
809 });
810}
811
812fn retain_re_export_cycles_by_changed_file(
813 cycles: &mut Vec<ReExportCycleFinding>,
814 changed_files: &FxHashSet<PathBuf>,
815) {
816 cycles.retain(|cycle| {
817 cycle
818 .cycle
819 .files
820 .iter()
821 .any(|file| contains_normalized(changed_files, file))
822 });
823}
824
825fn retain_security_findings_by_changed_path(
826 findings: &mut Vec<SecurityFinding>,
827 changed_files: &FxHashSet<PathBuf>,
828) {
829 findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
830}
831
832fn retain_prop_drilling_chains_by_anchor(
833 chains: &mut Vec<PropDrillingChainFinding>,
834 changed_files: &FxHashSet<PathBuf>,
835) {
836 chains.retain(|chain| {
837 chain
838 .chain
839 .hops
840 .first()
841 .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
842 });
843}
844
845fn retain_duplicate_prop_shapes_by_anchor(
846 shapes: &mut Vec<DuplicatePropShapeFinding>,
847 changed_files: &FxHashSet<PathBuf>,
848) {
849 retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
850}
851
852fn retain_by_changed_path<T>(
853 items: &mut Vec<T>,
854 changed_files: &FxHashSet<PathBuf>,
855 path: impl Fn(&T) -> &Path,
856) {
857 items.retain(|item| contains_normalized(changed_files, path(item)));
858}
859
860fn security_finding_touches_changed_path(
861 finding: &SecurityFinding,
862 changed_files: &FxHashSet<PathBuf>,
863) -> bool {
864 contains_normalized(changed_files, &finding.path)
865 || finding
866 .trace
867 .iter()
868 .any(|hop| contains_normalized(changed_files, &hop.path))
869 || finding.reachability.as_ref().is_some_and(|reachability| {
870 reachability
871 .untrusted_source_trace
872 .iter()
873 .any(|hop| contains_normalized(changed_files, &hop.path))
874 })
875}
876
877fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
878 changed_files
879 .iter()
880 .map(|p| dunce::simplified(p).to_path_buf())
881 .collect()
882}
883
884fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
885 normalized.contains(dunce::simplified(path))
886}
887
888fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
889 contains_normalized(normalized, path)
890 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
891}
892
893#[expect(
895 clippy::implicit_hasher,
896 reason = "fallow standardizes on FxHashSet across the workspace"
897)]
898pub fn filter_duplication_by_changed_files(
899 report: &mut DuplicationReport,
900 changed_files: &FxHashSet<PathBuf>,
901 root: &Path,
902) {
903 let cf = normalize_changed_files_set(changed_files);
904 report.clone_groups.retain(|group| {
905 group
906 .instances
907 .iter()
908 .any(|instance| contains_normalized(&cf, &instance.file))
909 });
910 duplicates::refresh_clone_families(report, root);
911 report.stats = duplicates::recompute_stats(report);
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917 use fallow_types::{
918 duplicates::{CloneGroup, CloneInstance, DuplicationStats},
919 output_dead_code::{
920 EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
921 UnusedFileFinding,
922 },
923 results::{
924 DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
925 },
926 };
927
928 #[test]
929 fn validate_git_ref_rejects_option_like_ref() {
930 assert!(validate_git_ref("--upload-pack=evil").is_err());
931 assert!(validate_git_ref("-flag").is_err());
932 }
933
934 #[test]
935 fn validate_git_ref_allows_reflog_relative_date() {
936 assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
937 }
938
939 #[test]
940 fn git_command_clears_parent_git_environment() {
941 let command = git_command(Path::new("."), &["status"]);
942 let envs: Vec<_> = command.get_envs().collect();
943
944 for var in AMBIENT_GIT_ENV_VARS {
945 assert!(
946 envs.iter()
947 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
948 "{var} should be cleared from the command env",
949 );
950 }
951 }
952
953 #[test]
954 fn try_get_changed_files_not_a_repository() {
955 let temp = tempfile::tempdir().expect("tempdir");
956 let result = try_get_changed_files(temp.path(), "main");
957 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
958 }
959
960 #[cfg(unix)]
961 #[test]
962 fn changed_files_preserve_special_filenames() {
963 let repo = tempfile::tempdir().expect("tempdir");
964 for args in [
965 &["init", "--quiet"][..],
966 &["config", "user.email", "test@example.com"][..],
967 &["config", "user.name", "Test User"][..],
968 &["config", "commit.gpgsign", "false"][..],
969 ] {
970 run_git(repo.path(), args);
971 }
972 std::fs::write(repo.path().join("initial.ts"), "initial\n").expect("initial fixture");
973 run_git(repo.path(), &["add", "."]);
974 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
975 run_git(repo.path(), &["tag", "base"]);
976
977 let canonical_root = repo.path().canonicalize().expect("canonical repo");
978 let special_files = [
979 "src/line\nbreak.ts",
980 "src/space name.ts",
981 "src/quote\"name.ts",
982 "src/back\\slash.ts",
983 "src/unicode-λ.ts",
984 ]
985 .map(|path| canonical_root.join(path));
986 std::fs::create_dir_all(canonical_root.join("src")).expect("source dir");
987 for special in &special_files {
988 std::fs::write(special, "changed\n").expect("special fixture");
989 }
990
991 let changed = try_get_changed_files(repo.path(), "base").expect("changed files");
992 for special in special_files {
993 assert!(
994 changed.contains(&special),
995 "missing {special:?}: {changed:?}"
996 );
997 }
998 }
999
1000 #[cfg(windows)]
1001 #[test]
1002 fn git_path_bytes_use_windows_separators() {
1003 assert_eq!(
1004 git_path_from_bytes(b"src/nested/file.ts"),
1005 PathBuf::from(r"src\nested\file.ts")
1006 );
1007 }
1008
1009 #[test]
1010 fn changed_diff_covers_staged_unstaged_and_untracked_files() {
1011 let repo = tempfile::tempdir().expect("tempdir");
1012 for args in [
1013 &["init", "--quiet"][..],
1014 &["config", "user.email", "test@example.com"][..],
1015 &["config", "user.name", "Test User"][..],
1016 &["config", "commit.gpgsign", "false"][..],
1017 ] {
1018 run_git(repo.path(), args);
1019 }
1020 std::fs::write(repo.path().join("staged.ts"), "old\n").expect("staged fixture");
1021 std::fs::write(repo.path().join("unstaged.ts"), "old\n").expect("unstaged fixture");
1022 run_git(repo.path(), &["add", "."]);
1023 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1024 run_git(repo.path(), &["tag", "base"]);
1025
1026 std::fs::write(repo.path().join("committed.ts"), "committed\n").expect("committed fixture");
1027 run_git(repo.path(), &["add", "committed.ts"]);
1028 run_git(
1029 repo.path(),
1030 &["commit", "--quiet", "-m", "committed change"],
1031 );
1032
1033 std::fs::write(repo.path().join("staged.ts"), "staged\n").expect("staged edit");
1034 run_git(repo.path(), &["add", "staged.ts"]);
1035 std::fs::write(repo.path().join("unstaged.ts"), "unstaged\n").expect("unstaged edit");
1036 std::fs::write(repo.path().join("untracked.ts"), "untracked\n").expect("untracked edit");
1037
1038 let diff = try_get_changed_diff(repo.path(), "base").expect("complete changeset diff");
1039 let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1040
1041 assert!(diff.contains("b/committed.ts"), "{diff}");
1042 assert!(diff.contains("b/staged.ts"), "{diff}");
1043 assert!(diff.contains("b/unstaged.ts"), "{diff}");
1044 assert!(diff.contains("b/untracked.ts"), "{diff}");
1045 assert_eq!(index.hunk_count(), 4);
1046 assert_eq!(index.net_lines(), 2);
1047 }
1048
1049 fn run_git(root: &Path, args: &[&str]) {
1050 let output = spawn_output(&mut git_command(root, args)).expect("git command");
1051 assert!(
1052 output.status.success(),
1053 "git {args:?} failed: {}",
1054 String::from_utf8_lossy(&output.stderr)
1055 );
1056 }
1057
1058 #[test]
1059 fn untracked_path_arg_uses_forward_slashes() {
1060 assert_eq!(
1061 super::untracked_path_arg(Path::new("src\\nested\\b.ts")),
1062 "src/nested/b.ts"
1063 );
1064 assert_eq!(super::untracked_path_arg(Path::new("src/b.ts")), "src/b.ts");
1065 }
1066
1067 #[test]
1068 fn changed_files_error_describe_matches_core_contract() {
1069 assert_eq!(
1070 ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
1071 "invalid git ref: bad ref"
1072 );
1073 assert_eq!(
1074 ChangedFilesError::GitMissing("not found".to_string()).describe(),
1075 "failed to run git: not found"
1076 );
1077 assert_eq!(
1078 ChangedFilesError::NotARepository.describe(),
1079 "not a git repository"
1080 );
1081 assert!(
1082 ChangedFilesError::GitFailed("unknown revision main".to_string())
1083 .describe()
1084 .contains("fetch-depth: 0")
1085 );
1086 }
1087
1088 #[test]
1089 fn filter_results_keeps_only_changed_file_findings() {
1090 let mut results = AnalysisResults::default();
1091 results
1092 .unused_files
1093 .push(UnusedFileFinding::with_actions(UnusedFile {
1094 path: PathBuf::from("/repo/a.ts"),
1095 }));
1096 results
1097 .unused_files
1098 .push(UnusedFileFinding::with_actions(UnusedFile {
1099 path: PathBuf::from("/repo/b.ts"),
1100 }));
1101 results
1102 .unused_exports
1103 .push(UnusedExportFinding::with_actions(UnusedExport {
1104 path: PathBuf::from("/repo/a.ts"),
1105 export_name: "foo".to_owned(),
1106 is_type_only: false,
1107 line: 1,
1108 col: 0,
1109 span_start: 0,
1110 is_re_export: false,
1111 }));
1112
1113 let mut changed = FxHashSet::default();
1114 changed.insert(PathBuf::from("/repo/a.ts"));
1115
1116 filter_results_by_changed_files(&mut results, &changed);
1117
1118 assert_eq!(results.unused_files.len(), 1);
1119 assert_eq!(
1120 results.unused_files[0].file.path,
1121 PathBuf::from("/repo/a.ts")
1122 );
1123 assert_eq!(results.unused_exports.len(), 1);
1124 }
1125
1126 #[test]
1127 fn filter_results_preserves_graph_global_dependency_findings() {
1128 let mut results = AnalysisResults::default();
1129 results
1130 .unused_dependencies
1131 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1132 package_name: "lodash".to_owned(),
1133 location: DependencyLocation::Dependencies,
1134 path: PathBuf::from("/repo/package.json"),
1135 line: 3,
1136 used_in_workspaces: Vec::new(),
1137 }));
1138
1139 let changed = FxHashSet::default();
1140 filter_results_by_changed_files(&mut results, &changed);
1141
1142 assert_eq!(results.unused_dependencies.len(), 1);
1143 }
1144
1145 #[test]
1146 fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
1147 let mut results = AnalysisResults::default();
1148 results
1149 .empty_catalog_groups
1150 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1151 catalog_name: "legacy".to_owned(),
1152 path: PathBuf::from("pnpm-workspace.yaml"),
1153 line: 4,
1154 }));
1155
1156 let mut changed = FxHashSet::default();
1157 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1158
1159 filter_results_by_changed_files(&mut results, &changed);
1160
1161 assert_eq!(results.empty_catalog_groups.len(), 1);
1162 }
1163
1164 #[test]
1165 fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
1166 let mut report = DuplicationReport {
1167 clone_groups: vec![
1168 CloneGroup {
1169 instances: vec![
1170 CloneInstance {
1171 file: PathBuf::from("/repo/a.ts"),
1172 start_line: 1,
1173 end_line: 5,
1174 start_col: 0,
1175 end_col: 10,
1176 fragment: "code".to_owned(),
1177 },
1178 CloneInstance {
1179 file: PathBuf::from("/repo/b.ts"),
1180 start_line: 1,
1181 end_line: 5,
1182 start_col: 0,
1183 end_col: 10,
1184 fragment: "code".to_owned(),
1185 },
1186 ],
1187 token_count: 20,
1188 line_count: 5,
1189 similarity: None,
1190 },
1191 CloneGroup {
1192 instances: vec![
1193 CloneInstance {
1194 file: PathBuf::from("/repo/c.ts"),
1195 start_line: 1,
1196 end_line: 5,
1197 start_col: 0,
1198 end_col: 10,
1199 fragment: "other".to_owned(),
1200 },
1201 CloneInstance {
1202 file: PathBuf::from("/repo/d.ts"),
1203 start_line: 1,
1204 end_line: 5,
1205 start_col: 0,
1206 end_col: 10,
1207 fragment: "other".to_owned(),
1208 },
1209 ],
1210 token_count: 20,
1211 line_count: 5,
1212 similarity: None,
1213 },
1214 ],
1215 clone_families: Vec::new(),
1216 mirrored_directories: Vec::new(),
1217 stats: DuplicationStats {
1218 total_files: 4,
1219 files_with_clones: 4,
1220 total_lines: 100,
1221 duplicated_lines: 20,
1222 total_tokens: 200,
1223 duplicated_tokens: 80,
1224 clone_groups: 2,
1225 clone_families: 0,
1226 clone_instances: 4,
1227 duplication_percentage: 20.0,
1228 clone_groups_below_min_occurrences: 0,
1229 clone_groups_ignored: 0,
1230 near_candidates_skipped: 0,
1231 },
1232 };
1233
1234 let mut changed = FxHashSet::default();
1235 changed.insert(PathBuf::from("/repo/a.ts"));
1236
1237 filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1238
1239 assert_eq!(report.clone_groups.len(), 1);
1240 assert_eq!(report.stats.clone_groups, 1);
1241 assert_eq!(report.stats.clone_instances, 2);
1242 }
1243}