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