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