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, Stdio};
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(untracked_path_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
398/// Forward-slashed relative path for the `git diff --no-index` argument.
399/// Passing the native form on Windows makes git echo backslashes into the
400/// `+++ b/` header, so every consumer keyed on forward-slashed diff paths
401/// (added-line lookups included) silently misses the file.
402fn untracked_path_arg(path: &Path) -> String {
403    path.to_string_lossy().replace('\\', "/")
404}
405
406fn changed_files_error_from_output(output: &Output) -> ChangedFilesError {
407    let stderr = String::from_utf8_lossy(&output.stderr);
408    if stderr.contains("not a git repository") {
409        ChangedFilesError::NotARepository
410    } else {
411        ChangedFilesError::GitFailed(stderr.trim().to_owned())
412    }
413}
414
415/// Get changed files if git can resolve them, otherwise return `None`.
416#[must_use]
417#[expect(
418    clippy::print_stderr,
419    reason = "intentional user-facing warning for the CLI's --changed-since fallback path; typed callers use try_get_changed_files instead"
420)]
421pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
422    match try_get_changed_files(root, git_ref) {
423        Ok(files) => Some(files),
424        Err(ChangedFilesError::InvalidRef(e)) => {
425            eprintln!("Warning: --changed-since ignored: invalid git ref: {e}");
426            None
427        }
428        Err(ChangedFilesError::GitMissing(e)) => {
429            eprintln!("Warning: --changed-since ignored: failed to run git: {e}");
430            None
431        }
432        Err(ChangedFilesError::NotARepository) => {
433            eprintln!("Warning: --changed-since ignored: not a git repository");
434            None
435        }
436        Err(ChangedFilesError::GitFailed(stderr)) => {
437            eprintln!("Warning: --changed-since failed for ref '{git_ref}': {stderr}");
438            None
439        }
440    }
441}
442
443fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
444    if let Some(hook) = SPAWN_HOOK.get() {
445        hook(command)
446    } else {
447        command.output()
448    }
449}
450
451fn collect_git_paths(
452    cwd: &Path,
453    toplevel: &Path,
454    args: &[&str],
455) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
456    let output = spawn_output(&mut git_command(cwd, args))
457        .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
458
459    if !output.status.success() {
460        let stderr = String::from_utf8_lossy(&output.stderr);
461        return Err(if stderr.contains("not a git repository") {
462            ChangedFilesError::NotARepository
463        } else {
464            ChangedFilesError::GitFailed(stderr.trim().to_owned())
465        });
466    }
467
468    let files = output
469        .stdout
470        .split(|byte| *byte == 0)
471        .filter(|path| !path.is_empty())
472        .map(git_path_from_bytes)
473        .map(|path| toplevel.join(path))
474        .collect();
475
476    Ok(files)
477}
478
479/// Decode one NUL-separated `git` output path into a `PathBuf`.
480///
481/// Unix keeps the raw bytes, so a non-UTF-8 tree path survives intact.
482/// Shared with `crate::churn`, which decodes the same `git` byte output under
483/// the same semantics.
484#[cfg(unix)]
485pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
486    use std::ffi::OsString;
487    use std::os::unix::ffi::OsStringExt;
488
489    PathBuf::from(OsString::from_vec(path.to_vec()))
490}
491
492/// Windows counterpart: there is no byte-oriented `OsString`, so decode
493/// lossily and rewrite to backslash separators.
494#[cfg(windows)]
495pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
496    PathBuf::from(String::from_utf8_lossy(path).replace('/', "\\"))
497}
498
499#[expect(
500    clippy::disallowed_methods,
501    reason = "canonical engine-owned git spawn wrapper for changed-file orchestration"
502)]
503fn git_command(cwd: &Path, args: &[&str]) -> Command {
504    let mut command = Command::new("git");
505    clear_ambient_git_env(&mut command);
506    // Changed-file probes are non-interactive and must not inherit protocol stdin.
507    command.stdin(Stdio::null()).args(args).current_dir(cwd);
508    command
509}
510
511/// Scope dead-code results to findings affected by changed files.
512///
513/// Dependency-level issues stay unfiltered because whether a dependency is
514/// unused is a graph-global fact, not a changed-file-local fact.
515#[expect(
516    clippy::implicit_hasher,
517    reason = "fallow standardizes on FxHashSet across the workspace"
518)]
519pub fn filter_results_by_changed_files(
520    results: &mut AnalysisResults,
521    changed_files: &FxHashSet<PathBuf>,
522) {
523    let cf = normalize_changed_files_set(changed_files);
524    classify_changed_file_filter_fields(results);
525    retain_basic_issue_findings_by_changed_path(results, &cf);
526    retain_graph_findings_by_changed_files(results, &cf);
527    retain_boundary_policy_and_suppression_findings(results, &cf);
528    retain_security_and_workspace_findings(results, &cf);
529    retain_framework_findings_by_changed_files(results, &cf);
530}
531
532fn classify_changed_file_filter_fields(results: &AnalysisResults) {
533    let AnalysisResults {
534        unused_files: _unused_files,
535        unused_exports: _unused_exports,
536        unused_types: _unused_types,
537        private_type_leaks: _private_type_leaks,
538        unused_dependencies: _unused_dependencies,
539        unused_dev_dependencies: _unused_dev_dependencies,
540        unused_optional_dependencies: _unused_optional_dependencies,
541        unused_enum_members: _unused_enum_members,
542        unused_class_members: _unused_class_members,
543        unused_store_members: _unused_store_members,
544        unresolved_imports: _unresolved_imports,
545        unlisted_dependencies: _unlisted_dependencies,
546        duplicate_exports: _duplicate_exports,
547        type_only_dependencies: _type_only_dependencies,
548        test_only_dependencies: _test_only_dependencies,
549        dev_dependencies_in_production: _dev_dependencies_in_production,
550        circular_dependencies: _circular_dependencies,
551        re_export_cycles: _re_export_cycles,
552        boundary_violations: _boundary_violations,
553        boundary_coverage_violations: _boundary_coverage_violations,
554        boundary_call_violations: _boundary_call_violations,
555        policy_violations: _policy_violations,
556        stale_suppressions: _stale_suppressions,
557        unused_catalog_entries: _unused_catalog_entries,
558        empty_catalog_groups: _empty_catalog_groups,
559        unresolved_catalog_references: _unresolved_catalog_references,
560        unused_dependency_overrides: _unused_dependency_overrides,
561        misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
562        invalid_client_exports: _invalid_client_exports,
563        mixed_client_server_barrels: _mixed_client_server_barrels,
564        misplaced_directives: _misplaced_directives,
565        unprovided_injects: _unprovided_injects,
566        unrendered_components: _unrendered_components,
567        route_collisions: _route_collisions,
568        dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
569        unused_component_props: _unused_component_props,
570        unused_component_emits: _unused_component_emits,
571        unused_component_inputs: _unused_component_inputs,
572        unused_component_outputs: _unused_component_outputs,
573        unused_svelte_events: _unused_svelte_events,
574        unused_server_actions: _unused_server_actions,
575        unused_load_data_keys: _unused_load_data_keys,
576        unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
577        prop_drilling_chains: _prop_drilling_chains,
578        thin_wrappers: _thin_wrappers,
579        duplicate_prop_shapes: _duplicate_prop_shapes,
580        suppression_count: _suppression_count,
581        unused_component_props_exempted: _unused_component_props_exempted,
582        active_suppressions: _active_suppressions,
583        feature_flags: _feature_flags,
584        security_findings: _security_findings,
585        security_unresolved_edge_files: _security_unresolved_edge_files,
586        security_unresolved_callee_sites: _security_unresolved_callee_sites,
587        security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
588        export_usages: _export_usages,
589        entry_point_summary: _entry_point_summary,
590        render_fan_in: _render_fan_in,
591        react_component_intel: _react_component_intel,
592        semantic_framework_contracts: _semantic_framework_contracts,
593    } = results;
594}
595
596fn retain_basic_issue_findings_by_changed_path(
597    results: &mut AnalysisResults,
598    changed_files: &FxHashSet<PathBuf>,
599) {
600    retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
601    retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
602        &e.export.path
603    });
604    retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
605    retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
606        &e.leak.path
607    });
608    retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
609        &m.member.path
610    });
611    retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
612        &m.member.path
613    });
614    retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
615        &m.member.path
616    });
617    retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
618        &i.import.path
619    });
620}
621
622fn retain_graph_findings_by_changed_files(
623    results: &mut AnalysisResults,
624    changed_files: &FxHashSet<PathBuf>,
625) {
626    retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
627    retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
628    retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
629    retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
630}
631
632fn retain_boundary_policy_and_suppression_findings(
633    results: &mut AnalysisResults,
634    changed_files: &FxHashSet<PathBuf>,
635) {
636    retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
637        &v.violation.from_path
638    });
639    retain_by_changed_path(
640        &mut results.boundary_coverage_violations,
641        changed_files,
642        |v| &v.violation.path,
643    );
644    retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
645        &v.violation.path
646    });
647    retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
648        &v.violation.path
649    });
650    retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
651}
652
653fn retain_security_and_workspace_findings(
654    results: &mut AnalysisResults,
655    changed_files: &FxHashSet<PathBuf>,
656) {
657    retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
658    retain_by_changed_path(
659        &mut results.security_unresolved_callee_diagnostics,
660        changed_files,
661        |d| &d.path,
662    );
663    retain_by_changed_path(
664        &mut results.unresolved_catalog_references,
665        changed_files,
666        |r| &r.reference.path,
667    );
668    results
669        .empty_catalog_groups
670        .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
671    retain_by_changed_path(
672        &mut results.unused_dependency_overrides,
673        changed_files,
674        |o| &o.entry.path,
675    );
676    retain_by_changed_path(
677        &mut results.misconfigured_dependency_overrides,
678        changed_files,
679        |o| &o.entry.path,
680    );
681}
682
683fn retain_framework_findings_by_changed_files(
684    results: &mut AnalysisResults,
685    changed_files: &FxHashSet<PathBuf>,
686) {
687    retain_client_boundary_findings_by_changed_files(results, changed_files);
688    retain_component_contract_findings_by_changed_files(results, changed_files);
689    retain_react_health_findings_by_changed_files(results, changed_files);
690    retain_nextjs_findings_by_changed_files(results, changed_files);
691}
692
693fn retain_client_boundary_findings_by_changed_files(
694    results: &mut AnalysisResults,
695    changed_files: &FxHashSet<PathBuf>,
696) {
697    let AnalysisResults {
698        invalid_client_exports,
699        mixed_client_server_barrels,
700        misplaced_directives,
701        ..
702    } = results;
703
704    retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
705    retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
706        &b.barrel.path
707    });
708    retain_by_changed_path(misplaced_directives, changed_files, |d| {
709        &d.directive_site.path
710    });
711}
712
713fn retain_component_contract_findings_by_changed_files(
714    results: &mut AnalysisResults,
715    changed_files: &FxHashSet<PathBuf>,
716) {
717    let AnalysisResults {
718        unprovided_injects,
719        unrendered_components,
720        unused_component_props,
721        unused_component_emits,
722        unused_component_inputs,
723        unused_component_outputs,
724        unused_svelte_events,
725        unused_server_actions,
726        unused_load_data_keys,
727        ..
728    } = results;
729
730    retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
731    retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
732    retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
733    retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
734    retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
735    retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
736    retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
737    retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
738    retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
739}
740
741fn retain_react_health_findings_by_changed_files(
742    results: &mut AnalysisResults,
743    changed_files: &FxHashSet<PathBuf>,
744) {
745    let AnalysisResults {
746        prop_drilling_chains,
747        thin_wrappers,
748        duplicate_prop_shapes,
749        ..
750    } = results;
751
752    retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
753    retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
754    retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
755}
756
757fn retain_nextjs_findings_by_changed_files(
758    results: &mut AnalysisResults,
759    changed_files: &FxHashSet<PathBuf>,
760) {
761    let AnalysisResults {
762        route_collisions,
763        dynamic_segment_name_conflicts,
764        ..
765    } = results;
766
767    retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
768    retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
769        &c.conflict.path
770    });
771}
772
773fn retain_unlisted_dependencies_by_import_site(
774    dependencies: &mut Vec<UnlistedDependencyFinding>,
775    changed_files: &FxHashSet<PathBuf>,
776) {
777    dependencies.retain(|dependency| {
778        dependency
779            .dep
780            .imported_from
781            .iter()
782            .any(|site| contains_normalized(changed_files, &site.path))
783    });
784}
785
786fn retain_duplicate_exports_by_changed_locations(
787    duplicate_exports: &mut Vec<DuplicateExportFinding>,
788    changed_files: &FxHashSet<PathBuf>,
789) {
790    for duplicate in &mut *duplicate_exports {
791        duplicate
792            .export
793            .locations
794            .retain(|location| contains_normalized(changed_files, &location.path));
795    }
796    duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
797}
798
799fn retain_circular_dependencies_by_changed_file(
800    cycles: &mut Vec<CircularDependencyFinding>,
801    changed_files: &FxHashSet<PathBuf>,
802) {
803    cycles.retain(|cycle| {
804        cycle
805            .cycle
806            .files
807            .iter()
808            .any(|file| contains_normalized(changed_files, file))
809    });
810}
811
812fn retain_re_export_cycles_by_changed_file(
813    cycles: &mut Vec<ReExportCycleFinding>,
814    changed_files: &FxHashSet<PathBuf>,
815) {
816    cycles.retain(|cycle| {
817        cycle
818            .cycle
819            .files
820            .iter()
821            .any(|file| contains_normalized(changed_files, file))
822    });
823}
824
825fn retain_security_findings_by_changed_path(
826    findings: &mut Vec<SecurityFinding>,
827    changed_files: &FxHashSet<PathBuf>,
828) {
829    findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
830}
831
832fn retain_prop_drilling_chains_by_anchor(
833    chains: &mut Vec<PropDrillingChainFinding>,
834    changed_files: &FxHashSet<PathBuf>,
835) {
836    chains.retain(|chain| {
837        chain
838            .chain
839            .hops
840            .first()
841            .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
842    });
843}
844
845fn retain_duplicate_prop_shapes_by_anchor(
846    shapes: &mut Vec<DuplicatePropShapeFinding>,
847    changed_files: &FxHashSet<PathBuf>,
848) {
849    retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
850}
851
852fn retain_by_changed_path<T>(
853    items: &mut Vec<T>,
854    changed_files: &FxHashSet<PathBuf>,
855    path: impl Fn(&T) -> &Path,
856) {
857    items.retain(|item| contains_normalized(changed_files, path(item)));
858}
859
860fn security_finding_touches_changed_path(
861    finding: &SecurityFinding,
862    changed_files: &FxHashSet<PathBuf>,
863) -> bool {
864    contains_normalized(changed_files, &finding.path)
865        || finding
866            .trace
867            .iter()
868            .any(|hop| contains_normalized(changed_files, &hop.path))
869        || finding.reachability.as_ref().is_some_and(|reachability| {
870            reachability
871                .untrusted_source_trace
872                .iter()
873                .any(|hop| contains_normalized(changed_files, &hop.path))
874        })
875}
876
877fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
878    changed_files
879        .iter()
880        .map(|p| dunce::simplified(p).to_path_buf())
881        .collect()
882}
883
884fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
885    normalized.contains(dunce::simplified(path))
886}
887
888fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
889    contains_normalized(normalized, path)
890        || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
891}
892
893/// Scope duplication groups to clone groups touching at least one changed file.
894#[expect(
895    clippy::implicit_hasher,
896    reason = "fallow standardizes on FxHashSet across the workspace"
897)]
898pub fn filter_duplication_by_changed_files(
899    report: &mut DuplicationReport,
900    changed_files: &FxHashSet<PathBuf>,
901    root: &Path,
902) {
903    let cf = normalize_changed_files_set(changed_files);
904    report.clone_groups.retain(|group| {
905        group
906            .instances
907            .iter()
908            .any(|instance| contains_normalized(&cf, &instance.file))
909    });
910    duplicates::refresh_clone_families(report, root);
911    report.stats = duplicates::recompute_stats(report);
912}
913
914#[cfg(test)]
915mod tests {
916    use super::*;
917    use fallow_types::{
918        duplicates::{CloneGroup, CloneInstance, DuplicationStats},
919        output_dead_code::{
920            EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
921            UnusedFileFinding,
922        },
923        results::{
924            DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
925        },
926    };
927
928    #[test]
929    fn validate_git_ref_rejects_option_like_ref() {
930        assert!(validate_git_ref("--upload-pack=evil").is_err());
931        assert!(validate_git_ref("-flag").is_err());
932    }
933
934    #[test]
935    fn validate_git_ref_allows_reflog_relative_date() {
936        assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
937    }
938
939    #[test]
940    fn git_command_clears_parent_git_environment() {
941        let command = git_command(Path::new("."), &["status"]);
942        let envs: Vec<_> = command.get_envs().collect();
943
944        for var in AMBIENT_GIT_ENV_VARS {
945            assert!(
946                envs.iter()
947                    .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
948                "{var} should be cleared from the command env",
949            );
950        }
951    }
952
953    #[test]
954    fn try_get_changed_files_not_a_repository() {
955        let temp = tempfile::tempdir().expect("tempdir");
956        let result = try_get_changed_files(temp.path(), "main");
957        assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
958    }
959
960    #[cfg(unix)]
961    #[test]
962    fn changed_files_preserve_special_filenames() {
963        let repo = tempfile::tempdir().expect("tempdir");
964        for args in [
965            &["init", "--quiet"][..],
966            &["config", "user.email", "test@example.com"][..],
967            &["config", "user.name", "Test User"][..],
968            &["config", "commit.gpgsign", "false"][..],
969        ] {
970            run_git(repo.path(), args);
971        }
972        std::fs::write(repo.path().join("initial.ts"), "initial\n").expect("initial fixture");
973        run_git(repo.path(), &["add", "."]);
974        run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
975        run_git(repo.path(), &["tag", "base"]);
976
977        let canonical_root = repo.path().canonicalize().expect("canonical repo");
978        let special_files = [
979            "src/line\nbreak.ts",
980            "src/space name.ts",
981            "src/quote\"name.ts",
982            "src/back\\slash.ts",
983            "src/unicode-λ.ts",
984        ]
985        .map(|path| canonical_root.join(path));
986        std::fs::create_dir_all(canonical_root.join("src")).expect("source dir");
987        for special in &special_files {
988            std::fs::write(special, "changed\n").expect("special fixture");
989        }
990
991        let changed = try_get_changed_files(repo.path(), "base").expect("changed files");
992        for special in special_files {
993            assert!(
994                changed.contains(&special),
995                "missing {special:?}: {changed:?}"
996            );
997        }
998    }
999
1000    #[cfg(windows)]
1001    #[test]
1002    fn git_path_bytes_use_windows_separators() {
1003        assert_eq!(
1004            git_path_from_bytes(b"src/nested/file.ts"),
1005            PathBuf::from(r"src\nested\file.ts")
1006        );
1007    }
1008
1009    #[test]
1010    fn changed_diff_covers_staged_unstaged_and_untracked_files() {
1011        let repo = tempfile::tempdir().expect("tempdir");
1012        for args in [
1013            &["init", "--quiet"][..],
1014            &["config", "user.email", "test@example.com"][..],
1015            &["config", "user.name", "Test User"][..],
1016            &["config", "commit.gpgsign", "false"][..],
1017        ] {
1018            run_git(repo.path(), args);
1019        }
1020        std::fs::write(repo.path().join("staged.ts"), "old\n").expect("staged fixture");
1021        std::fs::write(repo.path().join("unstaged.ts"), "old\n").expect("unstaged fixture");
1022        run_git(repo.path(), &["add", "."]);
1023        run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1024        run_git(repo.path(), &["tag", "base"]);
1025
1026        std::fs::write(repo.path().join("committed.ts"), "committed\n").expect("committed fixture");
1027        run_git(repo.path(), &["add", "committed.ts"]);
1028        run_git(
1029            repo.path(),
1030            &["commit", "--quiet", "-m", "committed change"],
1031        );
1032
1033        std::fs::write(repo.path().join("staged.ts"), "staged\n").expect("staged edit");
1034        run_git(repo.path(), &["add", "staged.ts"]);
1035        std::fs::write(repo.path().join("unstaged.ts"), "unstaged\n").expect("unstaged edit");
1036        std::fs::write(repo.path().join("untracked.ts"), "untracked\n").expect("untracked edit");
1037
1038        let diff = try_get_changed_diff(repo.path(), "base").expect("complete changeset diff");
1039        let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1040
1041        assert!(diff.contains("b/committed.ts"), "{diff}");
1042        assert!(diff.contains("b/staged.ts"), "{diff}");
1043        assert!(diff.contains("b/unstaged.ts"), "{diff}");
1044        assert!(diff.contains("b/untracked.ts"), "{diff}");
1045        assert_eq!(index.hunk_count(), 4);
1046        assert_eq!(index.net_lines(), 2);
1047    }
1048
1049    fn run_git(root: &Path, args: &[&str]) {
1050        let output = spawn_output(&mut git_command(root, args)).expect("git command");
1051        assert!(
1052            output.status.success(),
1053            "git {args:?} failed: {}",
1054            String::from_utf8_lossy(&output.stderr)
1055        );
1056    }
1057
1058    #[test]
1059    fn untracked_path_arg_uses_forward_slashes() {
1060        assert_eq!(
1061            super::untracked_path_arg(Path::new("src\\nested\\b.ts")),
1062            "src/nested/b.ts"
1063        );
1064        assert_eq!(super::untracked_path_arg(Path::new("src/b.ts")), "src/b.ts");
1065    }
1066
1067    #[test]
1068    fn changed_files_error_describe_matches_core_contract() {
1069        assert_eq!(
1070            ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
1071            "invalid git ref: bad ref"
1072        );
1073        assert_eq!(
1074            ChangedFilesError::GitMissing("not found".to_string()).describe(),
1075            "failed to run git: not found"
1076        );
1077        assert_eq!(
1078            ChangedFilesError::NotARepository.describe(),
1079            "not a git repository"
1080        );
1081        assert!(
1082            ChangedFilesError::GitFailed("unknown revision main".to_string())
1083                .describe()
1084                .contains("fetch-depth: 0")
1085        );
1086    }
1087
1088    #[test]
1089    fn filter_results_keeps_only_changed_file_findings() {
1090        let mut results = AnalysisResults::default();
1091        results
1092            .unused_files
1093            .push(UnusedFileFinding::with_actions(UnusedFile {
1094                path: PathBuf::from("/repo/a.ts"),
1095            }));
1096        results
1097            .unused_files
1098            .push(UnusedFileFinding::with_actions(UnusedFile {
1099                path: PathBuf::from("/repo/b.ts"),
1100            }));
1101        results
1102            .unused_exports
1103            .push(UnusedExportFinding::with_actions(UnusedExport {
1104                path: PathBuf::from("/repo/a.ts"),
1105                export_name: "foo".to_owned(),
1106                is_type_only: false,
1107                line: 1,
1108                col: 0,
1109                span_start: 0,
1110                is_re_export: false,
1111            }));
1112
1113        let mut changed = FxHashSet::default();
1114        changed.insert(PathBuf::from("/repo/a.ts"));
1115
1116        filter_results_by_changed_files(&mut results, &changed);
1117
1118        assert_eq!(results.unused_files.len(), 1);
1119        assert_eq!(
1120            results.unused_files[0].file.path,
1121            PathBuf::from("/repo/a.ts")
1122        );
1123        assert_eq!(results.unused_exports.len(), 1);
1124    }
1125
1126    #[test]
1127    fn filter_results_preserves_graph_global_dependency_findings() {
1128        let mut results = AnalysisResults::default();
1129        results
1130            .unused_dependencies
1131            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1132                package_name: "lodash".to_owned(),
1133                location: DependencyLocation::Dependencies,
1134                path: PathBuf::from("/repo/package.json"),
1135                line: 3,
1136                used_in_workspaces: Vec::new(),
1137            }));
1138
1139        let changed = FxHashSet::default();
1140        filter_results_by_changed_files(&mut results, &changed);
1141
1142        assert_eq!(results.unused_dependencies.len(), 1);
1143    }
1144
1145    #[test]
1146    fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
1147        let mut results = AnalysisResults::default();
1148        results
1149            .empty_catalog_groups
1150            .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1151                catalog_name: "legacy".to_owned(),
1152                path: PathBuf::from("pnpm-workspace.yaml"),
1153                line: 4,
1154            }));
1155
1156        let mut changed = FxHashSet::default();
1157        changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1158
1159        filter_results_by_changed_files(&mut results, &changed);
1160
1161        assert_eq!(results.empty_catalog_groups.len(), 1);
1162    }
1163
1164    #[test]
1165    fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
1166        let mut report = DuplicationReport {
1167            clone_groups: vec![
1168                CloneGroup {
1169                    instances: vec![
1170                        CloneInstance {
1171                            file: PathBuf::from("/repo/a.ts"),
1172                            start_line: 1,
1173                            end_line: 5,
1174                            start_col: 0,
1175                            end_col: 10,
1176                            fragment: "code".to_owned(),
1177                        },
1178                        CloneInstance {
1179                            file: PathBuf::from("/repo/b.ts"),
1180                            start_line: 1,
1181                            end_line: 5,
1182                            start_col: 0,
1183                            end_col: 10,
1184                            fragment: "code".to_owned(),
1185                        },
1186                    ],
1187                    token_count: 20,
1188                    line_count: 5,
1189                    similarity: None,
1190                },
1191                CloneGroup {
1192                    instances: vec![
1193                        CloneInstance {
1194                            file: PathBuf::from("/repo/c.ts"),
1195                            start_line: 1,
1196                            end_line: 5,
1197                            start_col: 0,
1198                            end_col: 10,
1199                            fragment: "other".to_owned(),
1200                        },
1201                        CloneInstance {
1202                            file: PathBuf::from("/repo/d.ts"),
1203                            start_line: 1,
1204                            end_line: 5,
1205                            start_col: 0,
1206                            end_col: 10,
1207                            fragment: "other".to_owned(),
1208                        },
1209                    ],
1210                    token_count: 20,
1211                    line_count: 5,
1212                    similarity: None,
1213                },
1214            ],
1215            clone_families: Vec::new(),
1216            mirrored_directories: Vec::new(),
1217            stats: DuplicationStats {
1218                total_files: 4,
1219                files_with_clones: 4,
1220                total_lines: 100,
1221                duplicated_lines: 20,
1222                total_tokens: 200,
1223                duplicated_tokens: 80,
1224                clone_groups: 2,
1225                clone_families: 0,
1226                clone_instances: 4,
1227                duplication_percentage: 20.0,
1228                clone_groups_below_min_occurrences: 0,
1229                clone_groups_ignored: 0,
1230                near_candidates_skipped: 0,
1231            },
1232        };
1233
1234        let mut changed = FxHashSet::default();
1235        changed.insert(PathBuf::from("/repo/a.ts"));
1236
1237        filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1238
1239        assert_eq!(report.clone_groups.len(), 1);
1240        assert_eq!(report.stats.clone_groups, 1);
1241        assert_eq!(report.stats.clone_instances, 2);
1242    }
1243}