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