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}