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        deprecated_exports_in_use: _deprecated_exports_in_use,
584        unused_dependencies: _unused_dependencies,
585        unused_dev_dependencies: _unused_dev_dependencies,
586        unused_optional_dependencies: _unused_optional_dependencies,
587        unused_enum_members: _unused_enum_members,
588        unused_class_members: _unused_class_members,
589        unused_store_members: _unused_store_members,
590        unresolved_imports: _unresolved_imports,
591        unlisted_dependencies: _unlisted_dependencies,
592        duplicate_exports: _duplicate_exports,
593        type_only_dependencies: _type_only_dependencies,
594        test_only_dependencies: _test_only_dependencies,
595        dev_dependencies_in_production: _dev_dependencies_in_production,
596        circular_dependencies: _circular_dependencies,
597        re_export_cycles: _re_export_cycles,
598        boundary_violations: _boundary_violations,
599        boundary_coverage_violations: _boundary_coverage_violations,
600        boundary_call_violations: _boundary_call_violations,
601        policy_violations: _policy_violations,
602        stale_suppressions: _stale_suppressions,
603        unused_catalog_entries: _unused_catalog_entries,
604        empty_catalog_groups: _empty_catalog_groups,
605        unresolved_catalog_references: _unresolved_catalog_references,
606        unused_dependency_overrides: _unused_dependency_overrides,
607        misconfigured_dependency_overrides: _misconfigured_dependency_overrides,
608        invalid_client_exports: _invalid_client_exports,
609        mixed_client_server_barrels: _mixed_client_server_barrels,
610        misplaced_directives: _misplaced_directives,
611        unprovided_injects: _unprovided_injects,
612        unrendered_components: _unrendered_components,
613        route_collisions: _route_collisions,
614        dynamic_segment_name_conflicts: _dynamic_segment_name_conflicts,
615        unused_component_props: _unused_component_props,
616        unused_component_emits: _unused_component_emits,
617        unused_component_inputs: _unused_component_inputs,
618        unused_component_outputs: _unused_component_outputs,
619        unused_svelte_events: _unused_svelte_events,
620        unused_server_actions: _unused_server_actions,
621        unused_load_data_keys: _unused_load_data_keys,
622        unused_load_data_keys_global_abstain: _unused_load_data_keys_global_abstain,
623        prop_drilling_chains: _prop_drilling_chains,
624        thin_wrappers: _thin_wrappers,
625        duplicate_prop_shapes: _duplicate_prop_shapes,
626        suppression_count: _suppression_count,
627        unused_component_props_exempted: _unused_component_props_exempted,
628        active_suppressions: _active_suppressions,
629        feature_flags: _feature_flags,
630        security_findings: _security_findings,
631        security_unresolved_edge_files: _security_unresolved_edge_files,
632        security_unresolved_callee_sites: _security_unresolved_callee_sites,
633        security_unresolved_callee_diagnostics: _security_unresolved_callee_diagnostics,
634        export_usages: _export_usages,
635        entry_point_summary: _entry_point_summary,
636        render_fan_in: _render_fan_in,
637        react_component_intel: _react_component_intel,
638        semantic_framework_contracts: _semantic_framework_contracts,
639    } = results;
640}
641
642fn retain_basic_issue_findings_by_changed_path(
643    results: &mut AnalysisResults,
644    changed_files: &FxHashSet<PathBuf>,
645) {
646    retain_by_changed_path(&mut results.unused_files, changed_files, |f| &f.file.path);
647    retain_by_changed_path(&mut results.unused_exports, changed_files, |e| {
648        &e.export.path
649    });
650    retain_by_changed_path(&mut results.unused_types, changed_files, |e| &e.export.path);
651    retain_by_changed_path(&mut results.private_type_leaks, changed_files, |e| {
652        &e.leak.path
653    });
654    retain_by_changed_path(&mut results.deprecated_exports_in_use, changed_files, |e| {
655        &e.export.path
656    });
657    retain_by_changed_path(&mut results.unused_enum_members, changed_files, |m| {
658        &m.member.path
659    });
660    retain_by_changed_path(&mut results.unused_class_members, changed_files, |m| {
661        &m.member.path
662    });
663    retain_by_changed_path(&mut results.unused_store_members, changed_files, |m| {
664        &m.member.path
665    });
666    retain_by_changed_path(&mut results.unresolved_imports, changed_files, |i| {
667        &i.import.path
668    });
669}
670
671fn retain_graph_findings_by_changed_files(
672    results: &mut AnalysisResults,
673    changed_files: &FxHashSet<PathBuf>,
674) {
675    retain_unlisted_dependencies_by_import_site(&mut results.unlisted_dependencies, changed_files);
676    retain_duplicate_exports_by_changed_locations(&mut results.duplicate_exports, changed_files);
677    retain_circular_dependencies_by_changed_file(&mut results.circular_dependencies, changed_files);
678    retain_re_export_cycles_by_changed_file(&mut results.re_export_cycles, changed_files);
679}
680
681fn retain_boundary_policy_and_suppression_findings(
682    results: &mut AnalysisResults,
683    changed_files: &FxHashSet<PathBuf>,
684) {
685    retain_by_changed_path(&mut results.boundary_violations, changed_files, |v| {
686        &v.violation.from_path
687    });
688    retain_by_changed_path(
689        &mut results.boundary_coverage_violations,
690        changed_files,
691        |v| &v.violation.path,
692    );
693    retain_by_changed_path(&mut results.boundary_call_violations, changed_files, |v| {
694        &v.violation.path
695    });
696    retain_by_changed_path(&mut results.policy_violations, changed_files, |v| {
697        &v.violation.path
698    });
699    retain_by_changed_path(&mut results.stale_suppressions, changed_files, |s| &s.path);
700}
701
702fn retain_security_and_workspace_findings(
703    results: &mut AnalysisResults,
704    changed_files: &FxHashSet<PathBuf>,
705) {
706    retain_security_findings_by_changed_path(&mut results.security_findings, changed_files);
707    retain_by_changed_path(
708        &mut results.security_unresolved_callee_diagnostics,
709        changed_files,
710        |d| &d.path,
711    );
712    retain_by_changed_path(
713        &mut results.unresolved_catalog_references,
714        changed_files,
715        |r| &r.reference.path,
716    );
717    results
718        .empty_catalog_groups
719        .retain(|g| normalized_set_contains_path(changed_files, &g.group.path));
720    retain_by_changed_path(
721        &mut results.unused_dependency_overrides,
722        changed_files,
723        |o| &o.entry.path,
724    );
725    retain_by_changed_path(
726        &mut results.misconfigured_dependency_overrides,
727        changed_files,
728        |o| &o.entry.path,
729    );
730}
731
732fn retain_framework_findings_by_changed_files(
733    results: &mut AnalysisResults,
734    changed_files: &FxHashSet<PathBuf>,
735) {
736    retain_client_boundary_findings_by_changed_files(results, changed_files);
737    retain_component_contract_findings_by_changed_files(results, changed_files);
738    retain_react_health_findings_by_changed_files(results, changed_files);
739    retain_nextjs_findings_by_changed_files(results, changed_files);
740}
741
742fn retain_client_boundary_findings_by_changed_files(
743    results: &mut AnalysisResults,
744    changed_files: &FxHashSet<PathBuf>,
745) {
746    let AnalysisResults {
747        invalid_client_exports,
748        mixed_client_server_barrels,
749        misplaced_directives,
750        ..
751    } = results;
752
753    retain_by_changed_path(invalid_client_exports, changed_files, |e| &e.export.path);
754    retain_by_changed_path(mixed_client_server_barrels, changed_files, |b| {
755        &b.barrel.path
756    });
757    retain_by_changed_path(misplaced_directives, changed_files, |d| {
758        &d.directive_site.path
759    });
760}
761
762fn retain_component_contract_findings_by_changed_files(
763    results: &mut AnalysisResults,
764    changed_files: &FxHashSet<PathBuf>,
765) {
766    let AnalysisResults {
767        unprovided_injects,
768        unrendered_components,
769        unused_component_props,
770        unused_component_emits,
771        unused_component_inputs,
772        unused_component_outputs,
773        unused_svelte_events,
774        unused_server_actions,
775        unused_load_data_keys,
776        ..
777    } = results;
778
779    retain_by_changed_path(unprovided_injects, changed_files, |i| &i.inject.path);
780    retain_by_changed_path(unrendered_components, changed_files, |c| &c.component.path);
781    retain_by_changed_path(unused_component_props, changed_files, |p| &p.prop.path);
782    retain_by_changed_path(unused_component_emits, changed_files, |e| &e.emit.path);
783    retain_by_changed_path(unused_component_inputs, changed_files, |i| &i.input.path);
784    retain_by_changed_path(unused_component_outputs, changed_files, |o| &o.output.path);
785    retain_by_changed_path(unused_svelte_events, changed_files, |e| &e.event.path);
786    retain_by_changed_path(unused_server_actions, changed_files, |a| &a.action.path);
787    retain_by_changed_path(unused_load_data_keys, changed_files, |k| &k.key.path);
788}
789
790fn retain_react_health_findings_by_changed_files(
791    results: &mut AnalysisResults,
792    changed_files: &FxHashSet<PathBuf>,
793) {
794    let AnalysisResults {
795        prop_drilling_chains,
796        thin_wrappers,
797        duplicate_prop_shapes,
798        ..
799    } = results;
800
801    retain_prop_drilling_chains_by_anchor(prop_drilling_chains, changed_files);
802    retain_by_changed_path(thin_wrappers, changed_files, |w| &w.wrapper.file);
803    retain_duplicate_prop_shapes_by_anchor(duplicate_prop_shapes, changed_files);
804}
805
806fn retain_nextjs_findings_by_changed_files(
807    results: &mut AnalysisResults,
808    changed_files: &FxHashSet<PathBuf>,
809) {
810    let AnalysisResults {
811        route_collisions,
812        dynamic_segment_name_conflicts,
813        ..
814    } = results;
815
816    retain_by_changed_path(route_collisions, changed_files, |c| &c.collision.path);
817    retain_by_changed_path(dynamic_segment_name_conflicts, changed_files, |c| {
818        &c.conflict.path
819    });
820}
821
822fn retain_unlisted_dependencies_by_import_site(
823    dependencies: &mut Vec<UnlistedDependencyFinding>,
824    changed_files: &FxHashSet<PathBuf>,
825) {
826    dependencies.retain(|dependency| {
827        dependency
828            .dep
829            .imported_from
830            .iter()
831            .any(|site| contains_normalized(changed_files, &site.path))
832    });
833}
834
835fn retain_duplicate_exports_by_changed_locations(
836    duplicate_exports: &mut Vec<DuplicateExportFinding>,
837    changed_files: &FxHashSet<PathBuf>,
838) {
839    for duplicate in &mut *duplicate_exports {
840        duplicate
841            .export
842            .locations
843            .retain(|location| contains_normalized(changed_files, &location.path));
844    }
845    duplicate_exports.retain(|duplicate| duplicate.export.locations.len() >= 2);
846}
847
848fn retain_circular_dependencies_by_changed_file(
849    cycles: &mut Vec<CircularDependencyFinding>,
850    changed_files: &FxHashSet<PathBuf>,
851) {
852    cycles.retain(|cycle| {
853        cycle
854            .cycle
855            .files
856            .iter()
857            .any(|file| contains_normalized(changed_files, file))
858    });
859}
860
861fn retain_re_export_cycles_by_changed_file(
862    cycles: &mut Vec<ReExportCycleFinding>,
863    changed_files: &FxHashSet<PathBuf>,
864) {
865    cycles.retain(|cycle| {
866        cycle
867            .cycle
868            .files
869            .iter()
870            .any(|file| contains_normalized(changed_files, file))
871    });
872}
873
874fn retain_security_findings_by_changed_path(
875    findings: &mut Vec<SecurityFinding>,
876    changed_files: &FxHashSet<PathBuf>,
877) {
878    findings.retain(|finding| security_finding_touches_changed_path(finding, changed_files));
879}
880
881fn retain_prop_drilling_chains_by_anchor(
882    chains: &mut Vec<PropDrillingChainFinding>,
883    changed_files: &FxHashSet<PathBuf>,
884) {
885    chains.retain(|chain| {
886        chain
887            .chain
888            .hops
889            .first()
890            .is_some_and(|hop| contains_normalized(changed_files, &hop.file))
891    });
892}
893
894fn retain_duplicate_prop_shapes_by_anchor(
895    shapes: &mut Vec<DuplicatePropShapeFinding>,
896    changed_files: &FxHashSet<PathBuf>,
897) {
898    retain_by_changed_path(shapes, changed_files, |shape| &shape.shape.file);
899}
900
901fn retain_by_changed_path<T>(
902    items: &mut Vec<T>,
903    changed_files: &FxHashSet<PathBuf>,
904    path: impl Fn(&T) -> &Path,
905) {
906    items.retain(|item| contains_normalized(changed_files, path(item)));
907}
908
909fn security_finding_touches_changed_path(
910    finding: &SecurityFinding,
911    changed_files: &FxHashSet<PathBuf>,
912) -> bool {
913    contains_normalized(changed_files, &finding.path)
914        || finding
915            .trace
916            .iter()
917            .any(|hop| contains_normalized(changed_files, &hop.path))
918        || finding.reachability.as_ref().is_some_and(|reachability| {
919            reachability
920                .untrusted_source_trace
921                .iter()
922                .any(|hop| contains_normalized(changed_files, &hop.path))
923        })
924}
925
926fn normalize_changed_files_set(changed_files: &FxHashSet<PathBuf>) -> FxHashSet<PathBuf> {
927    changed_files
928        .iter()
929        .map(|p| dunce::simplified(p).to_path_buf())
930        .collect()
931}
932
933fn contains_normalized(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
934    normalized.contains(dunce::simplified(path))
935}
936
937fn normalized_set_contains_path(normalized: &FxHashSet<PathBuf>, path: &Path) -> bool {
938    contains_normalized(normalized, path)
939        || (path.is_relative() && normalized.iter().any(|changed| changed.ends_with(path)))
940}
941
942/// Scope duplication groups to clone groups touching at least one changed file.
943#[expect(
944    clippy::implicit_hasher,
945    reason = "fallow standardizes on FxHashSet across the workspace"
946)]
947pub fn filter_duplication_by_changed_files(
948    report: &mut DuplicationReport,
949    changed_files: &FxHashSet<PathBuf>,
950    root: &Path,
951) {
952    let cf = normalize_changed_files_set(changed_files);
953    report.clone_groups.retain(|group| {
954        group
955            .instances
956            .iter()
957            .any(|instance| contains_normalized(&cf, &instance.file))
958    });
959    duplicates::refresh_clone_families(report, root);
960    report.stats = duplicates::recompute_stats(report);
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966    use fallow_types::{
967        duplicates::{CloneGroup, CloneInstance, DuplicationStats},
968        output_dead_code::{
969            EmptyCatalogGroupFinding, UnusedDependencyFinding, UnusedExportFinding,
970            UnusedFileFinding,
971        },
972        results::{
973            DependencyLocation, EmptyCatalogGroup, UnusedDependency, UnusedExport, UnusedFile,
974        },
975    };
976
977    /// One reason token per cause, because each carries a different remedy and
978    /// a consumer branches on the token rather than on the prose.
979    #[test]
980    fn every_changed_files_cause_reports_its_own_reason() {
981        let reasons = [
982            ChangedFilesError::InvalidRef("unclosed brace".to_owned()).reason(),
983            ChangedFilesError::GitMissing("no such file".to_owned()).reason(),
984            ChangedFilesError::NotARepository.reason(),
985            ChangedFilesError::GitFailed("unknown revision".to_owned()).reason(),
986        ];
987        assert_eq!(
988            reasons,
989            [
990                "invalid-ref",
991                "git-missing",
992                "not-a-repository",
993                "git-failed"
994            ]
995        );
996        let unique: std::collections::BTreeSet<&str> = reasons.iter().copied().collect();
997        assert_eq!(
998            unique.len(),
999            reasons.len(),
1000            "two causes must not share a token"
1001        );
1002    }
1003
1004    /// The published sentence and the stderr line are the same string, so the
1005    /// remedy cannot drift between a log a human read and a report a script
1006    /// read. It names the ref, says the report widened, and ends with a next
1007    /// step.
1008    #[test]
1009    fn the_changed_since_message_names_the_ref_the_widening_and_the_next_step() {
1010        let message = ChangedFilesError::NotARepository.changed_since_message("origin/main");
1011        assert!(
1012            message.contains("--changed-since 'origin/main'"),
1013            "{message}"
1014        );
1015        assert!(message.contains("covers the whole project"), "{message}");
1016        assert!(message.ends_with("or drop --changed-since."), "{message}");
1017    }
1018
1019    /// Git writes multi-line stderr, and the sentence travels into a CI
1020    /// annotation and a rendered comment where a newline splits one fact
1021    /// across two records.
1022    #[test]
1023    fn the_changed_since_message_folds_git_stderr_onto_one_line() {
1024        let message = ChangedFilesError::GitFailed(
1025            "fatal: ambiguous argument 'x'\nUse '--' to separate paths".to_owned(),
1026        )
1027        .changed_since_message("x");
1028        assert!(!message.contains('\n'), "{message}");
1029        assert!(
1030            message.contains("ambiguous argument 'x' Use '--'"),
1031            "{message}"
1032        );
1033    }
1034
1035    #[test]
1036    fn git_command_clears_parent_git_environment() {
1037        let command = git_command(Path::new("."), &["status"]);
1038        let envs: Vec<_> = command.get_envs().collect();
1039
1040        for var in AMBIENT_GIT_ENV_VARS {
1041            assert!(
1042                envs.iter()
1043                    .any(|(key, value)| key.to_str() == Some(*var) && value.is_none()),
1044                "{var} should be cleared from the command env",
1045            );
1046        }
1047    }
1048
1049    #[test]
1050    fn try_get_changed_files_not_a_repository() {
1051        let temp = tempfile::tempdir().expect("tempdir");
1052        let result = try_get_changed_files(temp.path(), "main");
1053        assert!(matches!(result, Err(ChangedFilesError::NotARepository)));
1054    }
1055
1056    #[cfg(unix)]
1057    #[test]
1058    fn changed_files_preserve_special_filenames() {
1059        let repo = tempfile::tempdir().expect("tempdir");
1060        for args in [
1061            &["init", "--quiet"][..],
1062            &["config", "user.email", "test@example.com"][..],
1063            &["config", "user.name", "Test User"][..],
1064            &["config", "commit.gpgsign", "false"][..],
1065        ] {
1066            run_git(repo.path(), args);
1067        }
1068        std::fs::write(repo.path().join("initial.ts"), "initial\n").expect("initial fixture");
1069        run_git(repo.path(), &["add", "."]);
1070        run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1071        run_git(repo.path(), &["tag", "base"]);
1072
1073        let canonical_root = repo.path().canonicalize().expect("canonical repo");
1074        let special_files = [
1075            "src/line\nbreak.ts",
1076            "src/space name.ts",
1077            "src/quote\"name.ts",
1078            "src/back\\slash.ts",
1079            "src/unicode-λ.ts",
1080        ]
1081        .map(|path| canonical_root.join(path));
1082        std::fs::create_dir_all(canonical_root.join("src")).expect("source dir");
1083        for special in &special_files {
1084            std::fs::write(special, "changed\n").expect("special fixture");
1085        }
1086
1087        let changed = try_get_changed_files(repo.path(), "base").expect("changed files");
1088        for special in special_files {
1089            assert!(
1090                changed.contains(&special),
1091                "missing {special:?}: {changed:?}"
1092            );
1093        }
1094    }
1095
1096    #[cfg(windows)]
1097    #[test]
1098    fn git_path_bytes_use_windows_separators() {
1099        assert_eq!(
1100            git_path_from_bytes(b"src/nested/file.ts"),
1101            PathBuf::from(r"src\nested\file.ts")
1102        );
1103    }
1104
1105    #[test]
1106    fn changed_diff_covers_staged_unstaged_and_untracked_files() {
1107        let repo = tempfile::tempdir().expect("tempdir");
1108        for args in [
1109            &["init", "--quiet"][..],
1110            &["config", "user.email", "test@example.com"][..],
1111            &["config", "user.name", "Test User"][..],
1112            &["config", "commit.gpgsign", "false"][..],
1113        ] {
1114            run_git(repo.path(), args);
1115        }
1116        std::fs::write(repo.path().join("staged.ts"), "old\n").expect("staged fixture");
1117        std::fs::write(repo.path().join("unstaged.ts"), "old\n").expect("unstaged fixture");
1118        run_git(repo.path(), &["add", "."]);
1119        run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
1120        run_git(repo.path(), &["tag", "base"]);
1121
1122        std::fs::write(repo.path().join("committed.ts"), "committed\n").expect("committed fixture");
1123        run_git(repo.path(), &["add", "committed.ts"]);
1124        run_git(
1125            repo.path(),
1126            &["commit", "--quiet", "-m", "committed change"],
1127        );
1128
1129        std::fs::write(repo.path().join("staged.ts"), "staged\n").expect("staged edit");
1130        run_git(repo.path(), &["add", "staged.ts"]);
1131        std::fs::write(repo.path().join("unstaged.ts"), "unstaged\n").expect("unstaged edit");
1132        std::fs::write(repo.path().join("untracked.ts"), "untracked\n").expect("untracked edit");
1133
1134        let diff = try_get_changed_diff(repo.path(), "base").expect("complete changeset diff");
1135        let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1136
1137        assert!(diff.contains("b/committed.ts"), "{diff}");
1138        assert!(diff.contains("b/staged.ts"), "{diff}");
1139        assert!(diff.contains("b/unstaged.ts"), "{diff}");
1140        assert!(diff.contains("b/untracked.ts"), "{diff}");
1141        assert_eq!(index.hunk_count(), 4);
1142        assert_eq!(index.net_lines(), 2);
1143    }
1144
1145    fn run_git(root: &Path, args: &[&str]) {
1146        let output = spawn_output(&mut git_command(root, args)).expect("git command");
1147        assert!(
1148            output.status.success(),
1149            "git {args:?} failed: {}",
1150            String::from_utf8_lossy(&output.stderr)
1151        );
1152    }
1153
1154    #[test]
1155    fn untracked_path_arg_uses_forward_slashes() {
1156        assert_eq!(
1157            super::untracked_path_arg(Path::new("src\\nested\\b.ts")),
1158            "src/nested/b.ts"
1159        );
1160        assert_eq!(super::untracked_path_arg(Path::new("src/b.ts")), "src/b.ts");
1161    }
1162
1163    #[test]
1164    fn changed_files_error_describe_matches_core_contract() {
1165        assert_eq!(
1166            ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
1167            "invalid git ref: bad ref"
1168        );
1169        assert_eq!(
1170            ChangedFilesError::GitMissing("not found".to_string()).describe(),
1171            "failed to run git: not found"
1172        );
1173        assert_eq!(
1174            ChangedFilesError::NotARepository.describe(),
1175            "not a git repository"
1176        );
1177        assert!(
1178            ChangedFilesError::GitFailed("unknown revision main".to_string())
1179                .describe()
1180                .contains("fetch-depth: 0")
1181        );
1182    }
1183
1184    #[test]
1185    fn filter_results_keeps_only_changed_file_findings() {
1186        let mut results = AnalysisResults::default();
1187        results
1188            .unused_files
1189            .push(UnusedFileFinding::with_actions(UnusedFile {
1190                path: PathBuf::from("/repo/a.ts"),
1191            }));
1192        results
1193            .unused_files
1194            .push(UnusedFileFinding::with_actions(UnusedFile {
1195                path: PathBuf::from("/repo/b.ts"),
1196            }));
1197        results
1198            .unused_exports
1199            .push(UnusedExportFinding::with_actions(UnusedExport {
1200                path: PathBuf::from("/repo/a.ts"),
1201                export_name: "foo".to_owned(),
1202                is_type_only: false,
1203                line: 1,
1204                col: 0,
1205                span_start: 0,
1206                is_re_export: false,
1207                deprecated: false,
1208                deprecated_reason: None,
1209            }));
1210
1211        let mut changed = FxHashSet::default();
1212        changed.insert(PathBuf::from("/repo/a.ts"));
1213
1214        filter_results_by_changed_files(&mut results, &changed);
1215
1216        assert_eq!(results.unused_files.len(), 1);
1217        assert_eq!(
1218            results.unused_files[0].file.path,
1219            PathBuf::from("/repo/a.ts")
1220        );
1221        assert_eq!(results.unused_exports.len(), 1);
1222    }
1223
1224    #[test]
1225    fn filter_results_preserves_graph_global_dependency_findings() {
1226        let mut results = AnalysisResults::default();
1227        results
1228            .unused_dependencies
1229            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1230                package_name: "lodash".to_owned(),
1231                location: DependencyLocation::Dependencies,
1232                path: PathBuf::from("/repo/package.json"),
1233                line: 3,
1234                used_in_workspaces: Vec::new(),
1235            }));
1236
1237        let changed = FxHashSet::default();
1238        filter_results_by_changed_files(&mut results, &changed);
1239
1240        assert_eq!(results.unused_dependencies.len(), 1);
1241    }
1242
1243    /// Retention for `deprecated-export-in-use` keys on the file that declares
1244    /// the export. A change that only adds a consumer does not surface an old
1245    /// deprecated export (documented v1 behavior).
1246    #[test]
1247    fn filter_results_keeps_deprecated_export_only_when_its_declaring_file_changed() {
1248        let finding = |path: &str| {
1249            fallow_types::output_dead_code::DeprecatedExportInUseFinding::with_actions(
1250                fallow_types::results::DeprecatedExportInUse {
1251                    path: PathBuf::from(path),
1252                    export_name: "old".to_owned(),
1253                    is_type_only: false,
1254                    line: 2,
1255                    col: 0,
1256                    span_start: 0,
1257                    deprecated_reason: None,
1258                    consumer_count: 1,
1259                    consumers: vec![fallow_types::results::DeprecatedExportConsumer {
1260                        path: PathBuf::from("/repo/consumer.ts"),
1261                        line: 1,
1262                        col: 0,
1263                        kind: fallow_types::results::DeprecatedConsumerKind::NamedImport,
1264                    }],
1265                    public_api: false,
1266                },
1267            )
1268        };
1269        let mut results = AnalysisResults::default();
1270        results
1271            .deprecated_exports_in_use
1272            .push(finding("/repo/a.ts"));
1273        results
1274            .deprecated_exports_in_use
1275            .push(finding("/repo/b.ts"));
1276
1277        let mut changed = FxHashSet::default();
1278        changed.insert(PathBuf::from("/repo/a.ts"));
1279        changed.insert(PathBuf::from("/repo/consumer.ts"));
1280        filter_results_by_changed_files(&mut results, &changed);
1281
1282        let kept: Vec<_> = results
1283            .deprecated_exports_in_use
1284            .iter()
1285            .map(|f| f.export.path.clone())
1286            .collect();
1287        assert_eq!(kept, vec![PathBuf::from("/repo/a.ts")]);
1288    }
1289
1290    #[test]
1291    fn filter_results_keeps_relative_manifest_finding_when_manifest_changed() {
1292        let mut results = AnalysisResults::default();
1293        results
1294            .empty_catalog_groups
1295            .push(EmptyCatalogGroupFinding::with_actions(EmptyCatalogGroup {
1296                catalog_name: "legacy".to_owned(),
1297                path: PathBuf::from("pnpm-workspace.yaml"),
1298                line: 4,
1299            }));
1300
1301        let mut changed = FxHashSet::default();
1302        changed.insert(PathBuf::from("/repo/pnpm-workspace.yaml"));
1303
1304        filter_results_by_changed_files(&mut results, &changed);
1305
1306        assert_eq!(results.empty_catalog_groups.len(), 1);
1307    }
1308
1309    #[test]
1310    fn filter_duplication_keeps_groups_with_changed_instances_and_recomputes_stats() {
1311        let mut report = DuplicationReport {
1312            clone_groups: vec![
1313                CloneGroup {
1314                    instances: vec![
1315                        CloneInstance {
1316                            file: PathBuf::from("/repo/a.ts"),
1317                            start_line: 1,
1318                            end_line: 5,
1319                            start_col: 0,
1320                            end_col: 10,
1321                            fragment: "code".to_owned(),
1322                        },
1323                        CloneInstance {
1324                            file: PathBuf::from("/repo/b.ts"),
1325                            start_line: 1,
1326                            end_line: 5,
1327                            start_col: 0,
1328                            end_col: 10,
1329                            fragment: "code".to_owned(),
1330                        },
1331                    ],
1332                    token_count: 20,
1333                    line_count: 5,
1334                    similarity: None,
1335                },
1336                CloneGroup {
1337                    instances: vec![
1338                        CloneInstance {
1339                            file: PathBuf::from("/repo/c.ts"),
1340                            start_line: 1,
1341                            end_line: 5,
1342                            start_col: 0,
1343                            end_col: 10,
1344                            fragment: "other".to_owned(),
1345                        },
1346                        CloneInstance {
1347                            file: PathBuf::from("/repo/d.ts"),
1348                            start_line: 1,
1349                            end_line: 5,
1350                            start_col: 0,
1351                            end_col: 10,
1352                            fragment: "other".to_owned(),
1353                        },
1354                    ],
1355                    token_count: 20,
1356                    line_count: 5,
1357                    similarity: None,
1358                },
1359            ],
1360            clone_families: Vec::new(),
1361            mirrored_directories: Vec::new(),
1362            stats: DuplicationStats {
1363                total_files: 4,
1364                files_with_clones: 4,
1365                total_lines: 100,
1366                duplicated_lines: 20,
1367                total_tokens: 200,
1368                duplicated_tokens: 80,
1369                clone_groups: 2,
1370                clone_families: 0,
1371                clone_instances: 4,
1372                duplication_percentage: 20.0,
1373                clone_groups_below_min_occurrences: 0,
1374                clone_groups_ignored: 0,
1375                near_candidates_skipped: 0,
1376            },
1377        };
1378
1379        let mut changed = FxHashSet::default();
1380        changed.insert(PathBuf::from("/repo/a.ts"));
1381
1382        filter_duplication_by_changed_files(&mut report, &changed, Path::new("/repo"));
1383
1384        assert_eq!(report.clone_groups.len(), 1);
1385        assert_eq!(report.stats.clone_groups, 1);
1386        assert_eq!(report.stats.clone_instances, 2);
1387    }
1388}