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