Skip to main content

fallow_engine/
changed_files.rs

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