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