Skip to main content

pristine/
fallback.rs

1//! Tier two: the gitignore fallback, for the ecosystems nobody wrote a rule for.
2//!
3//! This is the differentiator from `kondo`, whose coverage is exactly its ruleset and which is
4//! therefore blind to anything outside it. Inside a git work tree a directory is reclaimable by
5//! *inference* when all four of these hold:
6//!
7//! 1. it is ignored, per the whole gitignore stack — nested files, negations, `info/exclude`,
8//!    global excludes — and not merely per the root `.gitignore`;
9//! 2. it contains no tracked file at any depth;
10//! 3. it clears a size floor, 10 MiB by default;
11//! 4. no tier-one rule already claimed it.
12//!
13//! Condition two is the safety property, it is exactly the guarantee `git clean` enforces, and
14//! it is the reason this tier can be on by default without being reckless. Condition four falls
15//! out of evaluation order in [`crate::walk`]: tier one is asked first, and it prunes.
16//!
17//! ## A candidate may be a FILE, and then only conditions one, two and four apply
18//!
19//! Conditions one, two and four are statements about a path and hold unchanged. Condition
20//! three does not, and leaving it out is the design rather than an exemption: **the floor is
21//! about rows, not about safety.** A gitignored directory under 10 MiB is not worth a row
22//! because the list is sorted by size and it would be at the bottom of it. A 40-byte `.env` IS
23//! worth a row, and the reason has nothing to do with its size.
24//!
25//! A file is also **always priced**, where a tier-one directory lives in
26//! [`crate::size::Size::Unmeasured`] until somebody asks for a breakdown. One `lstat` — which
27//! the walk has already done — is the exact and complete answer in constant time, so the
28//! unpriced machinery never has to grow a file branch. That is a simplification rather than a
29//! special case.
30//!
31//! Because this is a different job from the one the tier does for directories — clearing fifty
32//! env files reclaims kilobytes, and the value is hygiene rather than space — it is asked for
33//! separately: see [`crate::walk::Walker::ignored_files`], which is off unless a caller says
34//! otherwise.
35//!
36//! ## The fifth condition, which `git clean` also enforces
37//!
38//! A directory holding a git checkout at any depth is not claimed either. This is not in the
39//! four above and it is not optional: `git clean -ndX` in a repository whose ignored
40//! `.sandboxes/` holds live work trees prints `Would skip repository …` and then lists the
41//! siblings one by one, rather than collapsing the directory into a single removal. Anything
42//! that collapses it is offering to delete somebody's uncommitted work, and that shape —
43//! checkouts parked under an ignored directory — is common rather than exotic. So a candidate
44//! holding a checkout is refused and descended into, which is exactly what git does with it,
45//! and its subdirectories that hold no checkout are claimed on their own.
46//!
47//! For the same reason a candidate with an unreadable corner is refused. "Holds no checkout" is
48//! a claim about the whole subtree, and a traversal that could not see all of it has not made
49//! that claim. Tier one survives an unreadable corner with a size that is a lower bound, because
50//! a rule vouched for the directory; here the traversal *is* the evidence.
51//!
52//! ## Outside a work tree this tier is inert
53//!
54//! Deliberately, and it is reported rather than left to look like an empty result. With no
55//! repository there is no ignore file that means anything, and the only signal left would be
56//! the directory's name — which is precisely how a cleaner deletes somebody's source. `build/`
57//! is a CMake project's hand-written source as often as it is output, and no amount of wanting
58//! a broader tier makes a name into evidence.
59//!
60//! ## Why a query matcher rather than a filtering walk
61//!
62//! Tier one switches every ignore file off, because `node_modules`, `target` and `.venv` are
63//! gitignored in every repository that has a `.gitignore` and a filtering walk would find
64//! almost nothing. So tier two cannot ride on the walk's own filtering and instead asks
65//! [`IncrementalIgnore`], which answers the same question for one path at a time and caches per
66//! directory. One matcher per work tree per walker thread: the matchers hold mutable caches, so
67//! sharing one would mean a lock on the hottest path in the scan.
68
69use std::collections::HashMap;
70use std::path::{Path, PathBuf};
71use std::sync::atomic::{AtomicUsize, Ordering};
72use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError};
73
74use ignore::{IncrementalIgnore, WalkBuilder};
75
76use crate::git::{self, WorkTree};
77use crate::walk::WalkError;
78
79/// The default size floor: below this a directory is not worth a row, and tier two would
80/// otherwise report every scrap of ignored cache on the disk.
81pub const DEFAULT_MIN_SIZE: u64 = 10 * 1024 * 1024;
82
83/// What tier two was able to do, reported alongside what it found.
84///
85/// Inertness is a result, not an absence of one. A user who scanned a directory that is not in
86/// a git work tree has to be able to tell "there was nothing reclaimable here" from "this tier
87/// had nothing to work with", and the two look identical without this.
88#[derive(Debug, Clone, Default)]
89pub struct FallbackReport {
90    /// Whether tier two ran at all.
91    pub enabled: bool,
92    /// The floor a directory had to clear, in bytes.
93    pub min_size: u64,
94    /// Git work trees whose ignore stack and index tier two consulted.
95    pub work_trees: usize,
96    /// Directories tier two passed over because they lie outside any git work tree.
97    pub outside_work_tree: usize,
98    /// Directories tier two would otherwise have claimed, but which hold a git checkout. Worth
99    /// surfacing: this is where the reclaimable bytes a scan declined to offer went.
100    pub holding_a_checkout: usize,
101    /// Directories tier two claimed.
102    pub hits: usize,
103    /// Whether tier two was asked to claim gitignored files as well as directories.
104    ///
105    /// Reported rather than inferred from `files` being zero, on this report's founding rule:
106    /// "there were none" and "nobody looked" are different facts and they look identical
107    /// without something that says which.
108    pub files_enabled: bool,
109    /// Gitignored files tier two claimed, which are a subset of `hits`.
110    pub files: usize,
111}
112
113impl FallbackReport {
114    /// Whether tier two ran and had nothing to work with, because no part of the scan was in a
115    /// git work tree.
116    ///
117    /// A tier switched off is not inert; it was not asked.
118    #[must_use]
119    pub fn is_inert(&self) -> bool {
120        self.enabled && self.work_trees == 0
121    }
122}
123
124/// The state tier two shares across walker threads: the work trees it has opened, which cost a
125/// subprocess each and must not be opened twice.
126#[derive(Debug)]
127pub(crate) struct Fallback {
128    scan_root: PathBuf,
129    /// The work tree containing the scan root, resolved once before the walk. Scanning a
130    /// subdirectory of a checkout is ordinary, and the repository above it still has an
131    /// opinion, so the search for it is the one search allowed to leave the scan root.
132    root_work_tree: Option<PathBuf>,
133    min_size: u64,
134    /// Whether a gitignored file is a candidate. Off unless asked for — see the module docs.
135    files: bool,
136    /// Keyed by work tree root. The `OnceLock` is what makes two threads arriving at the same
137    /// repository run `git ls-files` once between them rather than once each.
138    trees: Mutex<HashMap<PathBuf, Arc<Opened>>>,
139    outside_work_tree: AtomicUsize,
140}
141
142type Opened = OnceLock<Result<Arc<WorkTree>, Arc<str>>>;
143
144impl Fallback {
145    /// Prepares tier two for a scan of `scan_root`.
146    pub(crate) fn new(scan_root: &Path, min_size: u64, files: bool) -> Self {
147        Self {
148            scan_root: scan_root.to_path_buf(),
149            root_work_tree: git::discover(scan_root),
150            min_size,
151            files,
152            trees: Mutex::new(HashMap::new()),
153            outside_work_tree: AtomicUsize::new(0),
154        }
155    }
156
157    /// Whether gitignored files are candidates at all.
158    pub(crate) fn claims_files(&self) -> bool {
159        self.files
160    }
161
162    /// Per-thread state for one walker thread.
163    pub(crate) fn thread(&self) -> Thread<'_> {
164        Thread {
165            shared: self,
166            recent: None,
167            matchers: HashMap::new(),
168            trees: HashMap::new(),
169        }
170    }
171
172    /// What tier two managed, and everything it could not consult.
173    pub(crate) fn finish(
174        &self,
175        hits: usize,
176        files: usize,
177        holding_a_checkout: usize,
178    ) -> (FallbackReport, Vec<WalkError>) {
179        let trees = lock(&self.trees);
180        let mut work_trees = 0;
181        let mut errors = Vec::new();
182        for (root, opened) in trees.iter() {
183            match opened.get() {
184                Some(Ok(_)) => work_trees += 1,
185                // A repository that would not answer is inert ground too, and inertness that
186                // is not reported reads as "there was nothing here".
187                Some(Err(message)) => errors.push(WalkError {
188                    path: Some(root.clone()),
189                    message: message.to_string(),
190                    forbidden: false,
191                }),
192                None => {}
193            }
194        }
195        let report = FallbackReport {
196            enabled: true,
197            min_size: self.min_size,
198            work_trees,
199            outside_work_tree: self.outside_work_tree.load(Ordering::Relaxed),
200            holding_a_checkout,
201            hits,
202            files_enabled: self.files,
203            files,
204        };
205        (report, errors)
206    }
207}
208
209/// One walker thread's view of tier two.
210///
211/// Everything here is a cache. The matchers have to be per-thread because they mutate as they
212/// learn, and the rest is per-thread because a shared map would mean taking a lock for every
213/// directory in the scan.
214#[derive(Debug)]
215pub(crate) struct Thread<'a> {
216    shared: &'a Fallback,
217    /// The last directory whose work tree this thread resolved, and the answer. One slot is
218    /// enough: the walk hands a thread the entries of one directory at a time, so consecutive
219    /// questions almost always share a parent. It keeps the search for a work tree to a single
220    /// `stat` per directory without a per-directory cache to pay for.
221    recent: Option<(PathBuf, Option<PathBuf>)>,
222    matchers: HashMap<PathBuf, IncrementalIgnore>,
223    trees: HashMap<PathBuf, Option<Arc<WorkTree>>>,
224}
225
226impl Thread<'_> {
227    /// Whether a gitignored file is a candidate at all. See [`Fallback::claims_files`].
228    pub(crate) fn claims_files(&self) -> bool {
229        self.shared.claims_files()
230    }
231
232    /// The work tree whose ignore stack and index say tier two may claim `path`, judged on
233    /// everything that can be answered without touching a subtree.
234    ///
235    /// `is_dir` is not a detail: git's own matcher answers differently for a directory and a
236    /// file, since a pattern ending in `/` matches only the first — so passing the wrong one
237    /// claims files a `.gitignore` never mentioned.
238    ///
239    /// For a directory the walker applies the size floor and the nested-checkout rule
240    /// afterwards, because those are what cost a traversal. **A file has neither**: it has no
241    /// subtree to hold a checkout, and the floor exists to keep a small ignored *directory* off
242    /// a list sorted by size — which is not the reason a 40-byte `.env` is worth a row.
243    pub(crate) fn judge(&mut self, path: &Path, is_dir: bool) -> Option<PathBuf> {
244        let Some(work_tree) = self.work_tree_of(path) else {
245            self.shared
246                .outside_work_tree
247                .fetch_add(1, Ordering::Relaxed);
248            return None;
249        };
250        // A repository that would not answer leaves this ground unjudgeable, and unjudgeable
251        // means untouched.
252        let tracked = self.tree(&work_tree)?;
253
254        let relative = path.strip_prefix(&work_tree).ok()?.to_path_buf();
255        let matcher = self.matcher(&work_tree);
256        if !matcher.matched(&relative, is_dir).is_ignore() {
257            return None;
258        }
259
260        // The safety property, and the one condition that costs somebody their work if it is
261        // approximated rather than checked. It reads exactly right for a file too: the index
262        // is searched by exact path as well as by prefix, so a tracked file that happens to
263        // match an ignore pattern is refused rather than claimed.
264        if tracked.holds_tracked_path(path) {
265            return None;
266        }
267
268        Some(work_tree)
269    }
270
271    /// The work tree containing `dir`, or `None` when it is not in one.
272    fn work_tree_of(&mut self, dir: &Path) -> Option<PathBuf> {
273        // `dir` itself may be a checkout inside another one, and git's rule is that the nearest
274        // repository wins. This is the one `stat` tier two pays per directory.
275        if git::is_work_tree_root(dir) {
276            return Some(dir.to_path_buf());
277        }
278        let parent = dir.parent()?;
279        if let Some((cached, answer)) = &self.recent
280            && cached == parent
281        {
282            return answer.clone();
283        }
284        let answer = self.search_up(parent);
285        self.recent = Some((parent.to_path_buf(), answer.clone()));
286        answer
287    }
288
289    /// Walks up from `dir` for a work tree root, stopping at the scan root — above which the
290    /// answer was resolved once, before the walk started.
291    fn search_up(&self, dir: &Path) -> Option<PathBuf> {
292        let mut cursor = Some(dir);
293        while let Some(candidate) = cursor {
294            if candidate == self.shared.scan_root || !candidate.starts_with(&self.shared.scan_root)
295            {
296                return self.shared.root_work_tree.clone();
297            }
298            if git::is_work_tree_root(candidate) {
299                return Some(candidate.to_path_buf());
300            }
301            cursor = candidate.parent();
302        }
303        self.shared.root_work_tree.clone()
304    }
305
306    /// This thread's ignore matcher for `work_tree`, built on first use.
307    fn matcher(&mut self, work_tree: &Path) -> &mut IncrementalIgnore {
308        self.matchers
309            .entry(work_tree.to_path_buf())
310            .or_insert_with(|| build_matcher(work_tree))
311    }
312
313    /// The index of `work_tree`, opened once for the whole scan however many threads ask.
314    fn tree(&mut self, work_tree: &Path) -> Option<Arc<WorkTree>> {
315        if let Some(cached) = self.trees.get(work_tree) {
316            return cached.clone();
317        }
318        let opened = {
319            let mut trees = lock(&self.shared.trees);
320            Arc::clone(trees.entry(work_tree.to_path_buf()).or_default())
321        };
322        let tree = opened
323            .get_or_init(|| {
324                WorkTree::open(work_tree)
325                    .map(Arc::new)
326                    .map_err(|err| Arc::from(err.to_string().as_str()))
327            })
328            .as_ref()
329            .ok()
330            .map(Arc::clone);
331        self.trees.insert(work_tree.to_path_buf(), tree.clone());
332        tree
333    }
334}
335
336/// An ignore matcher for one work tree, configured to be git and nothing but git.
337///
338/// Every option here is a deliberate narrowing. `.ignore` and `.rgignore` files are ripgrep's,
339/// not git's, and honouring them would mean claiming directories git has no opinion about.
340/// `hidden` is ripgrep's "skip dotfiles", which would silently make every `.something`
341/// reclaimable. `parents` would read `.gitignore` files above the work tree root, which git
342/// does not do — and which, in a checkout inside another checkout, would let the outer
343/// repository's rules mark the inner one's source as reclaimable.
344fn build_matcher(work_tree: &Path) -> IncrementalIgnore {
345    let mut builder = WalkBuilder::new(work_tree);
346    builder
347        .hidden(false)
348        .parents(false)
349        .ignore(false)
350        .git_ignore(true)
351        .git_exclude(true)
352        .git_global(true);
353    // One matcher per root the builder was given, and `WalkBuilder::new` takes exactly one, so
354    // this cannot be empty.
355    let mut matchers = builder.build_matchers();
356    debug_assert_eq!(matchers.len(), 1, "one root in, one matcher out");
357    matchers.remove(0)
358}
359
360/// Locking helper. A poisoned mutex here means a panic elsewhere in the walk, which has already
361/// been reported; losing the work trees opened so far on top of that would help nobody.
362fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
363    mutex.lock().unwrap_or_else(PoisonError::into_inner)
364}
365
366#[cfg(test)]
367mod tests {
368    use super::FallbackReport;
369
370    #[test]
371    fn a_tier_that_was_never_asked_is_not_inert() {
372        let off = FallbackReport::default();
373        assert!(!off.enabled);
374        assert!(!off.is_inert());
375    }
376
377    #[test]
378    fn a_tier_that_ran_and_found_no_work_tree_is_inert() {
379        let report = FallbackReport {
380            enabled: true,
381            work_trees: 0,
382            ..FallbackReport::default()
383        };
384        assert!(report.is_inert());
385    }
386
387    #[test]
388    fn a_tier_that_found_a_work_tree_and_nothing_in_it_is_not_inert() {
389        let report = FallbackReport {
390            enabled: true,
391            work_trees: 3,
392            hits: 0,
393            ..FallbackReport::default()
394        };
395        assert!(!report.is_inert());
396    }
397}