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 #[must_use]
57 pub const fn reason(&self) -> &'static str {
58 match self {
59 Self::InvalidRef(_) => "invalid-ref",
60 Self::GitMissing(_) => "git-missing",
61 Self::NotARepository => "not-a-repository",
62 Self::GitFailed(_) => "git-failed",
63 }
64 }
65
66 #[must_use]
77 pub fn changed_since_message(&self, git_ref: &str) -> String {
78 let cause = self
79 .describe()
80 .split_whitespace()
81 .collect::<Vec<_>>()
82 .join(" ");
83 format!(
84 "--changed-since '{git_ref}' was ignored because {cause}, so this report covers \
85 the whole project instead of the changed files. {}",
86 self.changed_since_remedy()
87 )
88 }
89
90 const fn changed_since_remedy(&self) -> &'static str {
92 match self {
93 Self::InvalidRef(_) => {
94 "Pass a ref git can resolve, such as a branch name or a commit sha."
95 }
96 Self::GitMissing(_) => {
97 "Install git and make it available on PATH, or drop --changed-since."
98 }
99 Self::NotARepository => {
100 "Run fallow from inside the repository, or drop --changed-since."
101 }
102 Self::GitFailed(_) => {
103 "Verify the ref exists in this repository, and check out with full history."
104 }
105 }
106 }
107}
108
109fn augment_git_failed(stderr: &str) -> String {
110 let lower = stderr.to_ascii_lowercase();
111 if lower.contains("not a valid object name")
112 || lower.contains("unknown revision")
113 || lower.contains("ambiguous argument")
114 {
115 format!(
116 "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
117 )
118 } else {
119 stderr.to_owned()
120 }
121}
122
123pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
125 let _ = SPAWN_HOOK.set(hook);
126}
127
128pub(crate) fn validate_git_ref(s: &str) -> Result<&str, String> {
130 if s.is_empty() {
131 return Err("git ref cannot be empty".to_string());
132 }
133 if s.starts_with('-') {
134 return Err("git ref cannot start with '-'".to_string());
135 }
136 let mut in_braces = false;
137 for c in s.chars() {
138 match c {
139 '{' => in_braces = true,
140 '}' => in_braces = false,
141 ':' | ' ' if in_braces => {}
142 c if c.is_ascii_alphanumeric()
143 || matches!(c, '.' | '_' | '-' | '/' | '~' | '^' | '@' | '{' | '}') => {}
144 _ => return Err(format!("git ref contains disallowed character: '{c}'")),
145 }
146 }
147 if in_braces {
148 return Err("git ref has unclosed '{'".to_string());
149 }
150 Ok(s)
151}
152
153pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
155 let output = spawn_output(&mut git_command(cwd, &["rev-parse", "--show-toplevel"]))
156 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
157
158 if !output.status.success() {
159 let stderr = String::from_utf8_lossy(&output.stderr);
160 return Err(if stderr.contains("not a git repository") {
161 ChangedFilesError::NotARepository
162 } else {
163 ChangedFilesError::GitFailed(stderr.trim().to_owned())
164 });
165 }
166
167 let raw = String::from_utf8_lossy(&output.stdout);
168 let trimmed = raw.trim();
169 if trimmed.is_empty() {
170 return Err(ChangedFilesError::GitFailed(
171 "git rev-parse --show-toplevel returned empty output".to_owned(),
172 ));
173 }
174
175 let path = PathBuf::from(trimmed);
176 Ok(dunce::canonicalize(&path).unwrap_or(path))
177}
178
179pub fn resolve_git_common_dir(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
181 let output = spawn_output(&mut git_command(
182 cwd,
183 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
184 ))
185 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
186
187 if !output.status.success() {
188 let stderr = String::from_utf8_lossy(&output.stderr);
189 return Err(if stderr.contains("not a git repository") {
190 ChangedFilesError::NotARepository
191 } else {
192 ChangedFilesError::GitFailed(stderr.trim().to_owned())
193 });
194 }
195
196 let raw = String::from_utf8_lossy(&output.stdout);
197 let trimmed = raw.trim();
198 if trimmed.is_empty() {
199 return Err(ChangedFilesError::GitFailed(
200 "git rev-parse --git-common-dir returned empty output".to_owned(),
201 ));
202 }
203
204 let path = PathBuf::from(trimmed);
205 Ok(dunce::canonicalize(&path).unwrap_or(path))
206}
207
208fn try_get_changed_files(
210 root: &Path,
211 git_ref: &str,
212) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
213 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
214 let toplevel = resolve_git_toplevel(root)?;
215 try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
216}
217
218pub fn changed_files(root: &Path, git_ref: &str) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
224 try_get_changed_files(root, git_ref)
225}
226
227pub fn try_get_changed_files_with_toplevel(
229 cwd: &Path,
230 toplevel: &Path,
231 git_ref: &str,
232) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
233 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
234
235 let mut files = collect_git_paths(
236 cwd,
237 toplevel,
238 &[
239 "diff",
240 "--name-only",
241 "-z",
242 "--end-of-options",
243 &format!("{git_ref}...HEAD"),
244 ],
245 )?;
246 files.extend(collect_git_paths(
247 cwd,
248 toplevel,
249 &["diff", "--name-only", "-z", "HEAD"],
250 )?);
251 files.extend(collect_git_paths(
252 cwd,
253 toplevel,
254 &[
255 "ls-files",
256 "--full-name",
257 "--others",
258 "--exclude-standard",
259 "-z",
260 ],
261 )?);
262 Ok(files)
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct RenamedFile {
269 pub from: PathBuf,
271 pub to: PathBuf,
273}
274
275pub fn try_get_renamed_files(
287 root: &Path,
288 git_ref: &str,
289) -> Result<Vec<RenamedFile>, ChangedFilesError> {
290 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
291 let toplevel = resolve_git_toplevel(root)?;
292 let mut renames = collect_git_rename_pairs(
293 root,
294 &toplevel,
295 &[
296 "diff",
297 "--name-status",
298 "-z",
299 "--find-renames",
300 "--end-of-options",
301 &format!("{git_ref}...HEAD"),
302 ],
303 )?;
304 let staged = collect_git_rename_pairs(
305 root,
306 &toplevel,
307 &["diff", "--name-status", "-z", "--find-renames", "HEAD"],
308 )?;
309 for pair in staged {
310 if let Some(chained) = renames.iter_mut().find(|rename| rename.to == pair.from) {
311 chained.to = pair.to;
312 } else {
313 renames.push(pair);
314 }
315 }
316 Ok(renames)
317}
318
319fn collect_git_rename_pairs(
325 cwd: &Path,
326 toplevel: &Path,
327 args: &[&str],
328) -> Result<Vec<RenamedFile>, ChangedFilesError> {
329 let output = spawn_output(&mut git_command(cwd, args))
330 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
331
332 if !output.status.success() {
333 return Err(changed_files_error_from_output(&output));
334 }
335
336 let mut fields = output
337 .stdout
338 .split(|byte| *byte == 0)
339 .filter(|field| !field.is_empty());
340 let mut renames = Vec::new();
341 while let Some(status) = fields.next() {
342 let Some(first_path) = fields.next() else {
343 break;
344 };
345 match status.first() {
346 Some(b'R') => {
347 let Some(second_path) = fields.next() else {
348 break;
349 };
350 renames.push(RenamedFile {
351 from: toplevel.join(git_path_from_bytes(first_path)),
352 to: toplevel.join(git_path_from_bytes(second_path)),
353 });
354 }
355 Some(b'C') => {
358 let _ = fields.next();
359 }
360 _ => {}
361 }
362 }
363 Ok(renames)
364}
365
366pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
371 validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
372 let toplevel = resolve_git_toplevel(root)?;
373 let merge_base_output = spawn_output(&mut git_command(root, &["merge-base", git_ref, "HEAD"]))
374 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
375 if !merge_base_output.status.success() {
376 return Err(changed_files_error_from_output(&merge_base_output));
377 }
378 let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
379 .trim()
380 .to_owned();
381 if merge_base.is_empty() {
382 return Err(ChangedFilesError::GitFailed(
383 "git merge-base returned empty output".to_owned(),
384 ));
385 }
386
387 let output = spawn_output(&mut git_command(
388 root,
389 &[
390 "diff",
391 "--relative",
392 "--unified=0",
393 "--end-of-options",
394 &merge_base,
395 ],
396 ))
397 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
398
399 if !output.status.success() {
400 return Err(changed_files_error_from_output(&output));
401 }
402
403 let mut diff = String::from_utf8_lossy(&output.stdout).into_owned();
404 append_untracked_diffs(root, &toplevel, &mut diff)?;
405 Ok(diff)
406}
407
408fn append_untracked_diffs(
409 root: &Path,
410 toplevel: &Path,
411 diff: &mut String,
412) -> Result<(), ChangedFilesError> {
413 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
414 let mut untracked: Vec<PathBuf> = collect_git_paths(
415 root,
416 toplevel,
417 &[
418 "ls-files",
419 "--full-name",
420 "--others",
421 "--exclude-standard",
422 "-z",
423 ],
424 )?
425 .into_iter()
426 .filter_map(|path| {
427 path.strip_prefix(&canonical_root)
428 .ok()
429 .map(Path::to_path_buf)
430 })
431 .collect();
432 untracked.sort_unstable();
433
434 #[cfg(windows)]
435 let empty_file = "NUL";
436 #[cfg(not(windows))]
437 let empty_file = "/dev/null";
438
439 for path in untracked {
440 let mut command = git_command(root, &["diff", "--no-index", "--unified=0", "--"]);
441 command.arg(empty_file).arg(untracked_path_arg(&path));
442 let output =
443 spawn_output(&mut command).map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
444 if !output.status.success() && output.status.code() != Some(1) {
445 return Err(changed_files_error_from_output(&output));
446 }
447 if !diff.is_empty() && !diff.ends_with('\n') {
448 diff.push('\n');
449 }
450 diff.push_str(&String::from_utf8_lossy(&output.stdout));
451 }
452 Ok(())
453}
454
455fn untracked_path_arg(path: &Path) -> String {
460 path.to_string_lossy().replace('\\', "/")
461}
462
463fn changed_files_error_from_output(output: &Output) -> ChangedFilesError {
464 let stderr = String::from_utf8_lossy(&output.stderr);
465 if stderr.contains("not a git repository") {
466 ChangedFilesError::NotARepository
467 } else {
468 ChangedFilesError::GitFailed(stderr.trim().to_owned())
469 }
470}
471
472#[must_use]
474#[expect(
475 clippy::print_stderr,
476 reason = "intentional user-facing warning for the CLI's --changed-since fallback path; typed callers use try_get_changed_files instead"
477)]
478pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
479 match try_get_changed_files(root, git_ref) {
480 Ok(files) => Some(files),
481 Err(err) => {
482 eprintln!("Warning: {}", err.changed_since_message(git_ref));
483 None
484 }
485 }
486}
487
488fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
489 if let Some(hook) = SPAWN_HOOK.get() {
490 hook(command)
491 } else {
492 command.output()
493 }
494}
495
496fn collect_git_paths(
497 cwd: &Path,
498 toplevel: &Path,
499 args: &[&str],
500) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
501 let output = spawn_output(&mut git_command(cwd, args))
502 .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
503
504 if !output.status.success() {
505 let stderr = String::from_utf8_lossy(&output.stderr);
506 return Err(if stderr.contains("not a git repository") {
507 ChangedFilesError::NotARepository
508 } else {
509 ChangedFilesError::GitFailed(stderr.trim().to_owned())
510 });
511 }
512
513 let files = output
514 .stdout
515 .split(|byte| *byte == 0)
516 .filter(|path| !path.is_empty())
517 .map(git_path_from_bytes)
518 .map(|path| toplevel.join(path))
519 .collect();
520
521 Ok(files)
522}
523
524#[cfg(unix)]
530pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
531 use std::ffi::OsString;
532 use std::os::unix::ffi::OsStringExt;
533
534 PathBuf::from(OsString::from_vec(path.to_vec()))
535}
536
537#[cfg(windows)]
540pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
541 PathBuf::from(String::from_utf8_lossy(path).replace('/', "\\"))
542}
543
544#[expect(
545 clippy::disallowed_methods,
546 reason = "canonical engine-owned git spawn wrapper for changed-file orchestration"
547)]
548fn git_command(cwd: &Path, args: &[&str]) -> Command {
549 let mut command = Command::new("git");
550 clear_ambient_git_env(&mut command);
551 command.stdin(Stdio::null()).args(args).current_dir(cwd);
553 command
554}
555
556#[expect(
561 clippy::implicit_hasher,
562 reason = "fallow standardizes on FxHashSet across the workspace"
563)]
564pub fn filter_results_by_changed_files(
565 results: &mut AnalysisResults,
566 changed_files: &FxHashSet<PathBuf>,
567) {
568 let cf = normalize_changed_files_set(changed_files);
569 classify_changed_file_filter_fields(results);
570 retain_basic_issue_findings_by_changed_path(results, &cf);
571 retain_graph_findings_by_changed_files(results, &cf);
572 retain_boundary_policy_and_suppression_findings(results, &cf);
573 retain_security_and_workspace_findings(results, &cf);
574 retain_framework_findings_by_changed_files(results, &cf);
575}
576
577fn classify_changed_file_filter_fields(results: &AnalysisResults) {
578 let AnalysisResults {
579 unused_files: _unused_files,
580 unused_exports: _unused_exports,
581 unused_types: _unused_types,
582 private_type_leaks: _private_type_leaks,
583 deprecated_exports_in_use: _deprecated_exports_in_use,
584 unused_dependencies: _unused_dependencies,
585 unused_dev_dependencies: _unused_dev_dependencies,
586 unused_optional_dependencies: _unused_optional_dependencies,
587 unused_enum_members: _unused_enum_members,
588 unused_class_members: _unused_class_members,
589 unused_store_members: _unused_store_members,
590 unresolved_imports: _unresolved_imports,
591 unlisted_dependencies: _unlisted_dependencies,
592 duplicate_exports: _duplicate_exports,
593 type_only_dependencies: _type_only_dependencies,
594 test_only_dependencies: _test_only_dependencies,
595 dev_dependencies_in_production: _dev_dependencies_in_production,
596 circular_dependencies: _circular_dependencies,
597 re_export_cycles: _re_export_cycles,
598 boundary_violations: _boundary_violations,
599 boundary_coverage_violations: _boundary_coverage_violations,
600 boundary_call_violations: _boundary_call_violations,
601 policy_violations: _policy_violations,
602 stale_suppressions: _stale_suppressions,
603 unused_catalog_entries: _unused_catalog_entries,
604 empty_catalog_groups: _empty_catalog_groups,
605 unresolved_catalog_references: _unresolved_catalog_references,
606 unused_dependency_overrides: _unused_dependency_overrides,
607 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
608 invalid_client_exports: _invalid_client_exports,
609 mixed_client_server_barrels: _mixed_client_server_barrels,
610 misplaced_directives: _misplaced_directives,
611 unprovided_injects: _unprovided_injects,
612 unrendered_components: _unrendered_components,
613 route_collisions: _route_collisions,
614 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
615 unused_component_props: _unused_component_props,
616 unused_component_emits: _unused_component_emits,
617 unused_component_inputs: _unused_component_inputs,
618 unused_component_outputs: _unused_component_outputs,
619 unused_svelte_events: _unused_svelte_events,
620 unused_server_actions: _unused_server_actions,
621 unused_load_data_keys: _unused_load_data_keys,
622 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
623 prop_drilling_chains: _prop_drilling_chains,
624 thin_wrappers: _thin_wrappers,
625 duplicate_prop_shapes: _duplicate_prop_shapes,
626 suppression_count: _suppression_count,
627 unused_component_props_exempted: _unused_component_props_exempted,
628 active_suppressions: _active_suppressions,
629 feature_flags: _feature_flags,
630 security_findings: _security_findings,
631 security_unresolved_edge_files: _security_unresolved_edge_files,
632 security_unresolved_callee_sites: _security_unresolved_callee_sites,
633 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
634 export_usages: _export_usages,
635 entry_point_summary: _entry_point_summary,
636 render_fan_in: _render_fan_in,
637 react_component_intel: _react_component_intel,
638 semantic_framework_contracts: _semantic_framework_contracts,
639 } = results;
640}
641
642fn retain_basic_issue_findings_by_changed_path(
643 results: &mut AnalysisResults,
644 changed_files: &FxHashSet<PathBuf>,
645) {
646 retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
647 retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
648 &e.export.path
649 });
650 retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
651 retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
652 &e.leak.path
653 });
654 retain_by_changed_path(&mut results.deprecated_exports_in_use, changed_files, |e| {
655 &e.export.path
656 });
657 retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
658 &m.member.path
659 });
660 retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
661 &m.member.path
662 });
663 retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
664 &m.member.path
665 });
666 retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
667 &i.import.path
668 });
669}
670
671fn retain_graph_findings_by_changed_files(
672 results: &mut AnalysisResults,
673 changed_files: &FxHashSet<PathBuf>,
674) {
675 retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
676 retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
677 retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
678 retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
679}
680
681fn retain_boundary_policy_and_suppression_findings(
682 results: &mut AnalysisResults,
683 changed_files: &FxHashSet<PathBuf>,
684) {
685 retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
686 &v.violation.from_path
687 });
688 retain_by_changed_path(
689 &mut results.boundary_coverage_violations,
690 changed_files,
691 |v| &v.violation.path,
692 );
693 retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
694 &v.violation.path
695 });
696 retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
697 &v.violation.path
698 });
699 retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
700}
701
702fn retain_security_and_workspace_findings(
703 results: &mut AnalysisResults,
704 changed_files: &FxHashSet<PathBuf>,
705) {
706 retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
707 retain_by_changed_path(
708 &mut results.security_unresolved_callee_diagnostics,
709 changed_files,
710 |d| &d.path,
711 );
712 retain_by_changed_path(
713 &mut results.unresolved_catalog_references,
714 changed_files,
715 |r| &r.reference.path,
716 );
717 results
718 .empty_catalog_groups
719 .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
720 retain_by_changed_path(
721 &mut results.unused_dependency_overrides,
722 changed_files,
723 |o| &o.entry.path,
724 );
725 retain_by_changed_path(
726 &mut results.misconfigured_dependency_overrides,
727 changed_files,
728 |o| &o.entry.path,
729 );
730}
731
732fn retain_framework_findings_by_changed_files(
733 results: &mut AnalysisResults,
734 changed_files: &FxHashSet<PathBuf>,
735) {
736 retain_client_boundary_findings_by_changed_files(results, changed_files);
737 retain_component_contract_findings_by_changed_files(results, changed_files);
738 retain_react_health_findings_by_changed_files(results, changed_files);
739 retain_nextjs_findings_by_changed_files(results, changed_files);
740}
741
742fn retain_client_boundary_findings_by_changed_files(
743 results: &mut AnalysisResults,
744 changed_files: &FxHashSet<PathBuf>,
745) {
746 let AnalysisResults {
747 invalid_client_exports,
748 mixed_client_server_barrels,
749 misplaced_directives,
750 ..
751 } = results;
752
753 retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
754 retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
755 &b.barrel.path
756 });
757 retain_by_changed_path(misplaced_directives, changed_files, |d| {
758 &d.directive_site.path
759 });
760}
761
762fn retain_component_contract_findings_by_changed_files(
763 results: &mut AnalysisResults,
764 changed_files: &FxHashSet<PathBuf>,
765) {
766 let AnalysisResults {
767 unprovided_injects,
768 unrendered_components,
769 unused_component_props,
770 unused_component_emits,
771 unused_component_inputs,
772 unused_component_outputs,
773 unused_svelte_events,
774 unused_server_actions,
775 unused_load_data_keys,
776 ..
777 } = results;
778
779 retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
780 retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
781 retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
782 retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
783 retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
784 retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
785 retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
786 retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
787 retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
788}
789
790fn retain_react_health_findings_by_changed_files(
791 results: &mut AnalysisResults,
792 changed_files: &FxHashSet<PathBuf>,
793) {
794 let AnalysisResults {
795 prop_drilling_chains,
796 thin_wrappers,
797 duplicate_prop_shapes,
798 ..
799 } = results;
800
801 retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
802 retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
803 retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
804}
805
806fn retain_nextjs_findings_by_changed_files(
807 results: &mut AnalysisResults,
808 changed_files: &FxHashSet<PathBuf>,
809) {
810 let AnalysisResults {
811 route_collisions,
812 dynamic_segment_name_conflicts,
813 ..
814 } = results;
815
816 retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
817 retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
818 &c.conflict.path
819 });
820}
821
822fn retain_unlisted_dependencies_by_import_site(
823 dependencies: &mut Vec<UnlistedDependencyFinding>,
824 changed_files: &FxHashSet<PathBuf>,
825) {
826 dependencies.retain(|dependency| {
827 dependency
828 .dep
829 .imported_from
830 .iter()
831 .any(|site| contains_normalized(changed_files, &site.path))
832 });
833}
834
835fn retain_duplicate_exports_by_changed_locations(
836 duplicate_exports: &mut Vec<DuplicateExportFinding>,
837 changed_files: &FxHashSet<PathBuf>,
838) {
839 for duplicate in &mut *duplicate_exports {
840 duplicate
841 .export
842 .locations
843 .retain(|location| contains_normalized(changed_files, &location.path));
844 }
845 duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
846}
847
848fn retain_circular_dependencies_by_changed_file(
849 cycles: &mut Vec<CircularDependencyFinding>,
850 changed_files: &FxHashSet<PathBuf>,
851) {
852 cycles.retain(|cycle| {
853 cycle
854 .cycle
855 .files
856 .iter()
857 .any(|file| contains_normalized(changed_files, file))
858 });
859}
860
861fn retain_re_export_cycles_by_changed_file(
862 cycles: &mut Vec<ReExportCycleFinding>,
863 changed_files: &FxHashSet<PathBuf>,
864) {
865 cycles.retain(|cycle| {
866 cycle
867 .cycle
868 .files
869 .iter()
870 .any(|file| contains_normalized(changed_files, file))
871 });
872}
873
874fn retain_security_findings_by_changed_path(
875 findings: &mut Vec<SecurityFinding>,
876 changed_files: &FxHashSet<PathBuf>,
877) {
878 findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
879}
880
881fn retain_prop_drilling_chains_by_anchor(
882 chains: &mut Vec<PropDrillingChainFinding>,
883 changed_files: &FxHashSet<PathBuf>,
884) {
885 chains.retain(|chain| {
886 chain
887 .chain
888 .hops
889 .first()
890 .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
891 });
892}
893
894fn retain_duplicate_prop_shapes_by_anchor(
895 shapes: &mut Vec<DuplicatePropShapeFinding>,
896 changed_files: &FxHashSet<PathBuf>,
897) {
898 retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
899}
900
901fn retain_by_changed_path<T>(
902 items: &mut Vec<T>,
903 changed_files: &FxHashSet<PathBuf>,
904 path: impl Fn(&T) -> &Path,
905) {
906 items.retain(|item| contains_normalized(changed_files, path(item)));
907}
908
909fn security_finding_touches_changed_path(
910 finding: &SecurityFinding,
911 changed_files: &FxHashSet<PathBuf>,
912) -> bool {
913 contains_normalized(changed_files, &finding.path)
914 || finding
915 .trace
916 .iter()
917 .any(|hop| contains_normalized(changed_files, &hop.path))
918 || finding.reachability.as_ref().is_some_and(|reachability| {
919 reachability
920 .untrusted_source_trace
921 .iter()
922 .any(|hop| contains_normalized(changed_files, &hop.path))
923 })
924}
925
926fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
927 changed_files
928 .iter()
929 .map(|p| dunce::simplified(p).to_path_buf())
930 .collect()
931}
932
933fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
934 normalized.contains(dunce::simplified(path))
935}
936
937fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
938 contains_normalized(normalized, path)
939 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
940}
941
942#[expect(
944 clippy::implicit_hasher,
945 reason = "fallow standardizes on FxHashSet across the workspace"
946)]
947pub fn filter_duplication_by_changed_files(
948 report: &mut DuplicationReport,
949 changed_files: &FxHashSet<PathBuf>,
950 root: &Path,
951) {
952 let cf = normalize_changed_files_set(changed_files);
953 report.clone_groups.retain(|group| {
954 group
955 .instances
956 .iter()
957 .any(|instance| contains_normalized(&cf, &instance.file))
958 });
959 duplicates::refresh_clone_families(report, root);
960 report.stats = duplicates::recompute_stats(report);
961}
962
963#[cfg(test)]
964mod tests {
965 use super::*;
966 use fallow_types::{
967 duplicates::{CloneGroup, CloneInstance, DuplicationStats},
968 output_dead_code::{
969 EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
970 UnusedFileFinding,
971 },
972 results::{
973 DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
974 },
975 };
976
977 #[test]
980 fn every_changed_files_cause_reports_its_own_reason() {
981 let reasons = [
982 ChangedFilesError::InvalidRef("unclosed brace".to_owned()).reason(),
983 ChangedFilesError::GitMissing("no such file".to_owned()).reason(),
984 ChangedFilesError::NotARepository.reason(),
985 ChangedFilesError::GitFailed("unknown revision".to_owned()).reason(),
986 ];
987 assert_eq!(
988 reasons,
989 [
990 "invalid-ref",
991 "git-missing",
992 "not-a-repository",
993 "git-failed"
994 ]
995 );
996 let unique: std::collections::BTreeSet<&str> = reasons.iter().copied().collect();
997 assert_eq!(
998 unique.len(),
999 reasons.len(),
1000 "two causes must not share a token"
1001 );
1002 }
1003
1004 #[test]
1009 fn the_changed_since_message_names_the_ref_the_widening_and_the_next_step() {
1010 let message = ChangedFilesError::NotARepository.changed_since_message("origin/main");
1011 assert!(
1012 message.contains("--changed-since 'origin/main'"),
1013 "{message}"
1014 );
1015 assert!(message.contains("covers the whole project"), "{message}");
1016 assert!(message.ends_with("or drop --changed-since."), "{message}");
1017 }
1018
1019 #[test]
1023 fn the_changed_since_message_folds_git_stderr_onto_one_line() {
1024 let message = ChangedFilesError::GitFailed(
1025 "fatal: ambiguous argument 'x'\nUse '--' to separate paths".to_owned(),
1026 )
1027 .changed_since_message("x");
1028 assert!(!message.contains('\n'), "{message}");
1029 assert!(
1030 message.contains("ambiguous argument 'x' Use '--'"),
1031 "{message}"
1032 );
1033 }
1034
1035 #[test]
1036 fn git_command_clears_parent_git_environment() {
1037 let command = git_command(Path::new("."), &["status"]);
1038 let envs: Vec<_> = command.get_envs().collect();
1039
1040 for var in AMBIENT_GIT_ENV_VARS {
1041 assert!(
1042 envs.iter()
1043 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
1044 "{var} should be cleared from the command env",
1045 );
1046 }
1047 }
1048
1049 #[test]
1050 fn try_get_changed_files_not_a_repository() {
1051 let temp = tempfile::tempdir().expect("tempdir");
1052 let result = try_get_changed_files(temp.path(), "main");
1053 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1054 }
1055
1056 #[cfg(unix)]
1057 #[test]
1058 fn changed_files_preserve_special_filenames() {
1059 let repo = tempfile::tempdir().expect("tempdir");
1060 for args in [
1061 &["init", "--quiet"][..],
1062 &["config", "user.email", "test@example.com"][..],
1063 &["config", "user.name", "Test User"][..],
1064 &["config", "commit.gpgsign", "false"][..],
1065 ] {
1066 run_git(repo.path(), args);
1067 }
1068 std::fs::write(repo.path().join("initial.ts"), "initial\n").expect("initial fixture");
1069 run_git(repo.path(), &["add", "."]);
1070 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1071 run_git(repo.path(), &["tag", "base"]);
1072
1073 let canonical_root = repo.path().canonicalize().expect("canonical repo");
1074 let special_files = [
1075 "src/line\nbreak.ts",
1076 "src/space name.ts",
1077 "src/quote\"name.ts",
1078 "src/back\\slash.ts",
1079 "src/unicode-λ.ts",
1080 ]
1081 .map(|path| canonical_root.join(path));
1082 std::fs::create_dir_all(canonical_root.join("src")).expect("source dir");
1083 for special in &special_files {
1084 std::fs::write(special, "changed\n").expect("special fixture");
1085 }
1086
1087 let changed = try_get_changed_files(repo.path(), "base").expect("changed files");
1088 for special in special_files {
1089 assert!(
1090 changed.contains(&special),
1091 "missing {special:?}: {changed:?}"
1092 );
1093 }
1094 }
1095
1096 #[cfg(windows)]
1097 #[test]
1098 fn git_path_bytes_use_windows_separators() {
1099 assert_eq!(
1100 git_path_from_bytes(b"src/nested/file.ts"),
1101 PathBuf::from(r"src\nested\file.ts")
1102 );
1103 }
1104
1105 #[test]
1106 fn changed_diff_covers_staged_unstaged_and_untracked_files() {
1107 let repo = tempfile::tempdir().expect("tempdir");
1108 for args in [
1109 &["init", "--quiet"][..],
1110 &["config", "user.email", "test@example.com"][..],
1111 &["config", "user.name", "Test User"][..],
1112 &["config", "commit.gpgsign", "false"][..],
1113 ] {
1114 run_git(repo.path(), args);
1115 }
1116 std::fs::write(repo.path().join("staged.ts"), "old\n").expect("staged fixture");
1117 std::fs::write(repo.path().join("unstaged.ts"), "old\n").expect("unstaged fixture");
1118 run_git(repo.path(), &["add", "."]);
1119 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1120 run_git(repo.path(), &["tag", "base"]);
1121
1122 std::fs::write(repo.path().join("committed.ts"), "committed\n").expect("committed fixture");
1123 run_git(repo.path(), &["add", "committed.ts"]);
1124 run_git(
1125 repo.path(),
1126 &["commit", "--quiet", "-m", "committed change"],
1127 );
1128
1129 std::fs::write(repo.path().join("staged.ts"), "staged\n").expect("staged edit");
1130 run_git(repo.path(), &["add", "staged.ts"]);
1131 std::fs::write(repo.path().join("unstaged.ts"), "unstaged\n").expect("unstaged edit");
1132 std::fs::write(repo.path().join("untracked.ts"), "untracked\n").expect("untracked edit");
1133
1134 let diff = try_get_changed_diff(repo.path(), "base").expect("complete changeset diff");
1135 let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1136
1137 assert!(diff.contains("b/committed.ts"), "{diff}");
1138 assert!(diff.contains("b/staged.ts"), "{diff}");
1139 assert!(diff.contains("b/unstaged.ts"), "{diff}");
1140 assert!(diff.contains("b/untracked.ts"), "{diff}");
1141 assert_eq!(index.hunk_count(), 4);
1142 assert_eq!(index.net_lines(), 2);
1143 }
1144
1145 fn run_git(root: &Path, args: &[&str]) {
1146 let output = spawn_output(&mut git_command(root, args)).expect("git command");
1147 assert!(
1148 output.status.success(),
1149 "git {args:?} failed: {}",
1150 String::from_utf8_lossy(&output.stderr)
1151 );
1152 }
1153
1154 #[test]
1155 fn untracked_path_arg_uses_forward_slashes() {
1156 assert_eq!(
1157 super::untracked_path_arg(Path::new("src\\nested\\b.ts")),
1158 "src/nested/b.ts"
1159 );
1160 assert_eq!(super::untracked_path_arg(Path::new("src/b.ts")), "src/b.ts");
1161 }
1162
1163 #[test]
1164 fn changed_files_error_describe_matches_core_contract() {
1165 assert_eq!(
1166 ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
1167 "invalid git ref: bad ref"
1168 );
1169 assert_eq!(
1170 ChangedFilesError::GitMissing("not found".to_string()).describe(),
1171 "failed to run git: not found"
1172 );
1173 assert_eq!(
1174 ChangedFilesError::NotARepository.describe(),
1175 "not a git repository"
1176 );
1177 assert!(
1178 ChangedFilesError::GitFailed("unknown revision main".to_string())
1179 .describe()
1180 .contains("fetch-depth: 0")
1181 );
1182 }
1183
1184 #[test]
1185 fn filter_results_keeps_only_changed_file_findings() {
1186 let mut results = AnalysisResults::default();
1187 results
1188 .unused_files
1189 .push(UnusedFileFinding::with_actions(UnusedFile {
1190 path: PathBuf::from("/repo/a.ts"),
1191 }));
1192 results
1193 .unused_files
1194 .push(UnusedFileFinding::with_actions(UnusedFile {
1195 path: PathBuf::from("/repo/b.ts"),
1196 }));
1197 results
1198 .unused_exports
1199 .push(UnusedExportFinding::with_actions(UnusedExport {
1200 path: PathBuf::from("/repo/a.ts"),
1201 export_name: "foo".to_owned(),
1202 is_type_only: false,
1203 line: 1,
1204 col: 0,
1205 span_start: 0,
1206 is_re_export: false,
1207 deprecated: false,
1208 deprecated_reason: None,
1209 }));
1210
1211 let mut changed = FxHashSet::default();
1212 changed.insert(PathBuf::from("/repo/a.ts"));
1213
1214 filter_results_by_changed_files(&mut results, &changed);
1215
1216 assert_eq!(results.unused_files.len(), 1);
1217 assert_eq!(
1218 results.unused_files[0].file.path,
1219 PathBuf::from("/repo/a.ts")
1220 );
1221 assert_eq!(results.unused_exports.len(), 1);
1222 }
1223
1224 #[test]
1225 fn filter_results_preserves_graph_global_dependency_findings() {
1226 let mut results = AnalysisResults::default();
1227 results
1228 .unused_dependencies
1229 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1230 package_name: "lodash".to_owned(),
1231 location: DependencyLocation::Dependencies,
1232 path: PathBuf::from("/repo/package.json"),
1233 line: 3,
1234 used_in_workspaces: Vec::new(),
1235 }));
1236
1237 let changed = FxHashSet::default();
1238 filter_results_by_changed_files(&mut results, &changed);
1239
1240 assert_eq!(results.unused_dependencies.len(), 1);
1241 }
1242
1243 #[test]
1247 fn filter_results_keeps_deprecated_export_only_when_its_declaring_file_changed() {
1248 let finding = |path: &str| {
1249 fallow_types::output_dead_code::DeprecatedExportInUseFinding::with_actions(
1250 fallow_types::results::DeprecatedExportInUse {
1251 path: PathBuf::from(path),
1252 export_name: "old".to_owned(),
1253 is_type_only: false,
1254 line: 2,
1255 col: 0,
1256 span_start: 0,
1257 deprecated_reason: None,
1258 consumer_count: 1,
1259 consumers: vec![fallow_types::results::DeprecatedExportConsumer {
1260 path: PathBuf::from("/repo/consumer.ts"),
1261 line: 1,
1262 col: 0,
1263 kind: fallow_types::results::DeprecatedConsumerKind::NamedImport,
1264 }],
1265 public_api: false,
1266 },
1267 )
1268 };
1269 let mut results = AnalysisResults::default();
1270 results
1271 .deprecated_exports_in_use
1272 .push(finding("/repo/a.ts"));
1273 results
1274 .deprecated_exports_in_use
1275 .push(finding("/repo/b.ts"));
1276
1277 let mut changed = FxHashSet::default();
1278 changed.insert(PathBuf::from("/repo/a.ts"));
1279 changed.insert(PathBuf::from("/repo/consumer.ts"));
1280 filter_results_by_changed_files(&mut results, &changed);
1281
1282 let kept: Vec<_> = results
1283 .deprecated_exports_in_use
1284 .iter()
1285 .map(|f| f.export.path.clone())
1286 .collect();
1287 assert_eq!(kept, vec![PathBuf::from("/repo/a.ts")]);
1288 }
1289
1290 #[test]
1291 fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
1292 let mut results = AnalysisResults::default();
1293 results
1294 .empty_catalog_groups
1295 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1296 catalog_name: "legacy".to_owned(),
1297 path: PathBuf::from("pnpm-workspace.yaml"),
1298 line: 4,
1299 }));
1300
1301 let mut changed = FxHashSet::default();
1302 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1303
1304 filter_results_by_changed_files(&mut results, &changed);
1305
1306 assert_eq!(results.empty_catalog_groups.len(), 1);
1307 }
1308
1309 #[test]
1310 fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
1311 let mut report = DuplicationReport {
1312 clone_groups: vec![
1313 CloneGroup {
1314 instances: vec![
1315 CloneInstance {
1316 file: PathBuf::from("/repo/a.ts"),
1317 start_line: 1,
1318 end_line: 5,
1319 start_col: 0,
1320 end_col: 10,
1321 fragment: "code".to_owned(),
1322 },
1323 CloneInstance {
1324 file: PathBuf::from("/repo/b.ts"),
1325 start_line: 1,
1326 end_line: 5,
1327 start_col: 0,
1328 end_col: 10,
1329 fragment: "code".to_owned(),
1330 },
1331 ],
1332 token_count: 20,
1333 line_count: 5,
1334 similarity: None,
1335 },
1336 CloneGroup {
1337 instances: vec![
1338 CloneInstance {
1339 file: PathBuf::from("/repo/c.ts"),
1340 start_line: 1,
1341 end_line: 5,
1342 start_col: 0,
1343 end_col: 10,
1344 fragment: "other".to_owned(),
1345 },
1346 CloneInstance {
1347 file: PathBuf::from("/repo/d.ts"),
1348 start_line: 1,
1349 end_line: 5,
1350 start_col: 0,
1351 end_col: 10,
1352 fragment: "other".to_owned(),
1353 },
1354 ],
1355 token_count: 20,
1356 line_count: 5,
1357 similarity: None,
1358 },
1359 ],
1360 clone_families: Vec::new(),
1361 mirrored_directories: Vec::new(),
1362 stats: DuplicationStats {
1363 total_files: 4,
1364 files_with_clones: 4,
1365 total_lines: 100,
1366 duplicated_lines: 20,
1367 total_tokens: 200,
1368 duplicated_tokens: 80,
1369 clone_groups: 2,
1370 clone_families: 0,
1371 clone_instances: 4,
1372 duplication_percentage: 20.0,
1373 clone_groups_below_min_occurrences: 0,
1374 clone_groups_ignored: 0,
1375 near_candidates_skipped: 0,
1376 },
1377 };
1378
1379 let mut changed = FxHashSet::default();
1380 changed.insert(PathBuf::from("/repo/a.ts"));
1381
1382 filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1383
1384 assert_eq!(report.clone_groups.len(), 1);
1385 assert_eq!(report.stats.clone_groups, 1);
1386 assert_eq!(report.stats.clone_instances, 2);
1387 }
1388}