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        circular_dependencies: _circular_dependencies,
345        re_export_cycles: _re_export_cycles,
346        boundary_violations: _boundary_violations,
347        boundary_coverage_violations: _boundary_coverage_violations,
348        boundary_call_violations: _boundary_call_violations,
349        policy_violations: _policy_violations,
350        stale_suppressions: _stale_suppressions,
351        unused_catalog_entries: _unused_catalog_entries,
352        empty_catalog_groups: _empty_catalog_groups,
353        unresolved_catalog_references: _unresolved_catalog_references,
354        unused_dependency_overrides: _unused_dependency_overrides,
355        misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
356        invalid_client_exports: _invalid_client_exports,
357        mixed_client_server_barrels: _mixed_client_server_barrels,
358        misplaced_directives: _misplaced_directives,
359        unprovided_injects: _unprovided_injects,
360        unrendered_components: _unrendered_components,
361        route_collisions: _route_collisions,
362        dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
363        unused_component_props: _unused_component_props,
364        unused_component_emits: _unused_component_emits,
365        unused_component_inputs: _unused_component_inputs,
366        unused_component_outputs: _unused_component_outputs,
367        unused_svelte_events: _unused_svelte_events,
368        unused_server_actions: _unused_server_actions,
369        unused_load_data_keys: _unused_load_data_keys,
370        unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
371        prop_drilling_chains: _prop_drilling_chains,
372        thin_wrappers: _thin_wrappers,
373        duplicate_prop_shapes: _duplicate_prop_shapes,
374        suppression_count: _suppression_count,
375        unused_component_props_exempted: _unused_component_props_exempted,
376        active_suppressions: _active_suppressions,
377        feature_flags: _feature_flags,
378        security_findings: _security_findings,
379        security_unresolved_edge_files: _security_unresolved_edge_files,
380        security_unresolved_callee_sites: _security_unresolved_callee_sites,
381        security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
382        export_usages: _export_usages,
383        entry_point_summary: _entry_point_summary,
384        render_fan_in: _render_fan_in,
385        react_component_intel: _react_component_intel,
386    } = results;
387}
388
389fn retain_basic_issue_findings_by_changed_path(
390    results: &mut AnalysisResults,
391    changed_files: &FxHashSet<PathBuf>,
392) {
393    retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
394    retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
395        &e.export.path
396    });
397    retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
398    retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
399        &e.leak.path
400    });
401    retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
402        &m.member.path
403    });
404    retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
405        &m.member.path
406    });
407    retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
408        &m.member.path
409    });
410    retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
411        &i.import.path
412    });
413}
414
415fn retain_graph_findings_by_changed_files(
416    results: &mut AnalysisResults,
417    changed_files: &FxHashSet<PathBuf>,
418) {
419    retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
420    retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
421    retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
422    retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
423}
424
425fn retain_boundary_policy_and_suppression_findings(
426    results: &mut AnalysisResults,
427    changed_files: &FxHashSet<PathBuf>,
428) {
429    retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
430        &v.violation.from_path
431    });
432    retain_by_changed_path(
433        &mut results.boundary_coverage_violations,
434        changed_files,
435        |v| &v.violation.path,
436    );
437    retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
438        &v.violation.path
439    });
440    retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
441        &v.violation.path
442    });
443    retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
444}
445
446fn retain_security_and_workspace_findings(
447    results: &mut AnalysisResults,
448    changed_files: &FxHashSet<PathBuf>,
449) {
450    retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
451    retain_by_changed_path(
452        &mut results.security_unresolved_callee_diagnostics,
453        changed_files,
454        |d| &d.path,
455    );
456    retain_by_changed_path(
457        &mut results.unresolved_catalog_references,
458        changed_files,
459        |r| &r.reference.path,
460    );
461    results
462        .empty_catalog_groups
463        .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
464    retain_by_changed_path(
465        &mut results.unused_dependency_overrides,
466        changed_files,
467        |o| &o.entry.path,
468    );
469    retain_by_changed_path(
470        &mut results.misconfigured_dependency_overrides,
471        changed_files,
472        |o| &o.entry.path,
473    );
474}
475
476fn retain_framework_findings_by_changed_files(
477    results: &mut AnalysisResults,
478    changed_files: &FxHashSet<PathBuf>,
479) {
480    retain_client_boundary_findings_by_changed_files(results, changed_files);
481    retain_component_contract_findings_by_changed_files(results, changed_files);
482    retain_react_health_findings_by_changed_files(results, changed_files);
483    retain_nextjs_findings_by_changed_files(results, changed_files);
484}
485
486fn retain_client_boundary_findings_by_changed_files(
487    results: &mut AnalysisResults,
488    changed_files: &FxHashSet<PathBuf>,
489) {
490    let AnalysisResults {
491        invalid_client_exports,
492        mixed_client_server_barrels,
493        misplaced_directives,
494        ..
495    } = results;
496
497    retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
498    retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
499        &b.barrel.path
500    });
501    retain_by_changed_path(misplaced_directives, changed_files, |d| {
502        &d.directive_site.path
503    });
504}
505
506fn retain_component_contract_findings_by_changed_files(
507    results: &mut AnalysisResults,
508    changed_files: &FxHashSet<PathBuf>,
509) {
510    let AnalysisResults {
511        unprovided_injects,
512        unrendered_components,
513        unused_component_props,
514        unused_component_emits,
515        unused_component_inputs,
516        unused_component_outputs,
517        unused_svelte_events,
518        unused_server_actions,
519        unused_load_data_keys,
520        ..
521    } = results;
522
523    retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
524    retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
525    retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
526    retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
527    retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
528    retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
529    retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
530    retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
531    retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
532}
533
534fn retain_react_health_findings_by_changed_files(
535    results: &mut AnalysisResults,
536    changed_files: &FxHashSet<PathBuf>,
537) {
538    let AnalysisResults {
539        prop_drilling_chains,
540        thin_wrappers,
541        duplicate_prop_shapes,
542        ..
543    } = results;
544
545    retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
546    retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
547    retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
548}
549
550fn retain_nextjs_findings_by_changed_files(
551    results: &mut AnalysisResults,
552    changed_files: &FxHashSet<PathBuf>,
553) {
554    let AnalysisResults {
555        route_collisions,
556        dynamic_segment_name_conflicts,
557        ..
558    } = results;
559
560    retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
561    retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
562        &c.conflict.path
563    });
564}
565
566fn retain_unlisted_dependencies_by_import_site(
567    dependencies: &mut Vec<UnlistedDependencyFinding>,
568    changed_files: &FxHashSet<PathBuf>,
569) {
570    dependencies.retain(|dependency| {
571        dependency
572            .dep
573            .imported_from
574            .iter()
575            .any(|site| contains_normalized(changed_files, &site.path))
576    });
577}
578
579fn retain_duplicate_exports_by_changed_locations(
580    duplicate_exports: &mut Vec<DuplicateExportFinding>,
581    changed_files: &FxHashSet<PathBuf>,
582) {
583    for duplicate in &mut *duplicate_exports {
584        duplicate
585            .export
586            .locations
587            .retain(|location| contains_normalized(changed_files, &location.path));
588    }
589    duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
590}
591
592fn retain_circular_dependencies_by_changed_file(
593    cycles: &mut Vec<CircularDependencyFinding>,
594    changed_files: &FxHashSet<PathBuf>,
595) {
596    cycles.retain(|cycle| {
597        cycle
598            .cycle
599            .files
600            .iter()
601            .any(|file| contains_normalized(changed_files, file))
602    });
603}
604
605fn retain_re_export_cycles_by_changed_file(
606    cycles: &mut Vec<ReExportCycleFinding>,
607    changed_files: &FxHashSet<PathBuf>,
608) {
609    cycles.retain(|cycle| {
610        cycle
611            .cycle
612            .files
613            .iter()
614            .any(|file| contains_normalized(changed_files, file))
615    });
616}
617
618fn retain_security_findings_by_changed_path(
619    findings: &mut Vec<SecurityFinding>,
620    changed_files: &FxHashSet<PathBuf>,
621) {
622    findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
623}
624
625fn retain_prop_drilling_chains_by_anchor(
626    chains: &mut Vec<PropDrillingChainFinding>,
627    changed_files: &FxHashSet<PathBuf>,
628) {
629    chains.retain(|chain| {
630        chain
631            .chain
632            .hops
633            .first()
634            .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
635    });
636}
637
638fn retain_duplicate_prop_shapes_by_anchor(
639    shapes: &mut Vec<DuplicatePropShapeFinding>,
640    changed_files: &FxHashSet<PathBuf>,
641) {
642    retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
643}
644
645fn retain_by_changed_path<T>(
646    items: &mut Vec<T>,
647    changed_files: &FxHashSet<PathBuf>,
648    path: impl Fn(&T) -> &Path,
649) {
650    items.retain(|item| contains_normalized(changed_files, path(item)));
651}
652
653fn security_finding_touches_changed_path(
654    finding: &SecurityFinding,
655    changed_files: &FxHashSet<PathBuf>,
656) -> bool {
657    contains_normalized(changed_files, &finding.path)
658        || finding
659            .trace
660            .iter()
661            .any(|hop| contains_normalized(changed_files, &hop.path))
662        || finding.reachability.as_ref().is_some_and(|reachability| {
663            reachability
664                .untrusted_source_trace
665                .iter()
666                .any(|hop| contains_normalized(changed_files, &hop.path))
667        })
668}
669
670fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
671    changed_files
672        .iter()
673        .map(|p| dunce::simplified(p).to_path_buf())
674        .collect()
675}
676
677fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
678    normalized.contains(dunce::simplified(path))
679}
680
681fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
682    contains_normalized(normalized, path)
683        || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
684}
685
686/// Scope duplication groups to clone groups touching at least one changed file.
687#[expect(
688    clippy::implicit_hasher,
689    reason = "fallow standardizes on FxHashSet across the workspace"
690)]
691pub fn filter_duplication_by_changed_files(
692    report: &mut DuplicationReport,
693    changed_files: &FxHashSet<PathBuf>,
694    root: &Path,
695) {
696    let cf = normalize_changed_files_set(changed_files);
697    report.clone_groups.retain(|group| {
698        group
699            .instances
700            .iter()
701            .any(|instance| contains_normalized(&cf, &instance.file))
702    });
703    duplicates::refresh_clone_families(report, root);
704    report.stats = duplicates::recompute_stats(report);
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use fallow_types::{
711        duplicates::{CloneGroup, CloneInstance, DuplicationStats},
712        output_dead_code::{
713            EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
714            UnusedFileFinding,
715        },
716        results::{
717            DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
718        },
719    };
720
721    #[test]
722    fn validate_git_ref_rejects_option_like_ref() {
723        assert!(validate_git_ref("--upload-pack=evil").is_err());
724        assert!(validate_git_ref("-flag").is_err());
725    }
726
727    #[test]
728    fn validate_git_ref_allows_reflog_relative_date() {
729        assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
730    }
731
732    #[test]
733    fn git_command_clears_parent_git_environment() {
734        let command = git_command(Path::new("."), &["status"]);
735        let envs: Vec<_> = command.get_envs().collect();
736
737        for var in AMBIENT_GIT_ENV_VARS {
738            assert!(
739                envs.iter()
740                    .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
741                "{var} should be cleared from the command env",
742            );
743        }
744    }
745
746    #[test]
747    fn try_get_changed_files_not_a_repository() {
748        let temp = tempfile::tempdir().expect("tempdir");
749        let result = try_get_changed_files(temp.path(), "main");
750        assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
751    }
752
753    #[test]
754    fn changed_files_error_describe_matches_core_contract() {
755        assert_eq!(
756            ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
757            "invalid git ref: bad ref"
758        );
759        assert_eq!(
760            ChangedFilesError::GitMissing("not found".to_string()).describe(),
761            "failed to run git: not found"
762        );
763        assert_eq!(
764            ChangedFilesError::NotARepository.describe(),
765            "not a git repository"
766        );
767        assert!(
768            ChangedFilesError::GitFailed("unknown revision main".to_string())
769                .describe()
770                .contains("fetch-depth: 0")
771        );
772    }
773
774    #[test]
775    fn filter_results_keeps_only_changed_file_findings() {
776        let mut results = AnalysisResults::default();
777        results
778            .unused_files
779            .push(UnusedFileFinding::with_actions(UnusedFile {
780                path: PathBuf::from("/repo/a.ts"),
781            }));
782        results
783            .unused_files
784            .push(UnusedFileFinding::with_actions(UnusedFile {
785                path: PathBuf::from("/repo/b.ts"),
786            }));
787        results
788            .unused_exports
789            .push(UnusedExportFinding::with_actions(UnusedExport {
790                path: PathBuf::from("/repo/a.ts"),
791                export_name: "foo".to_owned(),
792                is_type_only: false,
793                line: 1,
794                col: 0,
795                span_start: 0,
796                is_re_export: false,
797            }));
798
799        let mut changed = FxHashSet::default();
800        changed.insert(PathBuf::from("/repo/a.ts"));
801
802        filter_results_by_changed_files(&mut results, &changed);
803
804        assert_eq!(results.unused_files.len(), 1);
805        assert_eq!(
806            results.unused_files[0].file.path,
807            PathBuf::from("/repo/a.ts")
808        );
809        assert_eq!(results.unused_exports.len(), 1);
810    }
811
812    #[test]
813    fn filter_results_preserves_graph_global_dependency_findings() {
814        let mut results = AnalysisResults::default();
815        results
816            .unused_dependencies
817            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
818                package_name: "lodash".to_owned(),
819                location: DependencyLocation::Dependencies,
820                path: PathBuf::from("/repo/package.json"),
821                line: 3,
822                used_in_workspaces: Vec::new(),
823            }));
824
825        let changed = FxHashSet::default();
826        filter_results_by_changed_files(&mut results, &changed);
827
828        assert_eq!(results.unused_dependencies.len(), 1);
829    }
830
831    #[test]
832    fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
833        let mut results = AnalysisResults::default();
834        results
835            .empty_catalog_groups
836            .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
837                catalog_name: "legacy".to_owned(),
838                path: PathBuf::from("pnpm-workspace.yaml"),
839                line: 4,
840            }));
841
842        let mut changed = FxHashSet::default();
843        changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
844
845        filter_results_by_changed_files(&mut results, &changed);
846
847        assert_eq!(results.empty_catalog_groups.len(), 1);
848    }
849
850    #[test]
851    fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
852        let mut report = DuplicationReport {
853            clone_groups: vec![
854                CloneGroup {
855                    instances: vec![
856                        CloneInstance {
857                            file: PathBuf::from("/repo/a.ts"),
858                            start_line: 1,
859                            end_line: 5,
860                            start_col: 0,
861                            end_col: 10,
862                            fragment: "code".to_owned(),
863                        },
864                        CloneInstance {
865                            file: PathBuf::from("/repo/b.ts"),
866                            start_line: 1,
867                            end_line: 5,
868                            start_col: 0,
869                            end_col: 10,
870                            fragment: "code".to_owned(),
871                        },
872                    ],
873                    token_count: 20,
874                    line_count: 5,
875                },
876                CloneGroup {
877                    instances: vec![
878                        CloneInstance {
879                            file: PathBuf::from("/repo/c.ts"),
880                            start_line: 1,
881                            end_line: 5,
882                            start_col: 0,
883                            end_col: 10,
884                            fragment: "other".to_owned(),
885                        },
886                        CloneInstance {
887                            file: PathBuf::from("/repo/d.ts"),
888                            start_line: 1,
889                            end_line: 5,
890                            start_col: 0,
891                            end_col: 10,
892                            fragment: "other".to_owned(),
893                        },
894                    ],
895                    token_count: 20,
896                    line_count: 5,
897                },
898            ],
899            clone_families: Vec::new(),
900            mirrored_directories: Vec::new(),
901            stats: DuplicationStats {
902                total_files: 4,
903                files_with_clones: 4,
904                total_lines: 100,
905                duplicated_lines: 20,
906                total_tokens: 200,
907                duplicated_tokens: 80,
908                clone_groups: 2,
909                clone_instances: 4,
910                duplication_percentage: 20.0,
911                clone_groups_below_min_occurrences: 0,
912            },
913        };
914
915        let mut changed = FxHashSet::default();
916        changed.insert(PathBuf::from("/repo/a.ts"));
917
918        filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
919
920        assert_eq!(report.clone_groups.len(), 1);
921        assert_eq!(report.stats.clone_groups, 1);
922        assert_eq!(report.stats.clone_instances, 2);
923    }
924}