Skip to main content

pristine/
walk.rs

1//! The parallel walker: one pass over a tree, pruning at every directory it claims.
2//!
3//! ## Prune on match
4//!
5//! When a rule claims a directory the walker records it and returns [`WalkState::Skip`]. That
6//! single decision is the performance thesis. npkill walks *into* `node_modules` to size it,
7//! enumerating tens of thousands of inodes through its full scan pipeline to produce one
8//! number the user is about to discard by deleting the tree. Here the scan stops at the
9//! boundary and the subtree, if it is measured at all, is handed to the tight loop in
10//! [`crate::size`].
11//!
12//! ## Why every ignore file is switched off
13//!
14//! [`ignore`] is here for two things: the parallel walk, and the gitignore stack that tier
15//! two needs. Tier one must not use the second. `node_modules`, `target` and `.venv` are
16//! gitignored in every repo that has a `.gitignore`, so a walk with the default filtering on
17//! would find almost nothing — and `hidden(false)` matters for the same reason, since
18//! `.venv`, `.gradle`, `.nx` and `.build` all start with a dot. Tier two therefore brings its
19//! own matcher, and asks it per path rather than letting it steer the walk. See
20//! [`crate::fallback`].
21//!
22//! ## The two tiers, in order
23//!
24//! Tier one is asked first at every directory, and it prunes. That ordering *is* tier two's
25//! fourth condition, "no tier-one rule already claimed it": there is no separate check for it
26//! anywhere, and there does not need to be.
27
28use std::borrow::Cow;
29use std::ffi::OsStr;
30use std::path::{Path, PathBuf};
31use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
32use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
33use std::time::SystemTime;
34
35use ignore::gitignore::Gitignore;
36use ignore::{DirEntry, WalkBuilder, WalkState};
37
38use crate::detect::Detector;
39use crate::fallback::{DEFAULT_MIN_SIZE, Fallback, FallbackReport};
40use crate::git;
41use crate::rules::{Kind, Rule, Ruleset};
42use crate::size::{Measurer, Size, SizeMode};
43use crate::tree::Tree;
44
45/// What tier two says in place of a label.
46///
47/// Not a blank and not a guess: the fallback knows the directory is safe to remove and knows
48/// nothing whatever about what put it there, so it says exactly that. See [`IgnoredClaim`].
49pub const UNLABELLED: &str = "Gitignored, kind unknown";
50
51/// What a claimed linked work tree is called on a row.
52pub const WORK_TREE_LABEL: &str = "Git · linked work tree";
53
54/// How long a linked work tree has to have been left alone before it is offered at all.
55///
56/// A floor rather than a flag, and it is **not** the same decision as `--older-than`. That one is
57/// off by default because a floor nobody asked for silently keeps directories they chose; this
58/// one is on always because nobody chooses a work tree — the walk offers it, and a work tree
59/// somebody used this morning is not a thing to put in front of them however clean it is.
60///
61/// The two compose rather than competing: the planner applies `--older-than` to every target
62/// including these, so the effective floor is whichever is stricter, and there is still one
63/// clock.
64pub const WORK_TREE_FLOOR: std::time::Duration = std::time::Duration::from_secs(14 * 24 * 60 * 60);
65
66/// Why something is reclaimable, and what is known about it.
67///
68/// Three variants rather than two because a *file* is genuinely a third thing rather than a
69/// directory with a smaller number on it: it has no subtree, so prune-on-match does not apply
70/// to it, the size floor does not apply to it, and it is never unpriced. Spelled as a variant
71/// so that every consumer is made to say what it does about one — the enum being exhaustive is
72/// what found the places that had quietly assumed a claim was a directory.
73#[derive(Debug, Clone)]
74pub enum Claim {
75    /// Tier one: a marker-anchored rule recognised the project and named this directory as its
76    /// output.
77    Rule(RuleClaim),
78    /// Tier two: nothing in the ruleset knows this directory, but git does.
79    Ignored(IgnoredClaim),
80    /// Tier two, on a leaf: a gitignored file.
81    IgnoredFile(IgnoredFileClaim),
82    /// A linked git work tree that has been left alone and holds nothing uncommitted.
83    ///
84    /// Neither tier could ever have produced this. Tier one is marker-anchored and claims a
85    /// directory's *children*, and a work tree is not an artefact of a project — it is a place
86    /// somebody was working. Tier two refuses anything holding a checkout, and rightly.
87    WorkTree,
88}
89
90/// Whether `dir` is a linked work tree nobody has touched in a while that holds no work of its
91/// own.
92///
93/// **Ordered by what each answer costs**, because two of these run a subprocess and the walk
94/// meets every directory on the disk. `is_work_tree_root` is one `lstat` and rejects everything
95/// that is not a checkout; the age floor is the mtime the walk is about to read anyway and
96/// rejects every work tree somebody is still using; only what survives both pays for git. On a
97/// home directory that is a handful of `git status` calls rather than one per checkout, and each
98/// of those is ~10 ms because git prunes the ignored trees it is being asked about — measured on
99/// a repository carrying 2.4 GiB of build output.
100///
101/// **Judged here rather than left to the planner, because a claim prunes.** A tree node that is
102/// itself a claim can still take children, and both would credit their bytes to every ancestor,
103/// so a work tree cannot be claimed *and* walked into. Claiming only the ones that will survive
104/// the plan is what keeps a work tree somebody is using from swallowing the `node_modules` inside
105/// it: it is never claimed, so the walk descends and tier one finds them exactly as before.
106///
107/// The plan asks all of this again before anything is unlinked. This is what decides whether a
108/// row appears; [`crate::Planner`] is what decides whether it goes.
109fn claims_work_tree(dir: &Path) -> bool {
110    if !git::is_work_tree_root(dir) {
111        return false;
112    }
113    let idle = dir
114        .symlink_metadata()
115        .ok()
116        .and_then(|metadata| metadata.modified().ok())
117        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
118        .is_some_and(|age| age >= WORK_TREE_FLOOR);
119    idle && git::checkout_at(dir) == Some(git::Checkout::Linked)
120        && git::head_on_branch(dir)
121        && git::is_clean(dir)
122}
123
124/// A claim made by the curated ruleset.
125#[derive(Debug, Clone)]
126pub struct RuleClaim {
127    /// The rule that matched, carrying the ecosystem, the kind and any caveat.
128    pub rule: Arc<Rule>,
129    /// The project whose markers justified the claim.
130    pub project_root: PathBuf,
131}
132
133/// A claim made by the tier-two gitignore fallback.
134///
135/// Nothing here says what the directory is, and that is the point rather than an omission: this
136/// tier knows the directory is safe to remove and knows nothing whatever about what put it
137/// there. The asymmetry against tier one is information — a named row is a directory whose cost
138/// to lose is known, and an unnamed one is a leap.
139#[derive(Debug, Clone)]
140pub struct IgnoredClaim {
141    /// The git work tree whose ignore stack and index justified the claim.
142    pub work_tree: PathBuf,
143}
144
145/// A claim on a gitignored **file**.
146///
147/// The one tier-two claim that can say what it found, and only because a file's *name* is
148/// sometimes evidence where a directory's never is. `.env` is the only copy of something and
149/// `.DS_Store` is the copy of nothing, so the two ends of [`Kind`]'s cost axis are reachable
150/// from a name alone — and the middle three, which are statements about how something was
151/// produced, are not.
152#[derive(Debug, Clone)]
153pub struct IgnoredFileClaim {
154    /// The git work tree whose ignore stack and index justified the claim.
155    pub work_tree: PathBuf,
156    /// What the name says this is, or `None` when it says nothing. See
157    /// [`Kind::of_ignored_file`].
158    pub kind: Option<Kind>,
159}
160
161/// One reclaimable thing: a directory, or — when the walk was asked for them — a gitignored
162/// file.
163#[derive(Debug, Clone)]
164pub struct Hit {
165    /// The path itself.
166    pub path: PathBuf,
167    /// Which tier claimed it, and everything that tier knows.
168    pub claim: Claim,
169    /// What is known about the size. For a tier-one claim, [`Size::Unmeasured`] unless a
170    /// breakdown was asked for, because measuring means enumerating the subtree the scan
171    /// deliberately pruned at. A tier-two claim always carries a real number: a directory
172    /// could not have been claimed without a full pass over it, so there was nothing left to
173    /// save, and a *file* is one `lstat` the walk had already done.
174    pub size: Size,
175    /// The directory's own mtime. The best single proxy for "do I still need this".
176    pub modified: Option<SystemTime>,
177}
178
179impl Hit {
180    /// How long ago the directory was last touched, or `None` if the clock disagrees with
181    /// the filesystem.
182    #[must_use]
183    pub fn age(&self, now: SystemTime) -> Option<std::time::Duration> {
184        now.duration_since(self.modified?).ok()
185    }
186
187    /// What this directory is: the ecosystem and the kind, or [`UNLABELLED`] when only git
188    /// knows the directory at all.
189    ///
190    /// A fact rather than a hint, which is the whole reason it replaced the command that used
191    /// to sit here. "`node_modules` is Node Dependencies" is checked; "`npm install` brings it
192    /// back" was a guess about a package manager, on a machine nothing here had looked at.
193    #[must_use]
194    pub fn label(&self) -> Cow<'_, str> {
195        match &self.claim {
196            Claim::Rule(claim) => Cow::Owned(claim.rule.label()),
197            Claim::Ignored(_) => Cow::Borrowed(UNLABELLED),
198            // The same sentence a directory gets when nothing named it, deliberately: the tier
199            // knows the file is disposable and knows nothing about what wrote it.
200            Claim::IgnoredFile(claim) => match claim.kind {
201                Some(kind) => Cow::Owned(format!("Gitignored, {}", kind.short())),
202                None => Cow::Borrowed(UNLABELLED),
203            },
204            Claim::WorkTree => Cow::Borrowed(WORK_TREE_LABEL),
205        }
206    }
207
208    /// What kind of artefact this is, or `None` when nothing knows what it is.
209    ///
210    /// The half of a label a machine can act on, which is what the closed vocabulary bought:
211    /// "show me every cache" is a question the front end can answer, and the `None` is not a
212    /// gap to be filled in but the tier-two claim's own content — see [`IgnoredClaim`].
213    ///
214    /// **A kind no longer implies a rule.** A gitignored file can carry one, read off its name
215    /// by [`Kind::of_ignored_file`], so "which tier claimed this" is [`Hit::is_ignored_file`]
216    /// and [`Hit::rule`] rather than "does it have a kind".
217    #[must_use]
218    pub fn kind(&self) -> Option<Kind> {
219        match &self.claim {
220            Claim::Rule(claim) => Some(claim.rule.kind),
221            Claim::IgnoredFile(claim) => claim.kind,
222            // Neither carries one, for the same reason stated twice over. The vocabulary is a
223            // scale of what an artefact costs to lose: tier two does not know what the
224            // directory is, and a work tree is not an artefact at all — what it costs is a
225            // `git worktree add` and a checkout, which is not a point on that axis.
226            Claim::Ignored(_) | Claim::WorkTree => None,
227        }
228    }
229
230    /// The rule that claimed this directory, or `None` when no rule did.
231    #[must_use]
232    pub fn rule(&self) -> Option<&Rule> {
233        match &self.claim {
234            Claim::Rule(claim) => Some(&claim.rule),
235            Claim::Ignored(_) | Claim::IgnoredFile(_) | Claim::WorkTree => None,
236        }
237    }
238
239    /// Whether this claim is a gitignored file rather than a directory.
240    ///
241    /// The one question the front end asks about the *shape* of a candidate, because a file is
242    /// a different job from the one the sweep does — see [`crate::tui::lens`], where it is an
243    /// axis of its own rather than a third value on the tier axis.
244    #[must_use]
245    pub fn is_ignored_file(&self) -> bool {
246        matches!(self.claim, Claim::IgnoredFile(_))
247    }
248}
249
250/// What a walk reports, as it happens.
251///
252/// Two events rather than one, because a claim and its price are found at different times and
253/// waiting for the second would throw away the first. See [`Walker::run`].
254#[derive(Debug)]
255pub enum Found {
256    /// A directory was claimed. Published the moment the claim is judged, whatever the size
257    /// mode: nothing here ever waits for a measurement.
258    Claim(Hit),
259    /// A pricing thread has gone into this claim and has not come back yet.
260    ///
261    /// The pool is bounded, so the number of these outstanding at any instant is the number
262    /// of threads in it — which is what makes it worth reporting at all. A live view can show
263    /// exactly which of its dashes are being worked on *now*, where before it could only show
264    /// that some of them would be worked on eventually. Followed by exactly one
265    /// [`Found::Priced`] for the same path, whatever the measurement turns out to be.
266    ///
267    /// A consumer that only wants totals ignores it, as the command line does.
268    Pricing(PathBuf),
269    /// A claim that was published without a size now has one.
270    ///
271    /// Arrives after the [`Found::Claim`] it belongs to — always, because the claim is
272    /// published before the pricing pool is even told about it — and on a different thread.
273    Priced(Priced),
274}
275
276/// A price for a claim that was published without one.
277#[derive(Debug, Clone)]
278pub struct Priced {
279    /// The claimed directory, spelled exactly as its [`Hit`] spelled it.
280    pub path: PathBuf,
281    /// What the traversal found.
282    pub size: Size,
283}
284
285/// One claim waiting for the pricing pool.
286///
287/// The metadata travels with the path because the walk has already paid for it, and measuring
288/// starts from the claim's own block count.
289struct Job {
290    path: PathBuf,
291    metadata: std::fs::Metadata,
292}
293
294/// Something the walk could not read. Collected rather than fatal: one unreadable directory
295/// must not cost the user the rest of the scan.
296#[derive(Debug)]
297pub struct WalkError {
298    /// The path involved, when the error names one.
299    pub path: Option<PathBuf>,
300    /// What went wrong.
301    pub message: String,
302    /// Whether the operating system refused, rather than something having gone wrong.
303    ///
304    /// Recorded from the `io::Error`'s kind at the moment it is caught, never by reading the
305    /// message afterwards — this crate has already been bitten once by parsing a program's
306    /// prose, and an errno does not get translated.
307    ///
308    /// The two are worth telling apart because only one of them is news. A macOS home directory
309    /// holds a dozen paths under `Library` that TCC refuses every process without Full Disk
310    /// Access, and they will read the same on every run forever; printing them beside a genuine
311    /// failure, every time, is how a reader learns to skip the line that says the totals are a
312    /// floor.
313    pub forbidden: bool,
314}
315
316impl WalkError {
317    /// Whether this was the system refusing rather than something going wrong.
318    #[must_use]
319    pub fn is_forbidden(&self) -> bool {
320        self.forbidden
321    }
322}
323
324/// What a walk found.
325#[derive(Debug, Default)]
326pub struct WalkOutcome {
327    /// How many directories were claimed, across both tiers.
328    pub hits: usize,
329    /// The total size of the claims that were measured. Zero on a default scan, which
330    /// measures nothing — read it alongside `unmeasured` rather than on its own.
331    pub reclaimable_bytes: u64,
332    /// How many claims were recorded without being measured, because the scan pruned there.
333    pub unmeasured: usize,
334    /// What tier two managed. Read it: a scan of a directory outside any git work tree finds
335    /// nothing through this tier and *cannot*, and the report is what tells the two apart.
336    pub fallback: FallbackReport,
337    /// Everything that could not be read.
338    pub errors: Vec<WalkError>,
339    /// How many paths were skipped because the reader excluded them.
340    ///
341    /// Counted and reported rather than silently obeyed: a total that is missing a subtree has
342    /// to say so, and "you told me not to look" is a different sentence from "I could not
343    /// look" — see [`Walker::excludes`].
344    pub excluded: usize,
345}
346
347/// A configured scan of one tree.
348// A builder's options are independent switches by construction, which is what the lint is
349// warning about everywhere it is not one.
350#[allow(clippy::struct_excessive_bools)]
351#[derive(Debug, Clone)]
352pub struct Walker {
353    root: PathBuf,
354    ruleset: Arc<Ruleset>,
355    threads: Option<usize>,
356    max_depth: Option<usize>,
357    follow_links: bool,
358    same_file_system: bool,
359    size_mode: SizeMode,
360    fallback: bool,
361    ignored_files: bool,
362    min_size: u64,
363    /// Paths the reader has said not to look at, in gitignore syntax. Empty by default: what
364    /// this program does not look at is a decision only the reader can make.
365    excludes: Arc<Gitignore>,
366}
367
368impl Walker {
369    /// A walk of `root` under `ruleset`, with the defaults the safety model asks for:
370    /// symlinks are not followed and mount points are not crossed.
371    ///
372    /// The tier-two gitignore fallback is on, at the default floor. It is safe on by default
373    /// because it never claims a directory holding a tracked file, and it is inert outside a
374    /// git work tree.
375    #[must_use]
376    pub fn new(root: impl AsRef<Path>, ruleset: Arc<Ruleset>) -> Self {
377        Self {
378            root: root.as_ref().to_path_buf(),
379            ruleset,
380            threads: None,
381            max_depth: None,
382            follow_links: false,
383            same_file_system: true,
384            size_mode: SizeMode::default(),
385            fallback: true,
386            ignored_files: false,
387            min_size: DEFAULT_MIN_SIZE,
388            excludes: Arc::new(Gitignore::empty()),
389        }
390    }
391
392    /// Paths not to descend into, matched in gitignore syntax against paths under the root.
393    ///
394    /// **Different from every other refusal in this program, and reported differently.** An
395    /// unreadable directory makes the totals a lower bound and says so, because the scan wanted
396    /// to look and could not. An excluded one is the reader saying "not there" — the totals are
397    /// still not the whole tree, but nothing went wrong, and a run that cried "scan incomplete"
398    /// over a choice its user made would be teaching them to ignore that sentence.
399    ///
400    /// Gitignore syntax rather than a list of prefixes, because it is the matcher everybody
401    /// reading this already knows, and it brings negation with it: an exclude of
402    /// `Library/Application Support` and a re-include of `!Library/Application Support/Zed` is
403    /// one line each and needs no new grammar.
404    #[must_use]
405    pub fn excludes(mut self, excludes: Arc<Gitignore>) -> Self {
406        self.excludes = excludes;
407        self
408    }
409
410    /// How many threads to walk with. Defaults to the machine's parallelism.
411    #[must_use]
412    pub fn threads(mut self, threads: usize) -> Self {
413        self.threads = Some(threads);
414        self
415    }
416
417    /// How deep to descend below the root, unbounded by default.
418    #[must_use]
419    pub fn max_depth(mut self, max_depth: Option<usize>) -> Self {
420        self.max_depth = max_depth;
421        self
422    }
423
424    /// Whether to follow symlinks. Off by default: a followed link leaves the root, and the
425    /// deleter will not remove anything it cannot prove is under it.
426    #[must_use]
427    pub fn follow_links(mut self, follow_links: bool) -> Self {
428        self.follow_links = follow_links;
429        self
430    }
431
432    /// Whether to stay on one filesystem. On by default.
433    #[must_use]
434    pub fn same_file_system(mut self, same_file_system: bool) -> Self {
435        self.same_file_system = same_file_system;
436        self
437    }
438
439    /// How hard to work for each claim's size.
440    #[must_use]
441    pub fn size_mode(mut self, size_mode: SizeMode) -> Self {
442        self.size_mode = size_mode;
443        self
444    }
445
446    /// Whether to run the tier-two gitignore fallback. On by default.
447    #[must_use]
448    pub fn fallback(mut self, fallback: bool) -> Self {
449        self.fallback = fallback;
450        self
451    }
452
453    /// Whether tier two also claims gitignored **files**. Off by default.
454    ///
455    /// Off rather than on because it is a different job from the one the sweep does, and the
456    /// difference is not a matter of degree: clearing fifty env files reclaims kilobytes, so
457    /// the value is hygiene rather than space and a list sorted by size is the wrong place to
458    /// discover it. A real `~/repos` holds tens of thousands of them, and an unasked-for sweep
459    /// would bury a 40 GB `node_modules` under `.DS_Store` rows.
460    ///
461    /// It costs an ignore query and an index lookup per file the walk sees, which the walk was
462    /// previously getting for free by refusing to judge files at all — so it is opt-in in the
463    /// library as well as on the command line.
464    #[must_use]
465    pub fn ignored_files(mut self, ignored_files: bool) -> Self {
466        self.ignored_files = ignored_files;
467        self
468    }
469
470    /// The size floor a tier-two **directory** must clear, [`DEFAULT_MIN_SIZE`] by default.
471    ///
472    /// It applies to tier-two directories only. A rule that names a directory has already said
473    /// the directory is output, and an empty `node_modules` is still a `node_modules` — and a
474    /// gitignored file is not on the list for its size in the first place, so a floor stated in
475    /// bytes has nothing to say about one.
476    #[must_use]
477    pub fn min_size(mut self, min_size: u64) -> Self {
478        self.min_size = min_size;
479        self
480    }
481
482    /// Runs the walk, calling `on_found` as each claim is found and again as each is priced.
483    ///
484    /// `on_found` is called concurrently, from the walker threads and from the pricing pool,
485    /// and while the walk is still running — that is the point, since the TUI renders rows as
486    /// they arrive. It must not block for long, or it becomes the walk's bottleneck.
487    ///
488    /// ## Why a claim and its price are two events
489    ///
490    /// Pricing a claim means walking the subtree the scan just pruned at, and that is an order
491    /// of magnitude more work than finding it. Measured over one real `~/repos`, 10,599
492    /// claims, under a full breakdown:
493    ///
494    /// | | last claim published | run complete |
495    /// |---|---|---|
496    /// | priced on the walker thread | 60.1 s | 60.1 s |
497    /// | priced on the pool | **7.5 s** | 63.0 s |
498    ///
499    /// Those two left-hand numbers are the whole change. Measuring on the walker thread makes
500    /// every claim's *publication* wait behind its own measurement, so the listing completes
501    /// only when the last byte has been counted and a front end has nothing whatever to render
502    /// for a minute. That is npkill's bargain, and not making it is what the pruning was for.
503    ///
504    /// So a claim is published the moment it is judged, carrying [`Size::Unmeasured`], and is
505    /// then handed to a pool of pricing threads. Its size arrives afterwards as
506    /// [`Found::Priced`], naming the same path, and a consumer updates the row in place.
507    ///
508    /// **`run` still does not return until the pool has drained**, so every number in the
509    /// returned [`WalkOutcome`] is final. A consumer that only wants totals — the command
510    /// line, today — need not care that any of this happened.
511    ///
512    /// The pool is one thread per walker thread. Oversubscribing it is the obvious next idea
513    /// and it was measured, because the deleter oversubscribes for exactly this reason: at
514    /// four times the threads the same scan takes **85.8 s** and does not publish its last
515    /// claim until 30.7 s. Pricing is `readdir` and `lstat`, which is 97% kernel time and
516    /// contends; `unlink` and `rmdir` wait on the disk and do not. The conclusion from the
517    /// deleter does not carry over here.
518    pub fn run<F>(&self, on_found: F) -> WalkOutcome
519    where
520        F: Fn(Found) + Send + Sync,
521    {
522        let fallback = self
523            .fallback
524            .then(|| Fallback::new(&self.root, self.min_size, self.ignored_files));
525        let scan = Scan {
526            root: self.root.as_path(),
527            detector: self.ruleset.detector(),
528            measurer: Measurer::new(self.size_mode.clone()).same_file_system(self.same_file_system),
529            min_size: self.min_size,
530            on_found,
531            errors: Mutex::new(Vec::new()),
532            hits: AtomicUsize::new(0),
533            fallback_hits: AtomicUsize::new(0),
534            file_hits: AtomicUsize::new(0),
535            holding_a_checkout: AtomicUsize::new(0),
536            reclaimed: AtomicU64::new(0),
537            unmeasured: AtomicUsize::new(0),
538        };
539
540        let threads = self.threads.unwrap_or_else(|| {
541            std::thread::available_parallelism().map_or(1, std::num::NonZero::get)
542        });
543        // No pool at all under the default mode, which is the hot path and queues nothing.
544        // Threads that could only ever block on an empty queue are not free, and a pool with
545        // no work in it is harder to reason about than no pool.
546        let pricers = if self.size_mode == SizeMode::Skip {
547            0
548        } else {
549            threads
550        };
551
552        let excluded = Arc::new(AtomicUsize::new(0));
553        let builder = self.builder(threads, &excluded);
554
555        // Deliberately unbounded, and the bound that matters is on the POOL rather than on the
556        // queue. A bounded queue is backpressure, and backpressure here means stalling the
557        // walk until pricing catches up — which is exactly the wait this exists to remove. Over
558        // `~/repos` any bound below the 10,599 claims would stretch a 4.6 s scan out to the
559        // 56 s the pricing takes, and the last rows would reach the screen last. What it costs
560        // instead is one path and one `stat` per claim not yet priced, which is strictly less
561        // than the `Hit` the consumer is already holding for that same claim.
562        let (submit, queue) = std::sync::mpsc::channel::<Job>();
563        let queue = Mutex::new(queue);
564
565        std::thread::scope(|pool| {
566            for _ in 0..pricers {
567                pool.spawn(|| scan.price(&queue));
568            }
569
570            // An inner scope so that every sender is dropped before the pool is joined: the
571            // walk's clones go when `ignore` joins its own threads, and this one goes at the
572            // closing brace. A pricing thread ends when the last sender is gone, not before.
573            {
574                let submit = submit;
575                builder.build_parallel().run(|| {
576                    // Per-thread, because tier two's matchers mutate as they learn and sharing
577                    // one would mean a lock on the hottest path in the scan.
578                    let mut tier_two = fallback.as_ref().map(Fallback::thread);
579                    let scan = &scan;
580                    let submit = submit.clone();
581                    Box::new(move |result| scan.visit(tier_two.as_mut(), &submit, result))
582                });
583            }
584        });
585
586        let mut errors = std::mem::take(&mut *lock(&scan.errors));
587        let fallback_hits = scan.fallback_hits.load(Ordering::Relaxed);
588        let fallback = match &fallback {
589            Some(fallback) => {
590                let (report, mut inert) = fallback.finish(
591                    fallback_hits,
592                    scan.file_hits.load(Ordering::Relaxed),
593                    scan.holding_a_checkout.load(Ordering::Relaxed),
594                );
595                errors.append(&mut inert);
596                report
597            }
598            None => FallbackReport {
599                min_size: self.min_size,
600                ..FallbackReport::default()
601            },
602        };
603
604        WalkOutcome {
605            hits: scan.hits.load(Ordering::Relaxed),
606            reclaimable_bytes: scan.reclaimed.load(Ordering::Relaxed),
607            unmeasured: scan.unmeasured.load(Ordering::Relaxed),
608            fallback,
609            errors,
610            excluded: excluded.load(Ordering::Relaxed),
611        }
612    }
613
614    /// The parallel walk itself, configured to be a plain traversal.
615    ///
616    /// Every ignore source is off, and that is tier one's requirement rather than an
617    /// oversight: `node_modules`, `target` and `.venv` are gitignored in every repository that
618    /// has a `.gitignore`, so a filtering walk would find almost nothing. `hidden(false)` is
619    /// the same point — `.venv`, `.gradle`, `.nx` and `.build` all start with a dot. Tier two
620    /// brings its own matcher and queries it per path instead.
621    fn builder(&self, threads: usize, excluded: &Arc<AtomicUsize>) -> WalkBuilder {
622        let mut builder = WalkBuilder::new(self.root.as_path());
623        // Cloned into the closure, which the parallel walker calls from every thread.
624        let matcher = Arc::clone(&self.excludes);
625        let counted = Arc::clone(excluded);
626        builder
627            .hidden(false)
628            .parents(false)
629            .ignore(false)
630            .git_global(false)
631            .git_ignore(false)
632            .git_exclude(false)
633            .follow_links(self.follow_links)
634            .same_file_system(self.same_file_system)
635            .threads(threads)
636            .max_depth(self.max_depth)
637            .filter_entry(move |entry| {
638                // Git's object store is large, never reclaimable, and full of names that would
639                // waste marker probes.
640                if entry.file_name() == OsStr::new(".git") {
641                    return false;
642                }
643                // Counted on the way past rather than dropped, so the report can say how much
644                // of the tree the reader chose not to see. Pruning here rather than filtering
645                // the results is the whole value of an exclude: the subtree is never walked,
646                // so a directory the process cannot read is never even reached — which is what
647                // takes its unreadable-path warning off the screen along with it.
648                let directory = entry.file_type().is_some_and(|kind| kind.is_dir());
649                if matcher.matched(entry.path(), directory).is_ignore() {
650                    counted.fetch_add(1, Ordering::Relaxed);
651                    return false;
652                }
653                true
654            });
655        builder
656    }
657
658    /// Runs the walk and files every hit into a rollup tree, pricing included.
659    ///
660    /// The tree is correct at every moment — after each claim and after each late price — so a
661    /// caller that wants to render while scanning can build the same thing itself around
662    /// [`Walker::run`] and read the shared tree between updates.
663    #[must_use]
664    pub fn run_to_tree(&self) -> (Tree, WalkOutcome) {
665        let tree = Mutex::new(Tree::new(&self.root));
666        let stray = Mutex::new(Vec::new());
667
668        let mut outcome = self.run(|found| match found {
669            Found::Claim(hit) => {
670                let path = hit.path.clone();
671                if lock(&tree).insert(hit).is_none() {
672                    lock(&stray).push(WalkError {
673                        path: Some(path),
674                        message: "claimed directory is not under the scan root".to_owned(),
675                        forbidden: false,
676                    });
677                }
678            }
679            // Nothing to file: it says a thread is busy, which a finished tree has no way to
680            // be interested in. The live view is the only consumer that is.
681            Found::Pricing(_) => {}
682            // A price for a row the tree does not hold, or holds priced already, would be
683            // double-counted rather than absorbed — so `price` refuses it and it is reported,
684            // on the same rule as a claim from outside the root.
685            Found::Priced(priced) => {
686                if lock(&tree).price(&priced.path, priced.size).is_none() {
687                    lock(&stray).push(WalkError {
688                        path: Some(priced.path),
689                        message: "priced directory is not an unpriced claim in this tree"
690                            .to_owned(),
691                        forbidden: false,
692                    });
693                }
694            }
695        });
696
697        outcome.errors.append(&mut lock(&stray));
698        let tree = tree.into_inner().unwrap_or_else(PoisonError::into_inner);
699        (tree, outcome)
700    }
701}
702
703/// Everything one walk shares across its threads. Split out so the per-thread visitor closure
704/// can capture a single reference rather than a dozen.
705struct Scan<'a, F> {
706    root: &'a Path,
707    detector: &'a Detector,
708    measurer: Measurer,
709    /// The floor a tier-two claim has to clear. Tier one is exempt: a rule that names a
710    /// directory has already said it is output.
711    min_size: u64,
712    on_found: F,
713    errors: Mutex<Vec<WalkError>>,
714    hits: AtomicUsize,
715    fallback_hits: AtomicUsize,
716    file_hits: AtomicUsize,
717    holding_a_checkout: AtomicUsize,
718    reclaimed: AtomicU64,
719    unmeasured: AtomicUsize,
720}
721
722impl<F> Scan<'_, F>
723where
724    F: Fn(Found) + Send + Sync,
725{
726    /// Judges one entry of the walk.
727    fn visit(
728        &self,
729        tier_two: Option<&mut crate::fallback::Thread<'_>>,
730        submit: &std::sync::mpsc::Sender<Job>,
731        result: Result<DirEntry, ignore::Error>,
732    ) -> WalkState {
733        let entry = match result {
734            Ok(entry) => entry,
735            Err(err) => {
736                let path = error_path(&err);
737                match err.io_error() {
738                    Some(io) => self.fail_io(path, io),
739                    None => self.fail(path, err.to_string()),
740                }
741                return WalkState::Continue;
742            }
743        };
744
745        // The root itself is never a claim: there would be no parent inside the scan
746        // to carry the markers, and pruning it would end the walk.
747        if entry.depth() == 0 {
748            return WalkState::Continue;
749        }
750        let Some(file_type) = entry.file_type() else {
751            return WalkState::Continue;
752        };
753        // Everything that is not a directory is a leaf: a plain file, and a symlink, which
754        // stays in the running for Bazel's `bazel-*` as well.
755        let leaf = !file_type.is_dir();
756        let Some(claim) = self.judge(tier_two, &entry, leaf) else {
757            return WalkState::Continue;
758        };
759
760        let metadata = match entry.metadata() {
761            Ok(metadata) => metadata,
762            Err(err) => {
763                let path = Some(entry.path().to_path_buf());
764                match err.io_error() {
765                    Some(io) => self.fail_io(path, io),
766                    None => self.fail(path, err.to_string()),
767                }
768                return WalkState::Skip;
769            }
770        };
771
772        // Whether this claim's size costs a traversal, and so belongs to the pricing pool
773        // rather than to this thread. Decided before the claim is published, because it
774        // decides what size the claim is published with.
775        // A work tree is priced exactly as a tier-one claim is, and for the same reason: its
776        // size costs a traversal of a subtree the walk is about to prune at, so it belongs to
777        // the pool rather than to this thread.
778        let queued = matches!(claim, Claim::Rule(_) | Claim::WorkTree)
779            && self.measurer.traverses(entry.path(), &metadata);
780
781        let size = match &claim {
782            // Nothing is measured here when the pool is taking it: the claim goes out
783            // unpriced and the number follows. What is left for this branch is the claim
784            // whose size is free — a symlink, one `lstat` the walk already did — and the
785            // claim no mode asked to price, which stays `Unmeasured`.
786            Claim::Rule(_) | Claim::WorkTree if queued => Size::Unmeasured,
787            Claim::Rule(_) | Claim::WorkTree => {
788                let measured = self.measurer.measure(entry.path(), &metadata);
789                self.report_blind_spots(
790                    measured.unreadable,
791                    "unreadable, so this size is a lower bound",
792                );
793                measured.size
794            }
795            Claim::Ignored(_) => match self.survey(entry.path(), &metadata) {
796                Some(size) => size,
797                // Refused, and always by descending rather than pruning: a rule may still match
798                // deeper, and an ignored directory holding a tracked file can still have
799                // reclaimable subdirectories under it that do not.
800                None => return WalkState::Continue,
801            },
802            // Always priced, and no floor. One `lstat` — which the walk has already done — is
803            // the exact and complete answer for a leaf in constant time, so a file never enters
804            // the unpriced state a tier-one directory lives in and the pricing pool never has
805            // to grow a branch for one. The floor is deliberately not applied: it exists to
806            // keep a small ignored *directory* off a list sorted by size, which is not why a
807            // 40-byte `.env` is on it.
808            Claim::IgnoredFile(_) => self.measurer.measure(entry.path(), &metadata).size,
809        };
810
811        self.hits.fetch_add(1, Ordering::Relaxed);
812        match &claim {
813            // Neither is a fallback hit: the report's fallback counts are what justify tier two
814            // being on by default, and a work tree was claimed by neither tier.
815            Claim::Rule(_) | Claim::WorkTree => {}
816            Claim::Ignored(_) => {
817                self.fallback_hits.fetch_add(1, Ordering::Relaxed);
818            }
819            Claim::IgnoredFile(_) => {
820                self.fallback_hits.fetch_add(1, Ordering::Relaxed);
821                self.file_hits.fetch_add(1, Ordering::Relaxed);
822            }
823        }
824        match size.bytes() {
825            Some(bytes) => {
826                self.reclaimed.fetch_add(bytes, Ordering::Relaxed);
827            }
828            None => {
829                self.unmeasured.fetch_add(1, Ordering::Relaxed);
830            }
831        }
832        // The whole thesis, in one line: what we have claimed, we do not enumerate. A leaf is
833        // the one claim that says nothing by it — there is no subtree to prune — so it says
834        // `Continue` rather than leaning on `Skip` happening to be a no-op on a file.
835        let after = if leaf {
836            WalkState::Continue
837        } else {
838            WalkState::Skip
839        };
840        let path = entry.into_path();
841        let queued = queued.then(|| path.clone());
842        (self.on_found)(Found::Claim(Hit {
843            path,
844            claim,
845            size,
846            modified: metadata.modified().ok(),
847        }));
848
849        // Queued only after the claim has been published, and that order is load-bearing: a
850        // pricing thread is running already, so submitting first would let a `Priced` reach
851        // the consumer for a row it has not been told about.
852        if let Some(path) = queued {
853            if let Err(returned) = submit.send(Job { path, metadata }) {
854                // Unreachable while the pool's receiver is alive, which it is for the whole
855                // walk. Reported rather than dropped: a claim queued and never priced would
856                // otherwise be indistinguishable from one nobody asked to price.
857                self.fail(
858                    Some(returned.0.path),
859                    "could not be queued for pricing".to_owned(),
860                );
861            }
862        }
863
864        after
865    }
866
867    /// Which tier, if either, claims this entry — and for a leaf, what its name says it is.
868    ///
869    /// Tier one is asked first, and tier one prunes. That ordering *is* tier two's fourth
870    /// condition, "no tier-one rule already claimed it": there is no separate check for it
871    /// anywhere and there does not need to be.
872    ///
873    /// A leaf reaches tier two only when the fallback was asked for files. Asking that of the
874    /// **fallback** rather than of the file type is what keeps a walk that did not want them
875    /// exactly as cheap as it was — no ignore query and no index lookup per file — while
876    /// leaving the symlink path alone, since a symlink has always been offered to tier one for
877    /// Bazel's sake.
878    fn judge(
879        &self,
880        tier_two: Option<&mut crate::fallback::Thread<'_>>,
881        entry: &DirEntry,
882        leaf: bool,
883    ) -> Option<Claim> {
884        let wanted = tier_two
885            .as_ref()
886            .is_some_and(|tier_two| tier_two.claims_files());
887        if leaf && !wanted && entry.file_type().is_some_and(|kind| !kind.is_symlink()) {
888            return None;
889        }
890        if let Some(rule) = self.detector.detect(entry.path(), self.root, entry.depth()) {
891            return Some(Claim::Rule(rule));
892        }
893        // Between the tiers, and it has to be: tier one prunes and would never reach a work
894        // tree root anyway, while tier two refuses every checkout outright — so a work tree
895        // asked of tier two is a directory nothing can ever claim.
896        if !leaf && claims_work_tree(entry.path()) {
897            return Some(Claim::WorkTree);
898        }
899        let work_tree = tier_two
900            .filter(|_| !leaf || wanted)
901            .and_then(|tier_two| tier_two.judge(entry.path(), !leaf))?;
902        Some(if leaf {
903            Claim::IgnoredFile(IgnoredFileClaim {
904                work_tree,
905                // Off the name, which is all a file offers — and all it needs to offer, since
906                // the two ends of the cost axis are the only ones a name can reach.
907                kind: entry.file_name().to_str().and_then(Kind::of_ignored_file),
908            })
909        } else {
910            Claim::Ignored(IgnoredClaim { work_tree })
911        })
912    }
913
914    /// The one pass tier two needs over a candidate, and the three ways it can refuse.
915    ///
916    /// Returns the size when the directory is claimable, and `None` when it is not — each
917    /// refusal already reported to the user through the errors or the checkout count, because
918    /// a directory somebody expected to see and did not is exactly what needs explaining.
919    ///
920    /// Unlike tier one this never goes near the pricing pool. The survey is not optional work:
921    /// neither the size floor nor "holds no checkout" can be inferred, and the second is a
922    /// negative, which is only proved by covering everything. By the time the tier can say
923    /// "claim", it has already paid for the number.
924    fn survey(&self, path: &Path, metadata: &std::fs::Metadata) -> Option<Size> {
925        let surveyed = self.measurer.survey(path, metadata);
926        let blind_spots = !surveyed.unreadable.is_empty() || !surveyed.not_crossed.is_empty();
927        self.report_blind_spots(
928            surveyed.unreadable,
929            "unreadable, so this subtree could not be judged reclaimable",
930        );
931        self.report_blind_spots(
932            surveyed.not_crossed,
933            "on another filesystem, so this subtree could not be judged reclaimable",
934        );
935        if blind_spots {
936            // Part of the subtree could not be read, so neither "holds no checkout" nor the
937            // size is established — both are claims about the whole of it. Tier one can live
938            // with a lower bound because a rule already vouched for the directory; here the
939            // traversal *is* the evidence, and unjudgeable ground is left alone.
940            return None;
941        }
942        if surveyed.nested_repo.is_some() {
943            // Somebody's checkout lives in here, so this directory is not a single thing to be
944            // removed. `git clean` descends past it rather than collapsing it, and so do we.
945            self.holding_a_checkout.fetch_add(1, Ordering::Relaxed);
946            return None;
947        }
948        surveyed
949            .size
950            .bytes()
951            .is_some_and(|bytes| bytes >= self.min_size)
952            .then_some(surveyed.size)
953    }
954
955    /// One pricing thread: takes claims off the queue and measures them until the walk is
956    /// finished with it.
957    ///
958    /// Ends when every sender is gone, which is what makes the pool self-terminating and is
959    /// why [`Walker::run`] is careful about where the senders are dropped.
960    fn price(&self, queue: &Mutex<std::sync::mpsc::Receiver<Job>>) {
961        loop {
962            // The lock covers the `recv` and nothing else. Held across the measurement it
963            // would make the pool one thread wearing several hats — and the measurement is
964            // the entire reason the pool exists.
965            let job = lock(queue).recv();
966            let Ok(job) = job else { return };
967
968            // Announced before the traversal rather than after it, which is the only ordering
969            // that makes the event mean anything: it says "a thread is in here", and a thread
970            // that has already come out is not.
971            (self.on_found)(Found::Pricing(job.path.clone()));
972            let measured = self.measurer.measure(&job.path, &job.metadata);
973            self.report_blind_spots(
974                measured.unreadable,
975                "unreadable, so this size is a lower bound",
976            );
977            if let Some(bytes) = measured.size.bytes() {
978                self.reclaimed.fetch_add(bytes, Ordering::Relaxed);
979                // The claim was counted as unpriced when it was published. It is not any
980                // longer, and the outcome has to agree with the events the consumer saw.
981                self.unmeasured.fetch_sub(1, Ordering::Relaxed);
982            }
983            (self.on_found)(Found::Priced(Priced {
984                path: job.path,
985                size: measured.size,
986            }));
987        }
988    }
989
990    fn fail(&self, path: Option<PathBuf>, message: String) {
991        lock(&self.errors).push(WalkError {
992            path,
993            message,
994            forbidden: false,
995        });
996    }
997
998    /// The same, for an error that came from the filesystem and can say which kind it is.
999    ///
1000    /// Takes the message from the `io::Error` rather than from the [`ignore::Error`] wrapping
1001    /// it, because the wrapper's `Display` already contains the path — and the reporter puts the
1002    /// path in front of it, which is how `pristine: <path>: <path>: …` reached a screen.
1003    fn fail_io(&self, path: Option<PathBuf>, err: &std::io::Error) {
1004        lock(&self.errors).push(WalkError {
1005            path,
1006            message: err.to_string(),
1007            forbidden: err.kind() == std::io::ErrorKind::PermissionDenied,
1008        });
1009    }
1010
1011    /// Reports the corners of a subtree a traversal did not see. The message differs by tier
1012    /// and is the point of the report: a tier-one claim survives a blind spot with a size that
1013    /// is a lower bound, and a tier-two claim does not survive one at all.
1014    fn report_blind_spots(&self, paths: Vec<PathBuf>, message: &str) {
1015        if paths.is_empty() {
1016            return;
1017        }
1018        let mut errors = lock(&self.errors);
1019        for path in paths {
1020            errors.push(WalkError {
1021                path: Some(path),
1022                forbidden: false,
1023                message: message.to_owned(),
1024            });
1025        }
1026    }
1027}
1028
1029/// Digs the path out of a walk error. `ignore` wraps the underlying failure in `WithPath` and
1030/// `WithDepth` layers rather than exposing an accessor, so unwrap them by hand.
1031fn error_path(err: &ignore::Error) -> Option<PathBuf> {
1032    match err {
1033        ignore::Error::WithPath { path, .. } => Some(path.clone()),
1034        ignore::Error::WithDepth { err, .. } | ignore::Error::WithLineNumber { err, .. } => {
1035            error_path(err)
1036        }
1037        ignore::Error::Loop { child, .. } => Some(child.clone()),
1038        _ => None,
1039    }
1040}
1041
1042/// Locking helper. A poisoned mutex here means a panic in `on_hit`, which has already been
1043/// reported to whoever wrote it; losing the errors collected so far on top of that would
1044/// help nobody.
1045fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
1046    mutex.lock().unwrap_or_else(PoisonError::into_inner)
1047}