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::Output;
5
6use fallow_types::results::AnalysisResults;
7use rustc_hash::FxHashSet;
8
9use crate::duplicates::DuplicationReport;
10
11/// Function pointer signature used to intercept short-running git
12/// subprocesses spawned by changed-file helpers.
13pub type ChangedFilesSpawnHook = fn(&mut std::process::Command) -> std::io::Result<Output>;
14
15/// Classification of a changed-file git failure.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ChangedFilesError {
18    /// Git ref failed validation before invoking `git`.
19    InvalidRef(String),
20    /// `git` binary not found or not executable.
21    GitMissing(String),
22    /// Command ran but the directory is not a git repository.
23    NotARepository,
24    /// Command ran but the ref is invalid or another git error occurred.
25    GitFailed(String),
26}
27
28impl ChangedFilesError {
29    /// Human-readable clause suitable for embedding in an error message.
30    #[must_use]
31    pub fn describe(&self) -> String {
32        match self {
33            Self::InvalidRef(err) => format!("invalid git ref: {err}"),
34            Self::GitMissing(err) => format!("failed to run git: {err}"),
35            Self::NotARepository => "not a git repository".to_owned(),
36            Self::GitFailed(stderr) => augment_git_failed(stderr),
37        }
38    }
39}
40
41impl From<fallow_core::changed_files::ChangedFilesError> for ChangedFilesError {
42    fn from(error: fallow_core::changed_files::ChangedFilesError) -> Self {
43        match error {
44            fallow_core::changed_files::ChangedFilesError::InvalidRef(err) => Self::InvalidRef(err),
45            fallow_core::changed_files::ChangedFilesError::GitMissing(err) => Self::GitMissing(err),
46            fallow_core::changed_files::ChangedFilesError::NotARepository => Self::NotARepository,
47            fallow_core::changed_files::ChangedFilesError::GitFailed(stderr) => {
48                Self::GitFailed(stderr)
49            }
50        }
51    }
52}
53
54fn augment_git_failed(stderr: &str) -> String {
55    let lower = stderr.to_ascii_lowercase();
56    if lower.contains("not a valid object name")
57        || lower.contains("unknown revision")
58        || lower.contains("ambiguous argument")
59    {
60        format!(
61            "{stderr} (shallow clone? try `git fetch --unshallow`, or set `fetch-depth: 0` on actions/checkout / `GIT_DEPTH: 0` in GitLab CI)"
62        )
63    } else {
64        stderr.to_owned()
65    }
66}
67
68/// Install a spawn-hook for changed-file git subprocesses.
69pub fn set_spawn_hook(hook: ChangedFilesSpawnHook) {
70    fallow_core::changed_files::set_spawn_hook(hook);
71}
72
73/// Validate a user-supplied git ref before passing it to git.
74pub fn validate_git_ref(s: &str) -> Result<&str, String> {
75    fallow_core::changed_files::validate_git_ref(s)
76}
77
78/// Resolve the canonical git toplevel for `cwd`.
79pub fn resolve_git_toplevel(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
80    fallow_core::changed_files::resolve_git_toplevel(cwd).map_err(ChangedFilesError::from)
81}
82
83/// Resolve the canonical git common directory for `cwd`.
84pub fn resolve_git_common_dir(cwd: &Path) -> Result<PathBuf, ChangedFilesError> {
85    fallow_core::changed_files::resolve_git_common_dir(cwd).map_err(ChangedFilesError::from)
86}
87
88/// Get files changed since a git ref.
89pub fn try_get_changed_files(
90    root: &Path,
91    git_ref: &str,
92) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
93    fallow_core::changed_files::try_get_changed_files(root, git_ref)
94        .map_err(ChangedFilesError::from)
95}
96
97/// Resolve changed files for a git ref relative to a project root.
98///
99/// # Errors
100///
101/// Returns an error when git cannot resolve the ref or repository state.
102pub fn changed_files(root: &Path, git_ref: &str) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
103    try_get_changed_files(root, git_ref)
104}
105
106/// Get changed files and the git toplevel used to resolve them.
107pub fn try_get_changed_files_with_toplevel(
108    cwd: &Path,
109    toplevel: &Path,
110    git_ref: &str,
111) -> Result<FxHashSet<PathBuf>, ChangedFilesError> {
112    fallow_core::changed_files::try_get_changed_files_with_toplevel(cwd, toplevel, git_ref)
113        .map_err(ChangedFilesError::from)
114}
115
116/// Return the raw git diff for a ref.
117pub fn try_get_changed_diff(root: &Path, git_ref: &str) -> Result<String, ChangedFilesError> {
118    fallow_core::changed_files::try_get_changed_diff(root, git_ref).map_err(ChangedFilesError::from)
119}
120
121/// Get changed files if git can resolve them, otherwise return `None`.
122#[must_use]
123pub fn get_changed_files(root: &Path, git_ref: &str) -> Option<FxHashSet<PathBuf>> {
124    fallow_core::changed_files::get_changed_files(root, git_ref)
125}
126
127/// Scope dead-code results to findings affected by changed files.
128#[expect(
129    clippy::implicit_hasher,
130    reason = "fallow standardizes on FxHashSet across the workspace"
131)]
132pub fn filter_results_by_changed_files(
133    results: &mut AnalysisResults,
134    changed_files: &FxHashSet<PathBuf>,
135) {
136    fallow_core::changed_files::filter_results_by_changed_files(results, changed_files);
137}
138
139/// Scope duplication groups to clone groups touching at least one changed file.
140#[expect(
141    clippy::implicit_hasher,
142    reason = "fallow standardizes on FxHashSet across the workspace"
143)]
144pub fn filter_duplication_by_changed_files(
145    report: &mut DuplicationReport,
146    changed_files: &FxHashSet<PathBuf>,
147    root: &Path,
148) {
149    fallow_core::changed_files::filter_duplication_by_changed_files(report, changed_files, root);
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn changed_files_error_describe_matches_core_contract() {
158        assert_eq!(
159            ChangedFilesError::InvalidRef("bad ref".to_string()).describe(),
160            "invalid git ref: bad ref"
161        );
162        assert_eq!(
163            ChangedFilesError::GitMissing("not found".to_string()).describe(),
164            "failed to run git: not found"
165        );
166        assert_eq!(
167            ChangedFilesError::NotARepository.describe(),
168            "not a git repository"
169        );
170        assert!(
171            ChangedFilesError::GitFailed("unknown revision main".to_string())
172                .describe()
173                .contains("fetch-depth: 0")
174        );
175    }
176
177    #[test]
178    fn changed_files_error_converts_from_core_without_leaking_type() {
179        let error = fallow_core::changed_files::ChangedFilesError::GitFailed(
180            "ambiguous argument main".to_string(),
181        );
182        assert_eq!(
183            ChangedFilesError::from(error),
184            ChangedFilesError::GitFailed("ambiguous argument main".to_string())
185        );
186    }
187}