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    /// Stable kebab-case token naming this failure, for machine consumers.
52    ///
53    /// One token per variant, so a consumer can branch on the cause without
54    /// matching prose. Published on the wire as
55    /// `request_outcomes["changed-since"].reason`.
56    #[must_use]
57    pub const fn reason(&self) -> &'static str {
58        match self {
59            Self::InvalidRef(_) => "invalid-ref",
60            Self::GitMissing(_) => "git-missing",
61            Self::NotARepository => "not-a-repository",
62            Self::GitFailed(_) => "git-failed",
63        }
64    }
65
66    /// The full sentence for a run whose `--changed-since` request could not be
67    /// applied: what was asked, what happened instead, and the next step.
68    ///
69    /// The one prose source for this fact. The CLI writes it to stderr and
70    /// publishes the same string on the envelope, so a log a human read and a
71    /// report a script read cannot say different things.
72    ///
73    /// Git's own stderr is folded onto one line: the sentence travels into a
74    /// jq-rendered CI annotation and into a rendered pull-request comment, and
75    /// a multi-line value there splits one fact across two log records.
76    #[must_use]
77    pub fn changed_since_message(&self, git_ref: &str) -> String {
78        let cause = self
79            .describe()
80            .split_whitespace()
81            .collect::<Vec<_>>()
82            .join(" ");
83        format!(
84            "--changed-since '{git_ref}' was ignored because {cause}, so this report covers \
85             the whole project instead of the changed files. {}",
86            self.changed_since_remedy()
87        )
88    }
89
90    /// The next step for [`Self::changed_since_message`], one per cause.
91    const fn changed_since_remedy(&self) -> &'static str {
92        match self {
93            Self::InvalidRef(_) => {
94                "Pass a ref git can resolve, such as a branch name or a commit sha."
95            }
96            Self::GitMissing(_) => {
97                "Install git and make it available on PATH, or drop --changed-since."
98            }
99            Self::NotARepository => {
100                "Run fallow from inside the repository, or drop --changed-since."
101            }
102            Self::GitFailed(_) => {
103                "Verify the ref exists in this repository, and check out with full history."
104            }
105        }
106    }
107}
108
109fn augment_git_failed(stderr: &str) -> String {
110    let lower = stderr.to_ascii_lowercase();
111    if lower.contains("not a valid object name")
112        || lower.contains("unknown revision")
113        || lower.contains("ambiguous argument")
114    {
115        format!(
116            "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
117        )
118    } else {
119        stderr.to_owned()
120    }
121}
122
123/// Install a spawn-hook for changed-file git subprocesses.
124pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
125    let _ = SPAWN_HOOK.set(hook);
126}
127
128/// Validate a user-supplied git ref before passing it to git.
129pub(crate) fn validate_git_ref(s: &str) -> Result<&str, String> {
130    if s.is_empty() {
131        return Err("git ref cannot be empty".to_string());
132    }
133    if s.starts_with('-') {
134        return Err("git ref cannot start with '-'".to_string());
135    }
136    let mut in_braces = false;
137    for c in s.chars() {
138        match c {
139            '{' => in_braces = true,
140            '}' => in_braces = false,
141            ':' | ' ' if in_braces => {}
142            c if c.is_ascii_alphanumeric()
143                || matches!(c, '.' | '_' | '-' | '/' | '~' | '^' | '@' | '{' | '}') => {}
144            _ => return Err(format!("git ref contains disallowed character: '{c}'")),
145        }
146    }
147    if in_braces {
148        return Err("git ref has unclosed '{'".to_string());
149    }
150    Ok(s)
151}
152
153/// Resolve the canonical git toplevel for `cwd`.
154pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
155    let output = spawn_output(&mut git_command(cwd, &["rev-parse", "--show-toplevel"]))
156        .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
157
158    if !output.status.success() {
159        let stderr = String::from_utf8_lossy(&output.stderr);
160        return Err(if stderr.contains("not a git repository") {
161            ChangedFilesError::NotARepository
162        } else {
163            ChangedFilesError::GitFailed(stderr.trim().to_owned())
164        });
165    }
166
167    let raw = String::from_utf8_lossy(&output.stdout);
168    let trimmed = raw.trim();
169    if trimmed.is_empty() {
170        return Err(ChangedFilesError::GitFailed(
171            "git rev-parse --show-toplevel returned empty output".to_owned(),
172        ));
173    }
174
175    let path = PathBuf::from(trimmed);
176    Ok(dunce::canonicalize(&path).unwrap_or(path))
177}
178
179/// Resolve the canonical git common directory for `cwd`.
180pub fn resolve_git_common_dir(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
181    let output = spawn_output(&mut git_command(
182        cwd,
183        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
184    ))
185    .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
186
187    if !output.status.success() {
188        let stderr = String::from_utf8_lossy(&output.stderr);
189        return Err(if stderr.contains("not a git repository") {
190            ChangedFilesError::NotARepository
191        } else {
192            ChangedFilesError::GitFailed(stderr.trim().to_owned())
193        });
194    }
195
196    let raw = String::from_utf8_lossy(&output.stdout);
197    let trimmed = raw.trim();
198    if trimmed.is_empty() {
199        return Err(ChangedFilesError::GitFailed(
200            "git rev-parse --git-common-dir returned empty output".to_owned(),
201        ));
202    }
203
204    let path = PathBuf::from(trimmed);
205    Ok(dunce::canonicalize(&path).unwrap_or(path))
206}
207
208/// Get files changed since a git ref.
209fn try_get_changed_files(
210    root: &Path,
211    git_ref: &str,
212) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
213    validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
214    let toplevel = resolve_git_toplevel(root)?;
215    try_get_changed_files_with_toplevel(root, &toplevel, git_ref)
216}
217
218/// Resolve changed files for a git ref relative to a project root.
219///
220/// # Errors
221///
222/// Returns an error when git cannot resolve the ref or repository state.
223pub fn changed_files(root: &Path, git_ref: &str) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
224    try_get_changed_files(root, git_ref)
225}
226
227/// Get changed files and the git toplevel used to resolve them.
228pub fn try_get_changed_files_with_toplevel(
229    cwd: &Path,
230    toplevel: &Path,
231    git_ref: &str,
232) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
233    validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
234
235    let mut files = collect_git_paths(
236        cwd,
237        toplevel,
238        &[
239            "diff",
240            "--name-only",
241            "-z",
242            "--end-of-options",
243            &format!("{git_ref}...HEAD"),
244        ],
245    )?;
246    files.extend(collect_git_paths(
247        cwd,
248        toplevel,
249        &["diff", "--name-only", "-z", "HEAD"],
250    )?);
251    files.extend(collect_git_paths(
252        cwd,
253        toplevel,
254        &[
255            "ls-files",
256            "--full-name",
257            "--others",
258            "--exclude-standard",
259            "-z",
260        ],
261    )?);
262    Ok(files)
263}
264
265/// A file rename detected between a git ref's merge base and the working
266/// tree, with absolute paths joined onto the git toplevel.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct RenamedFile {
269    /// Absolute pre-rename path (exists in the base tree).
270    pub from: PathBuf,
271    /// Absolute post-rename path (exists in the current tree).
272    pub to: PathBuf,
273}
274
275/// Detect renamed files between a git ref's merge base and the current tree.
276///
277/// Covers the committed range (`<ref>...HEAD`) plus renames staged against
278/// `HEAD`, mirroring the tracked scope of [`try_get_changed_files_with_toplevel`].
279/// A rename committed as `a -> b` and then staged as `b -> c` is composed into
280/// one `a -> c` entry. Copies are intentionally not reported: a copy leaves
281/// the original in place, so findings on the copy are genuinely new.
282///
283/// # Errors
284///
285/// Returns an error when git cannot resolve the ref or repository state.
286pub fn try_get_renamed_files(
287    root: &Path,
288    git_ref: &str,
289) -> Result<Vec<RenamedFile>, ChangedFilesError> {
290    validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
291    let toplevel = resolve_git_toplevel(root)?;
292    let mut renames = collect_git_rename_pairs(
293        root,
294        &toplevel,
295        &[
296            "diff",
297            "--name-status",
298            "-z",
299            "--find-renames",
300            "--end-of-options",
301            &format!("{git_ref}...HEAD"),
302        ],
303    )?;
304    let staged = collect_git_rename_pairs(
305        root,
306        &toplevel,
307        &["diff", "--name-status", "-z", "--find-renames", "HEAD"],
308    )?;
309    for pair in staged {
310        if let Some(chained) = renames.iter_mut().find(|rename| rename.to == pair.from) {
311            chained.to = pair.to;
312        } else {
313            renames.push(pair);
314        }
315    }
316    Ok(renames)
317}
318
319/// Run a `--name-status -z` diff and collect its `R` (rename) pairs.
320///
321/// The `-z` stream alternates status and path fields separated by NUL; rename
322/// and copy statuses (`R<score>` / `C<score>`) carry two path fields (old then
323/// new), every other status carries one.
324fn collect_git_rename_pairs(
325    cwd: &Path,
326    toplevel: &Path,
327    args: &[&str],
328) -> Result<Vec<RenamedFile>, ChangedFilesError> {
329    let output = spawn_output(&mut git_command(cwd, args))
330        .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
331
332    if !output.status.success() {
333        return Err(changed_files_error_from_output(&output));
334    }
335
336    let mut fields = output
337        .stdout
338        .split(|byte| *byte == 0)
339        .filter(|field| !field.is_empty());
340    let mut renames = Vec::new();
341    while let Some(status) = fields.next() {
342        let Some(first_path) = fields.next() else {
343            break;
344        };
345        match status.first() {
346            Some(b'R') => {
347                let Some(second_path) = fields.next() else {
348                    break;
349                };
350                renames.push(RenamedFile {
351                    from: toplevel.join(git_path_from_bytes(first_path)),
352                    to: toplevel.join(git_path_from_bytes(second_path)),
353                });
354            }
355            // Copies also carry two path fields but are conservatively treated
356            // as new files, so only the extra field is consumed.
357            Some(b'C') => {
358                let _ = fields.next();
359            }
360            _ => {}
361        }
362    }
363    Ok(renames)
364}
365
366/// Return the raw git diff from a ref's merge base through the working tree.
367///
368/// The result includes committed, staged, unstaged, and untracked changes so it
369/// covers the same scope as `try_get_changed_files`.
370pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
371    validate_git_ref(git_ref).map_err(ChangedFilesError::InvalidRef)?;
372    let toplevel = resolve_git_toplevel(root)?;
373    let merge_base_output = spawn_output(&mut git_command(root, &["merge-base", git_ref, "HEAD"]))
374        .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
375    if !merge_base_output.status.success() {
376        return Err(changed_files_error_from_output(&merge_base_output));
377    }
378    let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
379        .trim()
380        .to_owned();
381    if merge_base.is_empty() {
382        return Err(ChangedFilesError::GitFailed(
383            "git merge-base returned empty output".to_owned(),
384        ));
385    }
386
387    let output = spawn_output(&mut git_command(
388        root,
389        &[
390            "diff",
391            "--relative",
392            "--unified=0",
393            "--end-of-options",
394            &merge_base,
395        ],
396    ))
397    .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
398
399    if !output.status.success() {
400        return Err(changed_files_error_from_output(&output));
401    }
402
403    let mut diff = String::from_utf8_lossy(&output.stdout).into_owned();
404    append_untracked_diffs(root, &toplevel, &mut diff)?;
405    Ok(diff)
406}
407
408fn append_untracked_diffs(
409    root: &Path,
410    toplevel: &Path,
411    diff: &mut String,
412) -> Result<(), ChangedFilesError> {
413    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
414    let mut untracked: Vec<PathBuf> = collect_git_paths(
415        root,
416        toplevel,
417        &[
418            "ls-files",
419            "--full-name",
420            "--others",
421            "--exclude-standard",
422            "-z",
423        ],
424    )?
425    .into_iter()
426    .filter_map(|path| {
427        path.strip_prefix(&canonical_root)
428            .ok()
429            .map(Path::to_path_buf)
430    })
431    .collect();
432    untracked.sort_unstable();
433
434    #[cfg(windows)]
435    let empty_file = "NUL";
436    #[cfg(not(windows))]
437    let empty_file = "/dev/null";
438
439    for path in untracked {
440        let mut command = git_command(root, &["diff", "--no-index", "--unified=0", "--"]);
441        command.arg(empty_file).arg(untracked_path_arg(&path));
442        let output =
443            spawn_output(&mut command).map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
444        if !output.status.success() && output.status.code() != Some(1) {
445            return Err(changed_files_error_from_output(&output));
446        }
447        if !diff.is_empty() && !diff.ends_with('\n') {
448            diff.push('\n');
449        }
450        diff.push_str(&String::from_utf8_lossy(&output.stdout));
451    }
452    Ok(())
453}
454
455/// Forward-slashed relative path for the `git diff --no-index` argument.
456/// Passing the native form on Windows makes git echo backslashes into the
457/// `+++ b/` header, so every consumer keyed on forward-slashed diff paths
458/// (added-line lookups included) silently misses the file.
459fn untracked_path_arg(path: &Path) -> String {
460    path.to_string_lossy().replace('\\', "/")
461}
462
463fn changed_files_error_from_output(output: &Output) -> ChangedFilesError {
464    let stderr = String::from_utf8_lossy(&output.stderr);
465    if stderr.contains("not a git repository") {
466        ChangedFilesError::NotARepository
467    } else {
468        ChangedFilesError::GitFailed(stderr.trim().to_owned())
469    }
470}
471
472/// Get changed files if git can resolve them, otherwise return `None`.
473#[must_use]
474#[expect(
475    clippy::print_stderr,
476    reason = "intentional user-facing warning for the CLI's --changed-since fallback path; typed callers use try_get_changed_files instead"
477)]
478pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
479    match try_get_changed_files(root, git_ref) {
480        Ok(files) => Some(files),
481        Err(err) => {
482            eprintln!("Warning: {}", err.changed_since_message(git_ref));
483            None
484        }
485    }
486}
487
488fn spawn_output(command: &mut Command) -> std::io::Result<Output> {
489    if let Some(hook) = SPAWN_HOOK.get() {
490        hook(command)
491    } else {
492        command.output()
493    }
494}
495
496fn collect_git_paths(
497    cwd: &Path,
498    toplevel: &Path,
499    args: &[&str],
500) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
501    let output = spawn_output(&mut git_command(cwd, args))
502        .map_err(|e| ChangedFilesError::GitMissing(e.to_string()))?;
503
504    if !output.status.success() {
505        let stderr = String::from_utf8_lossy(&output.stderr);
506        return Err(if stderr.contains("not a git repository") {
507            ChangedFilesError::NotARepository
508        } else {
509            ChangedFilesError::GitFailed(stderr.trim().to_owned())
510        });
511    }
512
513    let files = output
514        .stdout
515        .split(|byte| *byte == 0)
516        .filter(|path| !path.is_empty())
517        .map(git_path_from_bytes)
518        .map(|path| toplevel.join(path))
519        .collect();
520
521    Ok(files)
522}
523
524/// Decode one NUL-separated `git` output path into a `PathBuf`.
525///
526/// Unix keeps the raw bytes, so a non-UTF-8 tree path survives intact.
527/// Shared with `crate::churn`, which decodes the same `git` byte output under
528/// the same semantics.
529#[cfg(unix)]
530pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
531    use std::ffi::OsString;
532    use std::os::unix::ffi::OsStringExt;
533
534    PathBuf::from(OsString::from_vec(path.to_vec()))
535}
536
537/// Windows counterpart: there is no byte-oriented `OsString`, so decode
538/// lossily and rewrite to backslash separators.
539#[cfg(windows)]
540pub(crate) fn git_path_from_bytes(path: &[u8]) -> PathBuf {
541    PathBuf::from(String::from_utf8_lossy(path).replace('/', "\\"))
542}
543
544#[expect(
545    clippy::disallowed_methods,
546    reason = "canonical engine-owned git spawn wrapper for changed-file orchestration"
547)]
548fn git_command(cwd: &Path, args: &[&str]) -> Command {
549    let mut command = Command::new("git");
550    clear_ambient_git_env(&mut command);
551    // Changed-file probes are non-interactive and must not inherit protocol stdin.
552    command.stdin(Stdio::null()).args(args).current_dir(cwd);
553    command
554}
555
556/// Scope dead-code results to findings affected by changed files.
557///
558/// Dependency-level issues stay unfiltered because whether a dependency is
559/// unused is a graph-global fact, not a changed-file-local fact.
560#[expect(
561    clippy::implicit_hasher,
562    reason = "fallow standardizes on FxHashSet across the workspace"
563)]
564pub fn filter_results_by_changed_files(
565    results: &mut AnalysisResults,
566    changed_files: &FxHashSet<PathBuf>,
567) {
568    let cf = normalize_changed_files_set(changed_files);
569    classify_changed_file_filter_fields(results);
570    retain_basic_issue_findings_by_changed_path(results, &cf);
571    retain_graph_findings_by_changed_files(results, &cf);
572    retain_boundary_policy_and_suppression_findings(results, &cf);
573    retain_security_and_workspace_findings(results, &cf);
574    retain_framework_findings_by_changed_files(results, &cf);
575}
576
577fn classify_changed_file_filter_fields(results: &AnalysisResults) {
578    let AnalysisResults {
579        unused_files: _unused_files,
580        unused_exports: _unused_exports,
581        unused_types: _unused_types,
582        private_type_leaks: _private_type_leaks,
583        unused_dependencies: _unused_dependencies,
584        unused_dev_dependencies: _unused_dev_dependencies,
585        unused_optional_dependencies: _unused_optional_dependencies,
586        unused_enum_members: _unused_enum_members,
587        unused_class_members: _unused_class_members,
588        unused_store_members: _unused_store_members,
589        unresolved_imports: _unresolved_imports,
590        unlisted_dependencies: _unlisted_dependencies,
591        duplicate_exports: _duplicate_exports,
592        type_only_dependencies: _type_only_dependencies,
593        test_only_dependencies: _test_only_dependencies,
594        dev_dependencies_in_production: _dev_dependencies_in_production,
595        circular_dependencies: _circular_dependencies,
596        re_export_cycles: _re_export_cycles,
597        boundary_violations: _boundary_violations,
598        boundary_coverage_violations: _boundary_coverage_violations,
599        boundary_call_violations: _boundary_call_violations,
600        policy_violations: _policy_violations,
601        stale_suppressions: _stale_suppressions,
602        unused_catalog_entries: _unused_catalog_entries,
603        empty_catalog_groups: _empty_catalog_groups,
604        unresolved_catalog_references: _unresolved_catalog_references,
605        unused_dependency_overrides: _unused_dependency_overrides,
606        misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
607        invalid_client_exports: _invalid_client_exports,
608        mixed_client_server_barrels: _mixed_client_server_barrels,
609        misplaced_directives: _misplaced_directives,
610        unprovided_injects: _unprovided_injects,
611        unrendered_components: _unrendered_components,
612        route_collisions: _route_collisions,
613        dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
614        unused_component_props: _unused_component_props,
615        unused_component_emits: _unused_component_emits,
616        unused_component_inputs: _unused_component_inputs,
617        unused_component_outputs: _unused_component_outputs,
618        unused_svelte_events: _unused_svelte_events,
619        unused_server_actions: _unused_server_actions,
620        unused_load_data_keys: _unused_load_data_keys,
621        unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
622        prop_drilling_chains: _prop_drilling_chains,
623        thin_wrappers: _thin_wrappers,
624        duplicate_prop_shapes: _duplicate_prop_shapes,
625        suppression_count: _suppression_count,
626        unused_component_props_exempted: _unused_component_props_exempted,
627        active_suppressions: _active_suppressions,
628        feature_flags: _feature_flags,
629        security_findings: _security_findings,
630        security_unresolved_edge_files: _security_unresolved_edge_files,
631        security_unresolved_callee_sites: _security_unresolved_callee_sites,
632        security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
633        export_usages: _export_usages,
634        entry_point_summary: _entry_point_summary,
635        render_fan_in: _render_fan_in,
636        react_component_intel: _react_component_intel,
637        semantic_framework_contracts: _semantic_framework_contracts,
638    } = results;
639}
640
641fn retain_basic_issue_findings_by_changed_path(
642    results: &mut AnalysisResults,
643    changed_files: &FxHashSet<PathBuf>,
644) {
645    retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
646    retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
647        &e.export.path
648    });
649    retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
650    retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
651        &e.leak.path
652    });
653    retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
654        &m.member.path
655    });
656    retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
657        &m.member.path
658    });
659    retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
660        &m.member.path
661    });
662    retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
663        &i.import.path
664    });
665}
666
667fn retain_graph_findings_by_changed_files(
668    results: &mut AnalysisResults,
669    changed_files: &FxHashSet<PathBuf>,
670) {
671    retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
672    retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
673    retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
674    retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
675}
676
677fn retain_boundary_policy_and_suppression_findings(
678    results: &mut AnalysisResults,
679    changed_files: &FxHashSet<PathBuf>,
680) {
681    retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
682        &v.violation.from_path
683    });
684    retain_by_changed_path(
685        &mut results.boundary_coverage_violations,
686        changed_files,
687        |v| &v.violation.path,
688    );
689    retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
690        &v.violation.path
691    });
692    retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
693        &v.violation.path
694    });
695    retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
696}
697
698fn retain_security_and_workspace_findings(
699    results: &mut AnalysisResults,
700    changed_files: &FxHashSet<PathBuf>,
701) {
702    retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
703    retain_by_changed_path(
704        &mut results.security_unresolved_callee_diagnostics,
705        changed_files,
706        |d| &d.path,
707    );
708    retain_by_changed_path(
709        &mut results.unresolved_catalog_references,
710        changed_files,
711        |r| &r.reference.path,
712    );
713    results
714        .empty_catalog_groups
715        .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
716    retain_by_changed_path(
717        &mut results.unused_dependency_overrides,
718        changed_files,
719        |o| &o.entry.path,
720    );
721    retain_by_changed_path(
722        &mut results.misconfigured_dependency_overrides,
723        changed_files,
724        |o| &o.entry.path,
725    );
726}
727
728fn retain_framework_findings_by_changed_files(
729    results: &mut AnalysisResults,
730    changed_files: &FxHashSet<PathBuf>,
731) {
732    retain_client_boundary_findings_by_changed_files(results, changed_files);
733    retain_component_contract_findings_by_changed_files(results, changed_files);
734    retain_react_health_findings_by_changed_files(results, changed_files);
735    retain_nextjs_findings_by_changed_files(results, changed_files);
736}
737
738fn retain_client_boundary_findings_by_changed_files(
739    results: &mut AnalysisResults,
740    changed_files: &FxHashSet<PathBuf>,
741) {
742    let AnalysisResults {
743        invalid_client_exports,
744        mixed_client_server_barrels,
745        misplaced_directives,
746        ..
747    } = results;
748
749    retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
750    retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
751        &b.barrel.path
752    });
753    retain_by_changed_path(misplaced_directives, changed_files, |d| {
754        &d.directive_site.path
755    });
756}
757
758fn retain_component_contract_findings_by_changed_files(
759    results: &mut AnalysisResults,
760    changed_files: &FxHashSet<PathBuf>,
761) {
762    let AnalysisResults {
763        unprovided_injects,
764        unrendered_components,
765        unused_component_props,
766        unused_component_emits,
767        unused_component_inputs,
768        unused_component_outputs,
769        unused_svelte_events,
770        unused_server_actions,
771        unused_load_data_keys,
772        ..
773    } = results;
774
775    retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
776    retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
777    retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
778    retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
779    retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
780    retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
781    retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
782    retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
783    retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
784}
785
786fn retain_react_health_findings_by_changed_files(
787    results: &mut AnalysisResults,
788    changed_files: &FxHashSet<PathBuf>,
789) {
790    let AnalysisResults {
791        prop_drilling_chains,
792        thin_wrappers,
793        duplicate_prop_shapes,
794        ..
795    } = results;
796
797    retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
798    retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
799    retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
800}
801
802fn retain_nextjs_findings_by_changed_files(
803    results: &mut AnalysisResults,
804    changed_files: &FxHashSet<PathBuf>,
805) {
806    let AnalysisResults {
807        route_collisions,
808        dynamic_segment_name_conflicts,
809        ..
810    } = results;
811
812    retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
813    retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
814        &c.conflict.path
815    });
816}
817
818fn retain_unlisted_dependencies_by_import_site(
819    dependencies: &mut Vec<UnlistedDependencyFinding>,
820    changed_files: &FxHashSet<PathBuf>,
821) {
822    dependencies.retain(|dependency| {
823        dependency
824            .dep
825            .imported_from
826            .iter()
827            .any(|site| contains_normalized(changed_files, &site.path))
828    });
829}
830
831fn retain_duplicate_exports_by_changed_locations(
832    duplicate_exports: &mut Vec<DuplicateExportFinding>,
833    changed_files: &FxHashSet<PathBuf>,
834) {
835    for duplicate in &mut *duplicate_exports {
836        duplicate
837            .export
838            .locations
839            .retain(|location| contains_normalized(changed_files, &location.path));
840    }
841    duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
842}
843
844fn retain_circular_dependencies_by_changed_file(
845    cycles: &mut Vec<CircularDependencyFinding>,
846    changed_files: &FxHashSet<PathBuf>,
847) {
848    cycles.retain(|cycle| {
849        cycle
850            .cycle
851            .files
852            .iter()
853            .any(|file| contains_normalized(changed_files, file))
854    });
855}
856
857fn retain_re_export_cycles_by_changed_file(
858    cycles: &mut Vec<ReExportCycleFinding>,
859    changed_files: &FxHashSet<PathBuf>,
860) {
861    cycles.retain(|cycle| {
862        cycle
863            .cycle
864            .files
865            .iter()
866            .any(|file| contains_normalized(changed_files, file))
867    });
868}
869
870fn retain_security_findings_by_changed_path(
871    findings: &mut Vec<SecurityFinding>,
872    changed_files: &FxHashSet<PathBuf>,
873) {
874    findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
875}
876
877fn retain_prop_drilling_chains_by_anchor(
878    chains: &mut Vec<PropDrillingChainFinding>,
879    changed_files: &FxHashSet<PathBuf>,
880) {
881    chains.retain(|chain| {
882        chain
883            .chain
884            .hops
885            .first()
886            .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
887    });
888}
889
890fn retain_duplicate_prop_shapes_by_anchor(
891    shapes: &mut Vec<DuplicatePropShapeFinding>,
892    changed_files: &FxHashSet<PathBuf>,
893) {
894    retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
895}
896
897fn retain_by_changed_path<T>(
898    items: &mut Vec<T>,
899    changed_files: &FxHashSet<PathBuf>,
900    path: impl Fn(&T) -> &Path,
901) {
902    items.retain(|item| contains_normalized(changed_files, path(item)));
903}
904
905fn security_finding_touches_changed_path(
906    finding: &SecurityFinding,
907    changed_files: &FxHashSet<PathBuf>,
908) -> bool {
909    contains_normalized(changed_files, &finding.path)
910        || finding
911            .trace
912            .iter()
913            .any(|hop| contains_normalized(changed_files, &hop.path))
914        || finding.reachability.as_ref().is_some_and(|reachability| {
915            reachability
916                .untrusted_source_trace
917                .iter()
918                .any(|hop| contains_normalized(changed_files, &hop.path))
919        })
920}
921
922fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
923    changed_files
924        .iter()
925        .map(|p| dunce::simplified(p).to_path_buf())
926        .collect()
927}
928
929fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
930    normalized.contains(dunce::simplified(path))
931}
932
933fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
934    contains_normalized(normalized, path)
935        || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
936}
937
938/// Scope duplication groups to clone groups touching at least one changed file.
939#[expect(
940    clippy::implicit_hasher,
941    reason = "fallow standardizes on FxHashSet across the workspace"
942)]
943pub fn filter_duplication_by_changed_files(
944    report: &mut DuplicationReport,
945    changed_files: &FxHashSet<PathBuf>,
946    root: &Path,
947) {
948    let cf = normalize_changed_files_set(changed_files);
949    report.clone_groups.retain(|group| {
950        group
951            .instances
952            .iter()
953            .any(|instance| contains_normalized(&cf, &instance.file))
954    });
955    duplicates::refresh_clone_families(report, root);
956    report.stats = duplicates::recompute_stats(report);
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962    use fallow_types::{
963        duplicates::{CloneGroup, CloneInstance, DuplicationStats},
964        output_dead_code::{
965            EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
966            UnusedFileFinding,
967        },
968        results::{
969            DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
970        },
971    };
972
973    #[test]
974    fn validate_git_ref_rejects_option_like_ref() {
975        assert!(validate_git_ref("--upload-pack=evil").is_err());
976        assert!(validate_git_ref("-flag").is_err());
977    }
978
979    #[test]
980    fn validate_git_ref_allows_reflog_relative_date() {
981        assert!(validate_git_ref("HEAD@{1 week ago}").is_ok());
982    }
983
984    /// One reason token per cause, because each carries a different remedy and
985    /// a consumer branches on the token rather than on the prose.
986    #[test]
987    fn every_changed_files_cause_reports_its_own_reason() {
988        let reasons = [
989            ChangedFilesError::InvalidRef("unclosed brace".to_owned()).reason(),
990            ChangedFilesError::GitMissing("no such file".to_owned()).reason(),
991            ChangedFilesError::NotARepository.reason(),
992            ChangedFilesError::GitFailed("unknown revision".to_owned()).reason(),
993        ];
994        assert_eq!(
995            reasons,
996            [
997                "invalid-ref",
998                "git-missing",
999                "not-a-repository",
1000                "git-failed"
1001            ]
1002        );
1003        let unique: std::collections::BTreeSet<&str> = reasons.iter().copied().collect();
1004        assert_eq!(
1005            unique.len(),
1006            reasons.len(),
1007            "two causes must not share a token"
1008        );
1009    }
1010
1011    /// The published sentence and the stderr line are the same string, so the
1012    /// remedy cannot drift between a log a human read and a report a script
1013    /// read. It names the ref, says the report widened, and ends with a next
1014    /// step.
1015    #[test]
1016    fn the_changed_since_message_names_the_ref_the_widening_and_the_next_step() {
1017        let message = ChangedFilesError::NotARepository.changed_since_message("origin/main");
1018        assert!(
1019            message.contains("--changed-since 'origin/main'"),
1020            "{message}"
1021        );
1022        assert!(message.contains("covers the whole project"), "{message}");
1023        assert!(message.ends_with("or drop --changed-since."), "{message}");
1024    }
1025
1026    /// Git writes multi-line stderr, and the sentence travels into a CI
1027    /// annotation and a rendered comment where a newline splits one fact
1028    /// across two records.
1029    #[test]
1030    fn the_changed_since_message_folds_git_stderr_onto_one_line() {
1031        let message = ChangedFilesError::GitFailed(
1032            "fatal: ambiguous argument 'x'\nUse '--' to separate paths".to_owned(),
1033        )
1034        .changed_since_message("x");
1035        assert!(!message.contains('\n'), "{message}");
1036        assert!(
1037            message.contains("ambiguous argument 'x' Use '--'"),
1038            "{message}"
1039        );
1040    }
1041
1042    #[test]
1043    fn git_command_clears_parent_git_environment() {
1044        let command = git_command(Path::new("."), &["status"]);
1045        let envs: Vec<_> = command.get_envs().collect();
1046
1047        for var in AMBIENT_GIT_ENV_VARS {
1048            assert!(
1049                envs.iter()
1050                    .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
1051                "{var} should be cleared from the command env",
1052            );
1053        }
1054    }
1055
1056    #[test]
1057    fn try_get_changed_files_not_a_repository() {
1058        let temp = tempfile::tempdir().expect("tempdir");
1059        let result = try_get_changed_files(temp.path(), "main");
1060        assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1061    }
1062
1063    #[cfg(unix)]
1064    #[test]
1065    fn changed_files_preserve_special_filenames() {
1066        let repo = tempfile::tempdir().expect("tempdir");
1067        for args in [
1068            &["init", "--quiet"][..],
1069            &["config", "user.email", "test@example.com"][..],
1070            &["config", "user.name", "Test User"][..],
1071            &["config", "commit.gpgsign", "false"][..],
1072        ] {
1073            run_git(repo.path(), args);
1074        }
1075        std::fs::write(repo.path().join("initial.ts"), "initial\n").expect("initial fixture");
1076        run_git(repo.path(), &["add", "."]);
1077        run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1078        run_git(repo.path(), &["tag", "base"]);
1079
1080        let canonical_root = repo.path().canonicalize().expect("canonical repo");
1081        let special_files = [
1082            "src/line\nbreak.ts",
1083            "src/space name.ts",
1084            "src/quote\"name.ts",
1085            "src/back\\slash.ts",
1086            "src/unicode-λ.ts",
1087        ]
1088        .map(|path| canonical_root.join(path));
1089        std::fs::create_dir_all(canonical_root.join("src")).expect("source dir");
1090        for special in &special_files {
1091            std::fs::write(special, "changed\n").expect("special fixture");
1092        }
1093
1094        let changed = try_get_changed_files(repo.path(), "base").expect("changed files");
1095        for special in special_files {
1096            assert!(
1097                changed.contains(&special),
1098                "missing {special:?}: {changed:?}"
1099            );
1100        }
1101    }
1102
1103    #[cfg(windows)]
1104    #[test]
1105    fn git_path_bytes_use_windows_separators() {
1106        assert_eq!(
1107            git_path_from_bytes(b"src/nested/file.ts"),
1108            PathBuf::from(r"src\nested\file.ts")
1109        );
1110    }
1111
1112    #[test]
1113    fn changed_diff_covers_staged_unstaged_and_untracked_files() {
1114        let repo = tempfile::tempdir().expect("tempdir");
1115        for args in [
1116            &["init", "--quiet"][..],
1117            &["config", "user.email", "test@example.com"][..],
1118            &["config", "user.name", "Test User"][..],
1119            &["config", "commit.gpgsign", "false"][..],
1120        ] {
1121            run_git(repo.path(), args);
1122        }
1123        std::fs::write(repo.path().join("staged.ts"), "old\n").expect("staged fixture");
1124        std::fs::write(repo.path().join("unstaged.ts"), "old\n").expect("unstaged fixture");
1125        run_git(repo.path(), &["add", "."]);
1126        run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1127        run_git(repo.path(), &["tag", "base"]);
1128
1129        std::fs::write(repo.path().join("committed.ts"), "committed\n").expect("committed fixture");
1130        run_git(repo.path(), &["add", "committed.ts"]);
1131        run_git(
1132            repo.path(),
1133            &["commit", "--quiet", "-m", "committed change"],
1134        );
1135
1136        std::fs::write(repo.path().join("staged.ts"), "staged\n").expect("staged edit");
1137        run_git(repo.path(), &["add", "staged.ts"]);
1138        std::fs::write(repo.path().join("unstaged.ts"), "unstaged\n").expect("unstaged edit");
1139        std::fs::write(repo.path().join("untracked.ts"), "untracked\n").expect("untracked edit");
1140
1141        let diff = try_get_changed_diff(repo.path(), "base").expect("complete changeset diff");
1142        let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1143
1144        assert!(diff.contains("b/committed.ts"), "{diff}");
1145        assert!(diff.contains("b/staged.ts"), "{diff}");
1146        assert!(diff.contains("b/unstaged.ts"), "{diff}");
1147        assert!(diff.contains("b/untracked.ts"), "{diff}");
1148        assert_eq!(index.hunk_count(), 4);
1149        assert_eq!(index.net_lines(), 2);
1150    }
1151
1152    fn run_git(root: &Path, args: &[&str]) {
1153        let output = spawn_output(&mut git_command(root, args)).expect("git command");
1154        assert!(
1155            output.status.success(),
1156            "git {args:?} failed: {}",
1157            String::from_utf8_lossy(&output.stderr)
1158        );
1159    }
1160
1161    #[test]
1162    fn untracked_path_arg_uses_forward_slashes() {
1163        assert_eq!(
1164            super::untracked_path_arg(Path::new("src\\nested\\b.ts")),
1165            "src/nested/b.ts"
1166        );
1167        assert_eq!(super::untracked_path_arg(Path::new("src/b.ts")), "src/b.ts");
1168    }
1169
1170    #[test]
1171    fn changed_files_error_describe_matches_core_contract() {
1172        assert_eq!(
1173            ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
1174            "invalid git ref: bad ref"
1175        );
1176        assert_eq!(
1177            ChangedFilesError::GitMissing("not found".to_string()).describe(),
1178            "failed to run git: not found"
1179        );
1180        assert_eq!(
1181            ChangedFilesError::NotARepository.describe(),
1182            "not a git repository"
1183        );
1184        assert!(
1185            ChangedFilesError::GitFailed("unknown revision main".to_string())
1186                .describe()
1187                .contains("fetch-depth: 0")
1188        );
1189    }
1190
1191    #[test]
1192    fn filter_results_keeps_only_changed_file_findings() {
1193        let mut results = AnalysisResults::default();
1194        results
1195            .unused_files
1196            .push(UnusedFileFinding::with_actions(UnusedFile {
1197                path: PathBuf::from("/repo/a.ts"),
1198            }));
1199        results
1200            .unused_files
1201            .push(UnusedFileFinding::with_actions(UnusedFile {
1202                path: PathBuf::from("/repo/b.ts"),
1203            }));
1204        results
1205            .unused_exports
1206            .push(UnusedExportFinding::with_actions(UnusedExport {
1207                path: PathBuf::from("/repo/a.ts"),
1208                export_name: "foo".to_owned(),
1209                is_type_only: false,
1210                line: 1,
1211                col: 0,
1212                span_start: 0,
1213                is_re_export: false,
1214            }));
1215
1216        let mut changed = FxHashSet::default();
1217        changed.insert(PathBuf::from("/repo/a.ts"));
1218
1219        filter_results_by_changed_files(&mut results, &changed);
1220
1221        assert_eq!(results.unused_files.len(), 1);
1222        assert_eq!(
1223            results.unused_files[0].file.path,
1224            PathBuf::from("/repo/a.ts")
1225        );
1226        assert_eq!(results.unused_exports.len(), 1);
1227    }
1228
1229    #[test]
1230    fn filter_results_preserves_graph_global_dependency_findings() {
1231        let mut results = AnalysisResults::default();
1232        results
1233            .unused_dependencies
1234            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1235                package_name: "lodash".to_owned(),
1236                location: DependencyLocation::Dependencies,
1237                path: PathBuf::from("/repo/package.json"),
1238                line: 3,
1239                used_in_workspaces: Vec::new(),
1240            }));
1241
1242        let changed = FxHashSet::default();
1243        filter_results_by_changed_files(&mut results, &changed);
1244
1245        assert_eq!(results.unused_dependencies.len(), 1);
1246    }
1247
1248    #[test]
1249    fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
1250        let mut results = AnalysisResults::default();
1251        results
1252            .empty_catalog_groups
1253            .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1254                catalog_name: "legacy".to_owned(),
1255                path: PathBuf::from("pnpm-workspace.yaml"),
1256                line: 4,
1257            }));
1258
1259        let mut changed = FxHashSet::default();
1260        changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1261
1262        filter_results_by_changed_files(&mut results, &changed);
1263
1264        assert_eq!(results.empty_catalog_groups.len(), 1);
1265    }
1266
1267    #[test]
1268    fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
1269        let mut report = DuplicationReport {
1270            clone_groups: vec![
1271                CloneGroup {
1272                    instances: vec![
1273                        CloneInstance {
1274                            file: PathBuf::from("/repo/a.ts"),
1275                            start_line: 1,
1276                            end_line: 5,
1277                            start_col: 0,
1278                            end_col: 10,
1279                            fragment: "code".to_owned(),
1280                        },
1281                        CloneInstance {
1282                            file: PathBuf::from("/repo/b.ts"),
1283                            start_line: 1,
1284                            end_line: 5,
1285                            start_col: 0,
1286                            end_col: 10,
1287                            fragment: "code".to_owned(),
1288                        },
1289                    ],
1290                    token_count: 20,
1291                    line_count: 5,
1292                    similarity: None,
1293                },
1294                CloneGroup {
1295                    instances: vec![
1296                        CloneInstance {
1297                            file: PathBuf::from("/repo/c.ts"),
1298                            start_line: 1,
1299                            end_line: 5,
1300                            start_col: 0,
1301                            end_col: 10,
1302                            fragment: "other".to_owned(),
1303                        },
1304                        CloneInstance {
1305                            file: PathBuf::from("/repo/d.ts"),
1306                            start_line: 1,
1307                            end_line: 5,
1308                            start_col: 0,
1309                            end_col: 10,
1310                            fragment: "other".to_owned(),
1311                        },
1312                    ],
1313                    token_count: 20,
1314                    line_count: 5,
1315                    similarity: None,
1316                },
1317            ],
1318            clone_families: Vec::new(),
1319            mirrored_directories: Vec::new(),
1320            stats: DuplicationStats {
1321                total_files: 4,
1322                files_with_clones: 4,
1323                total_lines: 100,
1324                duplicated_lines: 20,
1325                total_tokens: 200,
1326                duplicated_tokens: 80,
1327                clone_groups: 2,
1328                clone_families: 0,
1329                clone_instances: 4,
1330                duplication_percentage: 20.0,
1331                clone_groups_below_min_occurrences: 0,
1332                clone_groups_ignored: 0,
1333                near_candidates_skipped: 0,
1334            },
1335        };
1336
1337        let mut changed = FxHashSet::default();
1338        changed.insert(PathBuf::from("/repo/a.ts"));
1339
1340        filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1341
1342        assert_eq!(report.clone_groups.len(), 1);
1343        assert_eq!(report.stats.clone_groups, 1);
1344        assert_eq!(report.stats.clone_instances, 2);
1345    }
1346}