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 unused_dependencies: _unused_dependencies,
584 unused_dev_dependencies: _unused_dev_dependencies,
585 unused_optional_dependencies: _unused_optional_dependencies,
586 unused_enum_members: _unused_enum_members,
587 unused_class_members: _unused_class_members,
588 unused_store_members: _unused_store_members,
589 unresolved_imports: _unresolved_imports,
590 unlisted_dependencies: _unlisted_dependencies,
591 duplicate_exports: _duplicate_exports,
592 type_only_dependencies: _type_only_dependencies,
593 test_only_dependencies: _test_only_dependencies,
594 dev_dependencies_in_production: _dev_dependencies_in_production,
595 circular_dependencies: _circular_dependencies,
596 re_export_cycles: _re_export_cycles,
597 boundary_violations: _boundary_violations,
598 boundary_coverage_violations: _boundary_coverage_violations,
599 boundary_call_violations: _boundary_call_violations,
600 policy_violations: _policy_violations,
601 stale_suppressions: _stale_suppressions,
602 unused_catalog_entries: _unused_catalog_entries,
603 empty_catalog_groups: _empty_catalog_groups,
604 unresolved_catalog_references: _unresolved_catalog_references,
605 unused_dependency_overrides: _unused_dependency_overrides,
606 misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
607 invalid_client_exports: _invalid_client_exports,
608 mixed_client_server_barrels: _mixed_client_server_barrels,
609 misplaced_directives: _misplaced_directives,
610 unprovided_injects: _unprovided_injects,
611 unrendered_components: _unrendered_components,
612 route_collisions: _route_collisions,
613 dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
614 unused_component_props: _unused_component_props,
615 unused_component_emits: _unused_component_emits,
616 unused_component_inputs: _unused_component_inputs,
617 unused_component_outputs: _unused_component_outputs,
618 unused_svelte_events: _unused_svelte_events,
619 unused_server_actions: _unused_server_actions,
620 unused_load_data_keys: _unused_load_data_keys,
621 unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
622 prop_drilling_chains: _prop_drilling_chains,
623 thin_wrappers: _thin_wrappers,
624 duplicate_prop_shapes: _duplicate_prop_shapes,
625 suppression_count: _suppression_count,
626 unused_component_props_exempted: _unused_component_props_exempted,
627 active_suppressions: _active_suppressions,
628 feature_flags: _feature_flags,
629 security_findings: _security_findings,
630 security_unresolved_edge_files: _security_unresolved_edge_files,
631 security_unresolved_callee_sites: _security_unresolved_callee_sites,
632 security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
633 export_usages: _export_usages,
634 entry_point_summary: _entry_point_summary,
635 render_fan_in: _render_fan_in,
636 react_component_intel: _react_component_intel,
637 semantic_framework_contracts: _semantic_framework_contracts,
638 } = results;
639}
640
641fn retain_basic_issue_findings_by_changed_path(
642 results: &mut AnalysisResults,
643 changed_files: &FxHashSet<PathBuf>,
644) {
645 retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
646 retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
647 &e.export.path
648 });
649 retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
650 retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
651 &e.leak.path
652 });
653 retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
654 &m.member.path
655 });
656 retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
657 &m.member.path
658 });
659 retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
660 &m.member.path
661 });
662 retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
663 &i.import.path
664 });
665}
666
667fn retain_graph_findings_by_changed_files(
668 results: &mut AnalysisResults,
669 changed_files: &FxHashSet<PathBuf>,
670) {
671 retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
672 retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
673 retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
674 retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
675}
676
677fn retain_boundary_policy_and_suppression_findings(
678 results: &mut AnalysisResults,
679 changed_files: &FxHashSet<PathBuf>,
680) {
681 retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
682 &v.violation.from_path
683 });
684 retain_by_changed_path(
685 &mut results.boundary_coverage_violations,
686 changed_files,
687 |v| &v.violation.path,
688 );
689 retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
690 &v.violation.path
691 });
692 retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
693 &v.violation.path
694 });
695 retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
696}
697
698fn retain_security_and_workspace_findings(
699 results: &mut AnalysisResults,
700 changed_files: &FxHashSet<PathBuf>,
701) {
702 retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
703 retain_by_changed_path(
704 &mut results.security_unresolved_callee_diagnostics,
705 changed_files,
706 |d| &d.path,
707 );
708 retain_by_changed_path(
709 &mut results.unresolved_catalog_references,
710 changed_files,
711 |r| &r.reference.path,
712 );
713 results
714 .empty_catalog_groups
715 .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
716 retain_by_changed_path(
717 &mut results.unused_dependency_overrides,
718 changed_files,
719 |o| &o.entry.path,
720 );
721 retain_by_changed_path(
722 &mut results.misconfigured_dependency_overrides,
723 changed_files,
724 |o| &o.entry.path,
725 );
726}
727
728fn retain_framework_findings_by_changed_files(
729 results: &mut AnalysisResults,
730 changed_files: &FxHashSet<PathBuf>,
731) {
732 retain_client_boundary_findings_by_changed_files(results, changed_files);
733 retain_component_contract_findings_by_changed_files(results, changed_files);
734 retain_react_health_findings_by_changed_files(results, changed_files);
735 retain_nextjs_findings_by_changed_files(results, changed_files);
736}
737
738fn retain_client_boundary_findings_by_changed_files(
739 results: &mut AnalysisResults,
740 changed_files: &FxHashSet<PathBuf>,
741) {
742 let AnalysisResults {
743 invalid_client_exports,
744 mixed_client_server_barrels,
745 misplaced_directives,
746 ..
747 } = results;
748
749 retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
750 retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
751 &b.barrel.path
752 });
753 retain_by_changed_path(misplaced_directives, changed_files, |d| {
754 &d.directive_site.path
755 });
756}
757
758fn retain_component_contract_findings_by_changed_files(
759 results: &mut AnalysisResults,
760 changed_files: &FxHashSet<PathBuf>,
761) {
762 let AnalysisResults {
763 unprovided_injects,
764 unrendered_components,
765 unused_component_props,
766 unused_component_emits,
767 unused_component_inputs,
768 unused_component_outputs,
769 unused_svelte_events,
770 unused_server_actions,
771 unused_load_data_keys,
772 ..
773 } = results;
774
775 retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
776 retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
777 retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
778 retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
779 retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
780 retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
781 retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
782 retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
783 retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
784}
785
786fn retain_react_health_findings_by_changed_files(
787 results: &mut AnalysisResults,
788 changed_files: &FxHashSet<PathBuf>,
789) {
790 let AnalysisResults {
791 prop_drilling_chains,
792 thin_wrappers,
793 duplicate_prop_shapes,
794 ..
795 } = results;
796
797 retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
798 retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
799 retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
800}
801
802fn retain_nextjs_findings_by_changed_files(
803 results: &mut AnalysisResults,
804 changed_files: &FxHashSet<PathBuf>,
805) {
806 let AnalysisResults {
807 route_collisions,
808 dynamic_segment_name_conflicts,
809 ..
810 } = results;
811
812 retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
813 retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
814 &c.conflict.path
815 });
816}
817
818fn retain_unlisted_dependencies_by_import_site(
819 dependencies: &mut Vec<UnlistedDependencyFinding>,
820 changed_files: &FxHashSet<PathBuf>,
821) {
822 dependencies.retain(|dependency| {
823 dependency
824 .dep
825 .imported_from
826 .iter()
827 .any(|site| contains_normalized(changed_files, &site.path))
828 });
829}
830
831fn retain_duplicate_exports_by_changed_locations(
832 duplicate_exports: &mut Vec<DuplicateExportFinding>,
833 changed_files: &FxHashSet<PathBuf>,
834) {
835 for duplicate in &mut *duplicate_exports {
836 duplicate
837 .export
838 .locations
839 .retain(|location| contains_normalized(changed_files, &location.path));
840 }
841 duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
842}
843
844fn retain_circular_dependencies_by_changed_file(
845 cycles: &mut Vec<CircularDependencyFinding>,
846 changed_files: &FxHashSet<PathBuf>,
847) {
848 cycles.retain(|cycle| {
849 cycle
850 .cycle
851 .files
852 .iter()
853 .any(|file| contains_normalized(changed_files, file))
854 });
855}
856
857fn retain_re_export_cycles_by_changed_file(
858 cycles: &mut Vec<ReExportCycleFinding>,
859 changed_files: &FxHashSet<PathBuf>,
860) {
861 cycles.retain(|cycle| {
862 cycle
863 .cycle
864 .files
865 .iter()
866 .any(|file| contains_normalized(changed_files, file))
867 });
868}
869
870fn retain_security_findings_by_changed_path(
871 findings: &mut Vec<SecurityFinding>,
872 changed_files: &FxHashSet<PathBuf>,
873) {
874 findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
875}
876
877fn retain_prop_drilling_chains_by_anchor(
878 chains: &mut Vec<PropDrillingChainFinding>,
879 changed_files: &FxHashSet<PathBuf>,
880) {
881 chains.retain(|chain| {
882 chain
883 .chain
884 .hops
885 .first()
886 .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
887 });
888}
889
890fn retain_duplicate_prop_shapes_by_anchor(
891 shapes: &mut Vec<DuplicatePropShapeFinding>,
892 changed_files: &FxHashSet<PathBuf>,
893) {
894 retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
895}
896
897fn retain_by_changed_path<T>(
898 items: &mut Vec<T>,
899 changed_files: &FxHashSet<PathBuf>,
900 path: impl Fn(&T) -> &Path,
901) {
902 items.retain(|item| contains_normalized(changed_files, path(item)));
903}
904
905fn security_finding_touches_changed_path(
906 finding: &SecurityFinding,
907 changed_files: &FxHashSet<PathBuf>,
908) -> bool {
909 contains_normalized(changed_files, &finding.path)
910 || finding
911 .trace
912 .iter()
913 .any(|hop| contains_normalized(changed_files, &hop.path))
914 || finding.reachability.as_ref().is_some_and(|reachability| {
915 reachability
916 .untrusted_source_trace
917 .iter()
918 .any(|hop| contains_normalized(changed_files, &hop.path))
919 })
920}
921
922fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
923 changed_files
924 .iter()
925 .map(|p| dunce::simplified(p).to_path_buf())
926 .collect()
927}
928
929fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
930 normalized.contains(dunce::simplified(path))
931}
932
933fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
934 contains_normalized(normalized, path)
935 || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
936}
937
938#[expect(
940 clippy::implicit_hasher,
941 reason = "fallow standardizes on FxHashSet across the workspace"
942)]
943pub fn filter_duplication_by_changed_files(
944 report: &mut DuplicationReport,
945 changed_files: &FxHashSet<PathBuf>,
946 root: &Path,
947) {
948 let cf = normalize_changed_files_set(changed_files);
949 report.clone_groups.retain(|group| {
950 group
951 .instances
952 .iter()
953 .any(|instance| contains_normalized(&cf, &instance.file))
954 });
955 duplicates::refresh_clone_families(report, root);
956 report.stats = duplicates::recompute_stats(report);
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962 use fallow_types::{
963 duplicates::{CloneGroup, CloneInstance, DuplicationStats},
964 output_dead_code::{
965 EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
966 UnusedFileFinding,
967 },
968 results::{
969 DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
970 },
971 };
972
973 #[test]
974 fn validate_git_ref_rejects_option_like_ref() {
975 assert!(validate_git_ref("--upload-pack=evil").is_err());
976 assert!(validate_git_ref("-flag").is_err());
977 }
978
979 #[test]
980 fn validate_git_ref_allows_reflog_relative_date() {
981 assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
982 }
983
984 #[test]
987 fn every_changed_files_cause_reports_its_own_reason() {
988 let reasons = [
989 ChangedFilesError::InvalidRef("unclosed brace".to_owned()).reason(),
990 ChangedFilesError::GitMissing("no such file".to_owned()).reason(),
991 ChangedFilesError::NotARepository.reason(),
992 ChangedFilesError::GitFailed("unknown revision".to_owned()).reason(),
993 ];
994 assert_eq!(
995 reasons,
996 [
997 "invalid-ref",
998 "git-missing",
999 "not-a-repository",
1000 "git-failed"
1001 ]
1002 );
1003 let unique: std::collections::BTreeSet<&str> = reasons.iter().copied().collect();
1004 assert_eq!(
1005 unique.len(),
1006 reasons.len(),
1007 "two causes must not share a token"
1008 );
1009 }
1010
1011 #[test]
1016 fn the_changed_since_message_names_the_ref_the_widening_and_the_next_step() {
1017 let message = ChangedFilesError::NotARepository.changed_since_message("origin/main");
1018 assert!(
1019 message.contains("--changed-since 'origin/main'"),
1020 "{message}"
1021 );
1022 assert!(message.contains("covers the whole project"), "{message}");
1023 assert!(message.ends_with("or drop --changed-since."), "{message}");
1024 }
1025
1026 #[test]
1030 fn the_changed_since_message_folds_git_stderr_onto_one_line() {
1031 let message = ChangedFilesError::GitFailed(
1032 "fatal: ambiguous argument 'x'\nUse '--' to separate paths".to_owned(),
1033 )
1034 .changed_since_message("x");
1035 assert!(!message.contains('\n'), "{message}");
1036 assert!(
1037 message.contains("ambiguous argument 'x' Use '--'"),
1038 "{message}"
1039 );
1040 }
1041
1042 #[test]
1043 fn git_command_clears_parent_git_environment() {
1044 let command = git_command(Path::new("."), &["status"]);
1045 let envs: Vec<_> = command.get_envs().collect();
1046
1047 for var in AMBIENT_GIT_ENV_VARS {
1048 assert!(
1049 envs.iter()
1050 .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
1051 "{var} should be cleared from the command env",
1052 );
1053 }
1054 }
1055
1056 #[test]
1057 fn try_get_changed_files_not_a_repository() {
1058 let temp = tempfile::tempdir().expect("tempdir");
1059 let result = try_get_changed_files(temp.path(), "main");
1060 assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1061 }
1062
1063 #[cfg(unix)]
1064 #[test]
1065 fn changed_files_preserve_special_filenames() {
1066 let repo = tempfile::tempdir().expect("tempdir");
1067 for args in [
1068 &["init", "--quiet"][..],
1069 &["config", "user.email", "test@example.com"][..],
1070 &["config", "user.name", "Test User"][..],
1071 &["config", "commit.gpgsign", "false"][..],
1072 ] {
1073 run_git(repo.path(), args);
1074 }
1075 std::fs::write(repo.path().join("initial.ts"), "initial\n").expect("initial fixture");
1076 run_git(repo.path(), &["add", "."]);
1077 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1078 run_git(repo.path(), &["tag", "base"]);
1079
1080 let canonical_root = repo.path().canonicalize().expect("canonical repo");
1081 let special_files = [
1082 "src/line\nbreak.ts",
1083 "src/space name.ts",
1084 "src/quote\"name.ts",
1085 "src/back\\slash.ts",
1086 "src/unicode-λ.ts",
1087 ]
1088 .map(|path| canonical_root.join(path));
1089 std::fs::create_dir_all(canonical_root.join("src")).expect("source dir");
1090 for special in &special_files {
1091 std::fs::write(special, "changed\n").expect("special fixture");
1092 }
1093
1094 let changed = try_get_changed_files(repo.path(), "base").expect("changed files");
1095 for special in special_files {
1096 assert!(
1097 changed.contains(&special),
1098 "missing {special:?}: {changed:?}"
1099 );
1100 }
1101 }
1102
1103 #[cfg(windows)]
1104 #[test]
1105 fn git_path_bytes_use_windows_separators() {
1106 assert_eq!(
1107 git_path_from_bytes(b"src/nested/file.ts"),
1108 PathBuf::from(r"src\nested\file.ts")
1109 );
1110 }
1111
1112 #[test]
1113 fn changed_diff_covers_staged_unstaged_and_untracked_files() {
1114 let repo = tempfile::tempdir().expect("tempdir");
1115 for args in [
1116 &["init", "--quiet"][..],
1117 &["config", "user.email", "test@example.com"][..],
1118 &["config", "user.name", "Test User"][..],
1119 &["config", "commit.gpgsign", "false"][..],
1120 ] {
1121 run_git(repo.path(), args);
1122 }
1123 std::fs::write(repo.path().join("staged.ts"), "old\n").expect("staged fixture");
1124 std::fs::write(repo.path().join("unstaged.ts"), "old\n").expect("unstaged fixture");
1125 run_git(repo.path(), &["add", "."]);
1126 run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1127 run_git(repo.path(), &["tag", "base"]);
1128
1129 std::fs::write(repo.path().join("committed.ts"), "committed\n").expect("committed fixture");
1130 run_git(repo.path(), &["add", "committed.ts"]);
1131 run_git(
1132 repo.path(),
1133 &["commit", "--quiet", "-m", "committed change"],
1134 );
1135
1136 std::fs::write(repo.path().join("staged.ts"), "staged\n").expect("staged edit");
1137 run_git(repo.path(), &["add", "staged.ts"]);
1138 std::fs::write(repo.path().join("unstaged.ts"), "unstaged\n").expect("unstaged edit");
1139 std::fs::write(repo.path().join("untracked.ts"), "untracked\n").expect("untracked edit");
1140
1141 let diff = try_get_changed_diff(repo.path(), "base").expect("complete changeset diff");
1142 let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1143
1144 assert!(diff.contains("b/committed.ts"), "{diff}");
1145 assert!(diff.contains("b/staged.ts"), "{diff}");
1146 assert!(diff.contains("b/unstaged.ts"), "{diff}");
1147 assert!(diff.contains("b/untracked.ts"), "{diff}");
1148 assert_eq!(index.hunk_count(), 4);
1149 assert_eq!(index.net_lines(), 2);
1150 }
1151
1152 fn run_git(root: &Path, args: &[&str]) {
1153 let output = spawn_output(&mut git_command(root, args)).expect("git command");
1154 assert!(
1155 output.status.success(),
1156 "git {args:?} failed: {}",
1157 String::from_utf8_lossy(&output.stderr)
1158 );
1159 }
1160
1161 #[test]
1162 fn untracked_path_arg_uses_forward_slashes() {
1163 assert_eq!(
1164 super::untracked_path_arg(Path::new("src\\nested\\b.ts")),
1165 "src/nested/b.ts"
1166 );
1167 assert_eq!(super::untracked_path_arg(Path::new("src/b.ts")), "src/b.ts");
1168 }
1169
1170 #[test]
1171 fn changed_files_error_describe_matches_core_contract() {
1172 assert_eq!(
1173 ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
1174 "invalid git ref: bad ref"
1175 );
1176 assert_eq!(
1177 ChangedFilesError::GitMissing("not found".to_string()).describe(),
1178 "failed to run git: not found"
1179 );
1180 assert_eq!(
1181 ChangedFilesError::NotARepository.describe(),
1182 "not a git repository"
1183 );
1184 assert!(
1185 ChangedFilesError::GitFailed("unknown revision main".to_string())
1186 .describe()
1187 .contains("fetch-depth: 0")
1188 );
1189 }
1190
1191 #[test]
1192 fn filter_results_keeps_only_changed_file_findings() {
1193 let mut results = AnalysisResults::default();
1194 results
1195 .unused_files
1196 .push(UnusedFileFinding::with_actions(UnusedFile {
1197 path: PathBuf::from("/repo/a.ts"),
1198 }));
1199 results
1200 .unused_files
1201 .push(UnusedFileFinding::with_actions(UnusedFile {
1202 path: PathBuf::from("/repo/b.ts"),
1203 }));
1204 results
1205 .unused_exports
1206 .push(UnusedExportFinding::with_actions(UnusedExport {
1207 path: PathBuf::from("/repo/a.ts"),
1208 export_name: "foo".to_owned(),
1209 is_type_only: false,
1210 line: 1,
1211 col: 0,
1212 span_start: 0,
1213 is_re_export: false,
1214 }));
1215
1216 let mut changed = FxHashSet::default();
1217 changed.insert(PathBuf::from("/repo/a.ts"));
1218
1219 filter_results_by_changed_files(&mut results, &changed);
1220
1221 assert_eq!(results.unused_files.len(), 1);
1222 assert_eq!(
1223 results.unused_files[0].file.path,
1224 PathBuf::from("/repo/a.ts")
1225 );
1226 assert_eq!(results.unused_exports.len(), 1);
1227 }
1228
1229 #[test]
1230 fn filter_results_preserves_graph_global_dependency_findings() {
1231 let mut results = AnalysisResults::default();
1232 results
1233 .unused_dependencies
1234 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1235 package_name: "lodash".to_owned(),
1236 location: DependencyLocation::Dependencies,
1237 path: PathBuf::from("/repo/package.json"),
1238 line: 3,
1239 used_in_workspaces: Vec::new(),
1240 }));
1241
1242 let changed = FxHashSet::default();
1243 filter_results_by_changed_files(&mut results, &changed);
1244
1245 assert_eq!(results.unused_dependencies.len(), 1);
1246 }
1247
1248 #[test]
1249 fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
1250 let mut results = AnalysisResults::default();
1251 results
1252 .empty_catalog_groups
1253 .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1254 catalog_name: "legacy".to_owned(),
1255 path: PathBuf::from("pnpm-workspace.yaml"),
1256 line: 4,
1257 }));
1258
1259 let mut changed = FxHashSet::default();
1260 changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1261
1262 filter_results_by_changed_files(&mut results, &changed);
1263
1264 assert_eq!(results.empty_catalog_groups.len(), 1);
1265 }
1266
1267 #[test]
1268 fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
1269 let mut report = DuplicationReport {
1270 clone_groups: vec![
1271 CloneGroup {
1272 instances: vec![
1273 CloneInstance {
1274 file: PathBuf::from("/repo/a.ts"),
1275 start_line: 1,
1276 end_line: 5,
1277 start_col: 0,
1278 end_col: 10,
1279 fragment: "code".to_owned(),
1280 },
1281 CloneInstance {
1282 file: PathBuf::from("/repo/b.ts"),
1283 start_line: 1,
1284 end_line: 5,
1285 start_col: 0,
1286 end_col: 10,
1287 fragment: "code".to_owned(),
1288 },
1289 ],
1290 token_count: 20,
1291 line_count: 5,
1292 similarity: None,
1293 },
1294 CloneGroup {
1295 instances: vec![
1296 CloneInstance {
1297 file: PathBuf::from("/repo/c.ts"),
1298 start_line: 1,
1299 end_line: 5,
1300 start_col: 0,
1301 end_col: 10,
1302 fragment: "other".to_owned(),
1303 },
1304 CloneInstance {
1305 file: PathBuf::from("/repo/d.ts"),
1306 start_line: 1,
1307 end_line: 5,
1308 start_col: 0,
1309 end_col: 10,
1310 fragment: "other".to_owned(),
1311 },
1312 ],
1313 token_count: 20,
1314 line_count: 5,
1315 similarity: None,
1316 },
1317 ],
1318 clone_families: Vec::new(),
1319 mirrored_directories: Vec::new(),
1320 stats: DuplicationStats {
1321 total_files: 4,
1322 files_with_clones: 4,
1323 total_lines: 100,
1324 duplicated_lines: 20,
1325 total_tokens: 200,
1326 duplicated_tokens: 80,
1327 clone_groups: 2,
1328 clone_families: 0,
1329 clone_instances: 4,
1330 duplication_percentage: 20.0,
1331 clone_groups_below_min_occurrences: 0,
1332 clone_groups_ignored: 0,
1333 near_candidates_skipped: 0,
1334 },
1335 };
1336
1337 let mut changed = FxHashSet::default();
1338 changed.insert(PathBuf::from("/repo/a.ts"));
1339
1340 filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1341
1342 assert_eq!(report.clone_groups.len(), 1);
1343 assert_eq!(report.stats.clone_groups, 1);
1344 assert_eq!(report.stats.clone_instances, 2);
1345 }
1346}