Skip to main content

fallow_engine/
changed_files.rs

1//! Changed-file helpers owned by the engine boundary.
2
3use 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
20/// Function pointer signature used to intercept short-running git
21/// subprocesses spawned by changed-file helpers.
22pub type ChangedFilesSpawnHook = fn(&mut std::process::Command) -> std::io::Result<Output>;
23
24static SPAWN_HOOK: OnceLock<ChangedFilesSpawnHook> = OnceLock::new();
25
26/// Classification of a changed-file git failure.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ChangedFilesError {
29    /// Git ref failed validation before invoking `git`.
30    InvalidRef(String),
31    /// `git` binary not found or not executable.
32    GitMissing(String),
33    /// Command ran but the directory is not a git repository.
34    NotARepository,
35    /// Command ran but the ref is invalid or another git error occurred.
36    GitFailed(String),
37}
38
39impl ChangedFilesError {
40    /// Human-readable clause suitable for embedding in an error message.
41    #[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
66/// Install a spawn-hook for changed-file git subprocesses.
67pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
68    let _ = SPAWN_HOOK.set(hook);
69}
70
71/// Validate a user-supplied git ref before passing it to git.
72pub 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
96/// Resolve the canonical git toplevel for `cwd`.
97pub 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
122/// Resolve the canonical git common directory for `cwd`.
123pub 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
151/// Get files changed since a git ref.
152pub fn try_get_changed_files(
153    root: &Path,
154    git_ref: &str,
155) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
156    validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
157    let toplevel = resolve_git_toplevel(root)?;
158    try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
159}
160
161/// Resolve changed files for a git ref relative to a project root.
162///
163/// # Errors
164///
165/// Returns an error when git cannot resolve the ref or repository state.
166pub fn changed_files(root: &Path, git_ref: &str) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
167    try_get_changed_files(root, git_ref)
168}
169
170/// Get changed files and the git toplevel used to resolve them.
171pub fn try_get_changed_files_with_toplevel(
172    cwd: &Path,
173    toplevel: &Path,
174    git_ref: &str,
175) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
176    validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
177
178    let mut files = collect_git_paths(
179        cwd,
180        toplevel,
181        &[
182            "diff",
183            "--name-only",
184            "--end-of-options",
185            &format!("{git_ref}...HEAD"),
186        ],
187    )?;
188    files.extend(collect_git_paths(
189        cwd,
190        toplevel,
191        &["diff", "--name-only", "HEAD"],
192    )?);
193    files.extend(collect_git_paths(
194        cwd,
195        toplevel,
196        &["ls-files", "--full-name", "--others", "--exclude-standard"],
197    )?);
198    Ok(files)
199}
200
201/// Return the raw git diff for a ref.
202pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
203    validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
204    let output = spawn_output(&mut git_command(
205        root,
206        &[
207            "diff",
208            "--relative",
209            "--unified=0",
210            "--end-of-options",
211            &format!("{git_ref}...HEAD"),
212        ],
213    ))
214    .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
215
216    if !output.status.success() {
217        let stderr = String::from_utf8_lossy(&output.stderr);
218        return Err(if stderr.contains("not a git repository") {
219            ChangedFilesError::NotARepository
220        } else {
221            ChangedFilesError::GitFailed(stderr.trim().to_owned())
222        });
223    }
224
225    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
226}
227
228/// Get changed files if git can resolve them, otherwise return `None`.
229#[must_use]
230#[expect(
231    clippy::print_stderr,
232    reason = "intentional user-facing warning for the CLI's --changed-since fallback path; typed callers use try_get_changed_files instead"
233)]
234pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
235    match try_get_changed_files(root, git_ref) {
236        Ok(files) => Some(files),
237        Err(ChangedFilesError::InvalidRef(e)) => {
238            eprintln!("Warning: --changed-since ignored: invalid git ref: {e}");
239            None
240        }
241        Err(ChangedFilesError::GitMissing(e)) => {
242            eprintln!("Warning: --changed-since ignored: failed to run git: {e}");
243            None
244        }
245        Err(ChangedFilesError::NotARepository) => {
246            eprintln!("Warning: --changed-since ignored: not a git repository");
247            None
248        }
249        Err(ChangedFilesError::GitFailed(stderr)) => {
250            eprintln!("Warning: --changed-since failed for ref '{git_ref}': {stderr}");
251            None
252        }
253    }
254}
255
256fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
257    if let Some(hook) = SPAWN_HOOK.get() {
258        hook(command)
259    } else {
260        command.output()
261    }
262}
263
264fn collect_git_paths(
265    cwd: &Path,
266    toplevel: &Path,
267    args: &[&str],
268) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
269    let output = spawn_output(&mut git_command(cwd, args))
270        .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
271
272    if !output.status.success() {
273        let stderr = String::from_utf8_lossy(&output.stderr);
274        return Err(if stderr.contains("not a git repository") {
275            ChangedFilesError::NotARepository
276        } else {
277            ChangedFilesError::GitFailed(stderr.trim().to_owned())
278        });
279    }
280
281    #[cfg(windows)]
282    let normalise_segment = |line: &str| line.replace('/', "\\");
283    #[cfg(not(windows))]
284    let normalise_segment = |line: &str| line.to_owned();
285
286    let files = String::from_utf8_lossy(&output.stdout)
287        .lines()
288        .filter(|line| !line.is_empty())
289        .map(|line| toplevel.join(normalise_segment(line)))
290        .collect();
291
292    Ok(files)
293}
294
295#[expect(
296    clippy::disallowed_methods,
297    reason = "canonical engine-owned git spawn wrapper for changed-file orchestration"
298)]
299fn git_command(cwd: &Path, args: &[&str]) -> Command {
300    let mut command = Command::new("git");
301    clear_ambient_git_env(&mut command);
302    command.args(args).current_dir(cwd);
303    command
304}
305
306/// Scope dead-code results to findings affected by changed files.
307///
308/// Dependency-level issues stay unfiltered because whether a dependency is
309/// unused is a graph-global fact, not a changed-file-local fact.
310#[expect(
311    clippy::implicit_hasher,
312    reason = "fallow standardizes on FxHashSet across the workspace"
313)]
314pub fn filter_results_by_changed_files(
315    results: &mut AnalysisResults,
316    changed_files: &FxHashSet<PathBuf>,
317) {
318    let cf = normalize_changed_files_set(changed_files);
319    classify_changed_file_filter_fields(results);
320    retain_basic_issue_findings_by_changed_path(results, &cf);
321    retain_graph_findings_by_changed_files(results, &cf);
322    retain_boundary_policy_and_suppression_findings(results, &cf);
323    retain_security_and_workspace_findings(results, &cf);
324    retain_framework_findings_by_changed_files(results, &cf);
325}
326
327fn classify_changed_file_filter_fields(results: &AnalysisResults) {
328    let AnalysisResults {
329        unused_files: _unused_files,
330        unused_exports: _unused_exports,
331        unused_types: _unused_types,
332        private_type_leaks: _private_type_leaks,
333        unused_dependencies: _unused_dependencies,
334        unused_dev_dependencies: _unused_dev_dependencies,
335        unused_optional_dependencies: _unused_optional_dependencies,
336        unused_enum_members: _unused_enum_members,
337        unused_class_members: _unused_class_members,
338        unused_store_members: _unused_store_members,
339        unresolved_imports: _unresolved_imports,
340        unlisted_dependencies: _unlisted_dependencies,
341        duplicate_exports: _duplicate_exports,
342        type_only_dependencies: _type_only_dependencies,
343        test_only_dependencies: _test_only_dependencies,
344        dev_dependencies_in_production: _dev_dependencies_in_production,
345        circular_dependencies: _circular_dependencies,
346        re_export_cycles: _re_export_cycles,
347        boundary_violations: _boundary_violations,
348        boundary_coverage_violations: _boundary_coverage_violations,
349        boundary_call_violations: _boundary_call_violations,
350        policy_violations: _policy_violations,
351        stale_suppressions: _stale_suppressions,
352        unused_catalog_entries: _unused_catalog_entries,
353        empty_catalog_groups: _empty_catalog_groups,
354        unresolved_catalog_references: _unresolved_catalog_references,
355        unused_dependency_overrides: _unused_dependency_overrides,
356        misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
357        invalid_client_exports: _invalid_client_exports,
358        mixed_client_server_barrels: _mixed_client_server_barrels,
359        misplaced_directives: _misplaced_directives,
360        unprovided_injects: _unprovided_injects,
361        unrendered_components: _unrendered_components,
362        route_collisions: _route_collisions,
363        dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
364        unused_component_props: _unused_component_props,
365        unused_component_emits: _unused_component_emits,
366        unused_component_inputs: _unused_component_inputs,
367        unused_component_outputs: _unused_component_outputs,
368        unused_svelte_events: _unused_svelte_events,
369        unused_server_actions: _unused_server_actions,
370        unused_load_data_keys: _unused_load_data_keys,
371        unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
372        prop_drilling_chains: _prop_drilling_chains,
373        thin_wrappers: _thin_wrappers,
374        duplicate_prop_shapes: _duplicate_prop_shapes,
375        suppression_count: _suppression_count,
376        unused_component_props_exempted: _unused_component_props_exempted,
377        active_suppressions: _active_suppressions,
378        feature_flags: _feature_flags,
379        security_findings: _security_findings,
380        security_unresolved_edge_files: _security_unresolved_edge_files,
381        security_unresolved_callee_sites: _security_unresolved_callee_sites,
382        security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
383        export_usages: _export_usages,
384        entry_point_summary: _entry_point_summary,
385        render_fan_in: _render_fan_in,
386        react_component_intel: _react_component_intel,
387    } = results;
388}
389
390fn retain_basic_issue_findings_by_changed_path(
391    results: &mut AnalysisResults,
392    changed_files: &FxHashSet<PathBuf>,
393) {
394    retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
395    retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
396        &e.export.path
397    });
398    retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
399    retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
400        &e.leak.path
401    });
402    retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
403        &m.member.path
404    });
405    retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
406        &m.member.path
407    });
408    retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
409        &m.member.path
410    });
411    retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
412        &i.import.path
413    });
414}
415
416fn retain_graph_findings_by_changed_files(
417    results: &mut AnalysisResults,
418    changed_files: &FxHashSet<PathBuf>,
419) {
420    retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
421    retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
422    retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
423    retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
424}
425
426fn retain_boundary_policy_and_suppression_findings(
427    results: &mut AnalysisResults,
428    changed_files: &FxHashSet<PathBuf>,
429) {
430    retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
431        &v.violation.from_path
432    });
433    retain_by_changed_path(
434        &mut results.boundary_coverage_violations,
435        changed_files,
436        |v| &v.violation.path,
437    );
438    retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
439        &v.violation.path
440    });
441    retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
442        &v.violation.path
443    });
444    retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
445}
446
447fn retain_security_and_workspace_findings(
448    results: &mut AnalysisResults,
449    changed_files: &FxHashSet<PathBuf>,
450) {
451    retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
452    retain_by_changed_path(
453        &mut results.security_unresolved_callee_diagnostics,
454        changed_files,
455        |d| &d.path,
456    );
457    retain_by_changed_path(
458        &mut results.unresolved_catalog_references,
459        changed_files,
460        |r| &r.reference.path,
461    );
462    results
463        .empty_catalog_groups
464        .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
465    retain_by_changed_path(
466        &mut results.unused_dependency_overrides,
467        changed_files,
468        |o| &o.entry.path,
469    );
470    retain_by_changed_path(
471        &mut results.misconfigured_dependency_overrides,
472        changed_files,
473        |o| &o.entry.path,
474    );
475}
476
477fn retain_framework_findings_by_changed_files(
478    results: &mut AnalysisResults,
479    changed_files: &FxHashSet<PathBuf>,
480) {
481    retain_client_boundary_findings_by_changed_files(results, changed_files);
482    retain_component_contract_findings_by_changed_files(results, changed_files);
483    retain_react_health_findings_by_changed_files(results, changed_files);
484    retain_nextjs_findings_by_changed_files(results, changed_files);
485}
486
487fn retain_client_boundary_findings_by_changed_files(
488    results: &mut AnalysisResults,
489    changed_files: &FxHashSet<PathBuf>,
490) {
491    let AnalysisResults {
492        invalid_client_exports,
493        mixed_client_server_barrels,
494        misplaced_directives,
495        ..
496    } = results;
497
498    retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
499    retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
500        &b.barrel.path
501    });
502    retain_by_changed_path(misplaced_directives, changed_files, |d| {
503        &d.directive_site.path
504    });
505}
506
507fn retain_component_contract_findings_by_changed_files(
508    results: &mut AnalysisResults,
509    changed_files: &FxHashSet<PathBuf>,
510) {
511    let AnalysisResults {
512        unprovided_injects,
513        unrendered_components,
514        unused_component_props,
515        unused_component_emits,
516        unused_component_inputs,
517        unused_component_outputs,
518        unused_svelte_events,
519        unused_server_actions,
520        unused_load_data_keys,
521        ..
522    } = results;
523
524    retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
525    retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
526    retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
527    retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
528    retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
529    retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
530    retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
531    retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
532    retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
533}
534
535fn retain_react_health_findings_by_changed_files(
536    results: &mut AnalysisResults,
537    changed_files: &FxHashSet<PathBuf>,
538) {
539    let AnalysisResults {
540        prop_drilling_chains,
541        thin_wrappers,
542        duplicate_prop_shapes,
543        ..
544    } = results;
545
546    retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
547    retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
548    retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
549}
550
551fn retain_nextjs_findings_by_changed_files(
552    results: &mut AnalysisResults,
553    changed_files: &FxHashSet<PathBuf>,
554) {
555    let AnalysisResults {
556        route_collisions,
557        dynamic_segment_name_conflicts,
558        ..
559    } = results;
560
561    retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
562    retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
563        &c.conflict.path
564    });
565}
566
567fn retain_unlisted_dependencies_by_import_site(
568    dependencies: &mut Vec<UnlistedDependencyFinding>,
569    changed_files: &FxHashSet<PathBuf>,
570) {
571    dependencies.retain(|dependency| {
572        dependency
573            .dep
574            .imported_from
575            .iter()
576            .any(|site| contains_normalized(changed_files, &site.path))
577    });
578}
579
580fn retain_duplicate_exports_by_changed_locations(
581    duplicate_exports: &mut Vec<DuplicateExportFinding>,
582    changed_files: &FxHashSet<PathBuf>,
583) {
584    for duplicate in &mut *duplicate_exports {
585        duplicate
586            .export
587            .locations
588            .retain(|location| contains_normalized(changed_files, &location.path));
589    }
590    duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
591}
592
593fn retain_circular_dependencies_by_changed_file(
594    cycles: &mut Vec<CircularDependencyFinding>,
595    changed_files: &FxHashSet<PathBuf>,
596) {
597    cycles.retain(|cycle| {
598        cycle
599            .cycle
600            .files
601            .iter()
602            .any(|file| contains_normalized(changed_files, file))
603    });
604}
605
606fn retain_re_export_cycles_by_changed_file(
607    cycles: &mut Vec<ReExportCycleFinding>,
608    changed_files: &FxHashSet<PathBuf>,
609) {
610    cycles.retain(|cycle| {
611        cycle
612            .cycle
613            .files
614            .iter()
615            .any(|file| contains_normalized(changed_files, file))
616    });
617}
618
619fn retain_security_findings_by_changed_path(
620    findings: &mut Vec<SecurityFinding>,
621    changed_files: &FxHashSet<PathBuf>,
622) {
623    findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
624}
625
626fn retain_prop_drilling_chains_by_anchor(
627    chains: &mut Vec<PropDrillingChainFinding>,
628    changed_files: &FxHashSet<PathBuf>,
629) {
630    chains.retain(|chain| {
631        chain
632            .chain
633            .hops
634            .first()
635            .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
636    });
637}
638
639fn retain_duplicate_prop_shapes_by_anchor(
640    shapes: &mut Vec<DuplicatePropShapeFinding>,
641    changed_files: &FxHashSet<PathBuf>,
642) {
643    retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
644}
645
646fn retain_by_changed_path<T>(
647    items: &mut Vec<T>,
648    changed_files: &FxHashSet<PathBuf>,
649    path: impl Fn(&T) -> &Path,
650) {
651    items.retain(|item| contains_normalized(changed_files, path(item)));
652}
653
654fn security_finding_touches_changed_path(
655    finding: &SecurityFinding,
656    changed_files: &FxHashSet<PathBuf>,
657) -> bool {
658    contains_normalized(changed_files, &finding.path)
659        || finding
660            .trace
661            .iter()
662            .any(|hop| contains_normalized(changed_files, &hop.path))
663        || finding.reachability.as_ref().is_some_and(|reachability| {
664            reachability
665                .untrusted_source_trace
666                .iter()
667                .any(|hop| contains_normalized(changed_files, &hop.path))
668        })
669}
670
671fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
672    changed_files
673        .iter()
674        .map(|p| dunce::simplified(p).to_path_buf())
675        .collect()
676}
677
678fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
679    normalized.contains(dunce::simplified(path))
680}
681
682fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
683    contains_normalized(normalized, path)
684        || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
685}
686
687/// Scope duplication groups to clone groups touching at least one changed file.
688#[expect(
689    clippy::implicit_hasher,
690    reason = "fallow standardizes on FxHashSet across the workspace"
691)]
692pub fn filter_duplication_by_changed_files(
693    report: &mut DuplicationReport,
694    changed_files: &FxHashSet<PathBuf>,
695    root: &Path,
696) {
697    let cf = normalize_changed_files_set(changed_files);
698    report.clone_groups.retain(|group| {
699        group
700            .instances
701            .iter()
702            .any(|instance| contains_normalized(&cf, &instance.file))
703    });
704    duplicates::refresh_clone_families(report, root);
705    report.stats = duplicates::recompute_stats(report);
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use fallow_types::{
712        duplicates::{CloneGroup, CloneInstance, DuplicationStats},
713        output_dead_code::{
714            EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
715            UnusedFileFinding,
716        },
717        results::{
718            DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
719        },
720    };
721
722    #[test]
723    fn validate_git_ref_rejects_option_like_ref() {
724        assert!(validate_git_ref("--upload-pack=evil").is_err());
725        assert!(validate_git_ref("-flag").is_err());
726    }
727
728    #[test]
729    fn validate_git_ref_allows_reflog_relative_date() {
730        assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
731    }
732
733    #[test]
734    fn git_command_clears_parent_git_environment() {
735        let command = git_command(Path::new("."), &["status"]);
736        let envs: Vec<_> = command.get_envs().collect();
737
738        for var in AMBIENT_GIT_ENV_VARS {
739            assert!(
740                envs.iter()
741                    .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
742                "{var} should be cleared from the command env",
743            );
744        }
745    }
746
747    #[test]
748    fn try_get_changed_files_not_a_repository() {
749        let temp = tempfile::tempdir().expect("tempdir");
750        let result = try_get_changed_files(temp.path(), "main");
751        assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
752    }
753
754    #[test]
755    fn changed_files_error_describe_matches_core_contract() {
756        assert_eq!(
757            ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
758            "invalid git ref: bad ref"
759        );
760        assert_eq!(
761            ChangedFilesError::GitMissing("not found".to_string()).describe(),
762            "failed to run git: not found"
763        );
764        assert_eq!(
765            ChangedFilesError::NotARepository.describe(),
766            "not a git repository"
767        );
768        assert!(
769            ChangedFilesError::GitFailed("unknown revision main".to_string())
770                .describe()
771                .contains("fetch-depth: 0")
772        );
773    }
774
775    #[test]
776    fn filter_results_keeps_only_changed_file_findings() {
777        let mut results = AnalysisResults::default();
778        results
779            .unused_files
780            .push(UnusedFileFinding::with_actions(UnusedFile {
781                path: PathBuf::from("/repo/a.ts"),
782            }));
783        results
784            .unused_files
785            .push(UnusedFileFinding::with_actions(UnusedFile {
786                path: PathBuf::from("/repo/b.ts"),
787            }));
788        results
789            .unused_exports
790            .push(UnusedExportFinding::with_actions(UnusedExport {
791                path: PathBuf::from("/repo/a.ts"),
792                export_name: "foo".to_owned(),
793                is_type_only: false,
794                line: 1,
795                col: 0,
796                span_start: 0,
797                is_re_export: false,
798            }));
799
800        let mut changed = FxHashSet::default();
801        changed.insert(PathBuf::from("/repo/a.ts"));
802
803        filter_results_by_changed_files(&mut results, &changed);
804
805        assert_eq!(results.unused_files.len(), 1);
806        assert_eq!(
807            results.unused_files[0].file.path,
808            PathBuf::from("/repo/a.ts")
809        );
810        assert_eq!(results.unused_exports.len(), 1);
811    }
812
813    #[test]
814    fn filter_results_preserves_graph_global_dependency_findings() {
815        let mut results = AnalysisResults::default();
816        results
817            .unused_dependencies
818            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
819                package_name: "lodash".to_owned(),
820                location: DependencyLocation::Dependencies,
821                path: PathBuf::from("/repo/package.json"),
822                line: 3,
823                used_in_workspaces: Vec::new(),
824            }));
825
826        let changed = FxHashSet::default();
827        filter_results_by_changed_files(&mut results, &changed);
828
829        assert_eq!(results.unused_dependencies.len(), 1);
830    }
831
832    #[test]
833    fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
834        let mut results = AnalysisResults::default();
835        results
836            .empty_catalog_groups
837            .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
838                catalog_name: "legacy".to_owned(),
839                path: PathBuf::from("pnpm-workspace.yaml"),
840                line: 4,
841            }));
842
843        let mut changed = FxHashSet::default();
844        changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
845
846        filter_results_by_changed_files(&mut results, &changed);
847
848        assert_eq!(results.empty_catalog_groups.len(), 1);
849    }
850
851    #[test]
852    fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
853        let mut report = DuplicationReport {
854            clone_groups: vec![
855                CloneGroup {
856                    instances: vec![
857                        CloneInstance {
858                            file: PathBuf::from("/repo/a.ts"),
859                            start_line: 1,
860                            end_line: 5,
861                            start_col: 0,
862                            end_col: 10,
863                            fragment: "code".to_owned(),
864                        },
865                        CloneInstance {
866                            file: PathBuf::from("/repo/b.ts"),
867                            start_line: 1,
868                            end_line: 5,
869                            start_col: 0,
870                            end_col: 10,
871                            fragment: "code".to_owned(),
872                        },
873                    ],
874                    token_count: 20,
875                    line_count: 5,
876                },
877                CloneGroup {
878                    instances: vec![
879                        CloneInstance {
880                            file: PathBuf::from("/repo/c.ts"),
881                            start_line: 1,
882                            end_line: 5,
883                            start_col: 0,
884                            end_col: 10,
885                            fragment: "other".to_owned(),
886                        },
887                        CloneInstance {
888                            file: PathBuf::from("/repo/d.ts"),
889                            start_line: 1,
890                            end_line: 5,
891                            start_col: 0,
892                            end_col: 10,
893                            fragment: "other".to_owned(),
894                        },
895                    ],
896                    token_count: 20,
897                    line_count: 5,
898                },
899            ],
900            clone_families: Vec::new(),
901            mirrored_directories: Vec::new(),
902            stats: DuplicationStats {
903                total_files: 4,
904                files_with_clones: 4,
905                total_lines: 100,
906                duplicated_lines: 20,
907                total_tokens: 200,
908                duplicated_tokens: 80,
909                clone_groups: 2,
910                clone_instances: 4,
911                duplication_percentage: 20.0,
912                clone_groups_below_min_occurrences: 0,
913            },
914        };
915
916        let mut changed = FxHashSet::default();
917        changed.insert(PathBuf::from("/repo/a.ts"));
918
919        filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
920
921        assert_eq!(report.clone_groups.len(), 1);
922        assert_eq!(report.stats.clone_groups, 1);
923        assert_eq!(report.stats.clone_instances, 2);
924    }
925}