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(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
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.
152fn 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            "-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
208/// Return the raw git diff from a ref's merge base through the working tree.
209///
210/// The result includes committed, staged, unstaged, and untracked changes so it
211/// covers the same scope as `try_get_changed_files`.
212pub 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/// Get changed files if git can resolve them, otherwise return `None`.
307#[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/// Scope dead-code results to findings affected by changed files.
395///
396/// Dependency-level issues stay unfiltered because whether a dependency is
397/// unused is a graph-global fact, not a changed-file-local fact.
398#[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/// Scope duplication groups to clone groups touching at least one changed file.
777#[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}