Skip to main content

ocy_core/
walker.rs

1use std::{
2    cell::{Cell, RefCell},
3    collections::HashSet,
4    path::{Path, PathBuf},
5};
6
7use crate::{
8    filesystem::FileSystem,
9    models::RemovalCandidate,
10    models::{FileInfo, SimpleFileKind},
11    rule::{CleanAction, Rule, Target},
12};
13use eyre::Report;
14use eyre::Result;
15
16/// Version-control metadata, never descended into.
17///
18/// These directories hold thousands of small objects and no build output. Walking them is
19/// pure cost, so they are skipped even under [`WalkOptions::walk_all`].
20pub const VCS_DIRS: &[&str] = &[".git", ".svn", ".hg", ".jj", ".bzr"];
21
22#[derive(Debug, Default, Clone)]
23pub struct WalkOptions {
24    /// Absolute paths that are neither scanned nor reclaimed.
25    pub ignores: HashSet<PathBuf>,
26
27    /// Descend into every hidden directory, not only [`WalkOptions::scanned_hidden`].
28    pub walk_all: bool,
29
30    /// Hidden directories descended into even when `walk_all` is false.
31    ///
32    /// Build output routinely hides behind a leading dot -- `.venv`, `.gradle`, `.next`.
33    /// Skipping every dotted directory by default means missing most of it.
34    pub scanned_hidden: HashSet<String>,
35
36    /// Maximum depth below the scan root, or [`None`] for unlimited.
37    pub max_depth: Option<usize>,
38
39    /// Do not cross onto another filesystem, so a scan cannot wander onto network mounts.
40    pub one_file_system: bool,
41}
42
43/// Tracks paths already claimed by a rule, so nested candidates are not walked or re-reported.
44///
45/// This wrapper encapsulates the `RefCell<HashSet<PathBuf>>` to ensure borrow/borrow_mut
46/// operations are temporary and cannot overlap.
47#[derive(Debug, Default)]
48pub struct PrunedSet {
49    inner: RefCell<HashSet<PathBuf>>,
50}
51
52impl PrunedSet {
53    /// Creates a new empty PrunedSet.
54    pub fn new() -> Self {
55        Self {
56            inner: RefCell::new(HashSet::new()),
57        }
58    }
59
60    /// Checks if a path is directly in the pruned set.
61    pub fn contains(&self, path: &Path) -> bool {
62        self.inner.borrow().contains(path)
63    }
64
65    /// Inserts a path into the pruned set.
66    pub fn insert(&self, path: PathBuf) {
67        self.inner.borrow_mut().insert(path);
68    }
69
70    /// Whether this path overlaps a candidate that has already been reported.
71    ///
72    /// Reporting both a directory and something inside it would count the nested bytes
73    /// twice in the total and race the two deletions against each other. Rules within a
74    /// directory are applied in order, so the overlap can be found in either direction:
75    /// a nested target may be claimed before the parent enclosing it, or after.
76    ///
77    /// Candidates stream to the user as they are found, so the first claim stands and the
78    /// overlapping one is dropped. That can leave an enclosing directory unreclaimed,
79    /// which is the safe direction to err for a tool that deletes things.
80    ///
81    /// Returns true if:
82    /// - Any ancestor of `path` is in the pruned set (path is inside a claimed directory)
83    /// - Any path in the pruned set starts with `path` (path is a parent of something claimed)
84    pub fn is_already_claimed(&self, path: &Path) -> bool {
85        let pruned = self.inner.borrow();
86        path.ancestors().any(|ancestor| pruned.contains(ancestor))
87            || pruned.iter().any(|claimed| claimed.starts_with(path))
88    }
89}
90
91pub struct Walker<FS: FileSystem, N: WalkNotifier> {
92    fs: FS,
93    rules: Vec<Rule>,
94    notifier: N,
95    options: WalkOptions,
96    /// Paths already claimed by a rule, so nested candidates are not walked or re-reported.
97    pruned: PrunedSet,
98    root_device: RefCell<Option<u64>>,
99    /// Directories already walked, so a worktree reached both by descent and by its git
100    /// record is scanned once and counted once.
101    visited: RefCell<HashSet<PathBuf>>,
102    /// Checkouts of linked worktrees found during the walk, scanned once it finishes.
103    pending_worktrees: RefCell<Vec<FileInfo>>,
104    directories_scanned: Cell<usize>,
105    candidates_found: Cell<usize>,
106}
107
108pub trait WalkNotifier {
109    fn notify_entered_directory(&self, dir: &FileInfo);
110    fn notify_candidate_for_removal(&self, candidate: RemovalCandidate);
111    fn notify_fail_to_scan(&self, e: &FileInfo, report: Report);
112    fn notify_walk_finish(&self);
113}
114
115/// What the walk should do with a directory once its rules have been applied.
116enum DirOutcome {
117    /// Continue into these child directories.
118    Descend(Vec<FileInfo>),
119    /// The directory is itself a candidate; there is nothing below it worth visiting.
120    Reclaimed,
121}
122
123impl<FS: FileSystem, N: WalkNotifier> Walker<FS, N> {
124    pub fn new(fs: FS, rules: Vec<Rule>, notifier: N, options: WalkOptions) -> Self {
125        Self {
126            fs,
127            rules,
128            notifier,
129            options,
130            pruned: PrunedSet::new(),
131            root_device: RefCell::default(),
132            visited: RefCell::default(),
133            pending_worktrees: RefCell::default(),
134            directories_scanned: Cell::default(),
135            candidates_found: Cell::default(),
136        }
137    }
138
139    pub fn walk_from_path(&self, path: &FileInfo) {
140        if self.options.one_file_system {
141            *self.root_device.borrow_mut() = self.fs.device_id(path);
142        }
143
144        log::info!(
145            "scanning {} with {} rules",
146            path.path.display(),
147            self.rules.len()
148        );
149        self.process_dir(path, 0);
150        self.process_pending_worktrees(&path.path);
151        log::info!(
152            "scanned {} directories, found {} candidates",
153            self.directories_scanned.get(),
154            self.candidates_found.get()
155        );
156
157        self.notifier.notify_walk_finish();
158    }
159
160    /// Walk the linked worktrees discovered during the main walk.
161    ///
162    /// Deferred rather than recursed into on the spot, so that a worktree nested inside
163    /// the tree is reached by ordinary descent first and skipped here as already visited.
164    /// Only checkouts below `root` are followed: `ocy` was asked to clean one directory,
165    /// and a worktree parked in `/tmp` is outside what was asked for.
166    fn process_pending_worktrees(&self, root: &Path) {
167        loop {
168            // Popped in its own statement: as the scrutinee of a `while let`, the borrow
169            // would live for the whole body, and walking a worktree can queue more.
170            let next = self.pending_worktrees.borrow_mut().pop();
171            let Some(worktree) = next else {
172                break;
173            };
174
175            if worktree.path.starts_with(root) {
176                log::debug!("following linked worktree {}", worktree.path.display());
177                self.process_dir(&worktree, 0);
178            } else {
179                log::debug!(
180                    "skipping worktree outside the scan root: {}",
181                    worktree.path.display()
182                );
183            }
184        }
185    }
186
187    fn process_dir(&self, file: &FileInfo, depth: usize) {
188        // TODO consider using is_already_claimed
189        if self.is_ignored(&file.path) || self.pruned.contains(&file.path) {
190            return;
191        }
192        if !self.visited.borrow_mut().insert(file.path.clone()) {
193            return;
194        }
195
196        match self.process_entries(file, depth) {
197            Ok(DirOutcome::Descend(children)) => children
198                .iter()
199                .for_each(|child| self.process_dir(child, depth + 1)),
200            Ok(DirOutcome::Reclaimed) => (),
201            Err(report) => self.notifier.notify_fail_to_scan(file, report),
202        }
203    }
204
205    fn process_entries(&self, dir: &FileInfo, depth: usize) -> Result<DirOutcome> {
206        self.notifier.notify_entered_directory(dir);
207        self.directories_scanned
208            .set(self.directories_scanned.get() + 1);
209
210        let listing = self.fs.list_files(dir)?;
211        listing
212            .errors
213            .into_iter()
214            .for_each(|report| self.notifier.notify_fail_to_scan(dir, report));
215        let mut entries = listing.entries;
216
217        for rule in &self.rules {
218            if !rule.matches(&entries) {
219                continue;
220            }
221
222            match rule.action() {
223                // The scan root is never proposed for deletion: running ocy from inside a
224                // venv must not offer to delete the directory being scanned.
225                CleanAction::RemoveSelf if depth > 0 => {
226                    if self.claim(rule, dir.clone()) {
227                        return Ok(DirOutcome::Reclaimed);
228                    }
229                }
230                CleanAction::RemoveSelf => (),
231                CleanAction::Remove(targets) => {
232                    let claimed = self.claim_targets(rule, &entries, targets);
233                    entries.retain(|entry| !claimed.contains(&entry.path));
234                }
235                CleanAction::Run(command) => {
236                    self.notifier
237                        .notify_candidate_for_removal(RemovalCandidate::new_cmd(
238                            rule.name.clone(),
239                            dir.clone(),
240                            command.clone(),
241                        ));
242                }
243                CleanAction::RemoveStaleWorktrees => {
244                    self.claim_stale_worktrees(rule, dir);
245                    self.queue_linked_worktrees(dir);
246                }
247            }
248        }
249
250        entries.retain(|entry| self.is_walkable(entry, depth));
251        Ok(DirOutcome::Descend(entries))
252    }
253
254    /// Report every target of a matched rule that actually exists.
255    ///
256    /// Returns the paths that were claimed, so the caller can drop them from the entries
257    /// it is about to descend into.
258    fn claim_targets(
259        &self,
260        rule: &Rule,
261        entries: &[FileInfo],
262        targets: &[Target],
263    ) -> HashSet<PathBuf> {
264        targets
265            .iter()
266            .flat_map(|target| self.resolve_target(entries, target))
267            .filter_map(|found| {
268                let path = found.path.clone();
269                self.claim(rule, found).then_some(path)
270            })
271            .collect()
272    }
273
274    /// Walk a target's components one directory level at a time.
275    ///
276    /// The first component is matched against the already-listed entries, so the common
277    /// single-component target costs no extra syscall; only a nested target such as
278    /// `.angular/cache` reads further directories.
279    fn resolve_target(&self, entries: &[FileInfo], target: &Target) -> Vec<FileInfo> {
280        let Some((first, rest)) = target.components.split_first() else {
281            return Vec::new();
282        };
283
284        let mut found: Vec<FileInfo> = entries
285            .iter()
286            .filter(|entry| first.matches(&entry.name))
287            .cloned()
288            .collect();
289
290        for component in rest {
291            found = found
292                .iter()
293                .filter(|entry| entry.kind == SimpleFileKind::Directory)
294                .filter_map(|dir| self.fs.list_files(dir).ok())
295                .flat_map(|listing| listing.entries)
296                .filter(|entry| component.matches(&entry.name))
297                .collect();
298        }
299
300        found.retain(|entry| target.kind.is_none_or(|kind| kind == entry.kind));
301        found
302    }
303
304    /// Report the records of worktrees whose checkout is gone.
305    ///
306    /// This reads the records directly rather than through [`FileSystem`], because
307    /// deciding staleness means following a `gitdir` pointer out of the tree being
308    /// walked. The logic is covered by the tests in [`crate::git`].
309    fn claim_stale_worktrees(&self, rule: &Rule, dir: &FileInfo) {
310        crate::git::stale_worktree_records(&dir.path.join(".git"))
311            .into_iter()
312            .for_each(|record| {
313                let name = record
314                    .file_name()
315                    .map(|name| name.to_string_lossy().into_owned())
316                    .unwrap_or_default();
317                self.claim(rule, FileInfo::new(record, name, SimpleFileKind::Directory));
318            });
319    }
320
321    /// Note the checkouts of this repository's linked worktrees for later walking.
322    ///
323    /// A worktree is a working copy with its own build output, and it is routinely parked
324    /// under a hidden directory that the walk would otherwise never enter.
325    fn queue_linked_worktrees(&self, dir: &FileInfo) {
326        let found = crate::git::linked_worktree_paths(&dir.path.join(".git"));
327
328        self.pending_worktrees
329            .borrow_mut()
330            .extend(found.into_iter().map(|path| {
331                let name = path
332                    .file_name()
333                    .map(|name| name.to_string_lossy().into_owned())
334                    .unwrap_or_default();
335                FileInfo::new(path, name, SimpleFileKind::Directory)
336            }));
337    }
338
339    /// Claim `file` for `rule` and report it, unless it must not be claimed.
340    ///
341    /// Every claim goes through here, so being ignored, overlapping an existing candidate
342    /// and recording what has been claimed are decided in one place instead of in each
343    /// caller. Returns whether the claim was taken.
344    fn claim(&self, rule: &Rule, file: FileInfo) -> bool {
345        if self.is_ignored(&file.path) || self.pruned.is_already_claimed(&file.path) {
346            return false;
347        }
348        self.pruned.insert(file.path.clone());
349
350        let size = match self.fs.file_size(&file) {
351            Ok(size) => Some(size),
352            Err(report) => {
353                // The candidate is still offered; only its size is unknown.
354                log::debug!("cannot size {}: {report:#}", file.path.display());
355                None
356            }
357        };
358        log::debug!(
359            "rule `{}` claims {} ({})",
360            rule.name,
361            file.path.display(),
362            size.map_or_else(
363                || "size unknown".to_string(),
364                |size| format!("{size} bytes")
365            )
366        );
367        self.candidates_found.update(|n| n + 1);
368        self.notifier
369            .notify_candidate_for_removal(RemovalCandidate::new(rule.name.clone(), file, size));
370        true
371    }
372
373    fn is_ignored(&self, path: &Path) -> bool {
374        self.options.ignores.contains(path)
375    }
376
377    fn is_walkable(&self, file: &FileInfo, depth: usize) -> bool {
378        file.kind == SimpleFileKind::Directory
379            && self.within_depth(depth)
380            && self.is_scannable_name(&file.name)
381            && self.stays_on_one_filesystem(file)
382    }
383
384    fn within_depth(&self, depth: usize) -> bool {
385        self.options
386            .max_depth
387            .is_none_or(|max_depth| depth < max_depth)
388    }
389
390    fn is_scannable_name(&self, name: &str) -> bool {
391        if VCS_DIRS.contains(&name) {
392            log::trace!("skipping {name}: version control metadata");
393            false
394        } else if name.starts_with('.') {
395            let scannable = self.options.walk_all || self.options.scanned_hidden.contains(name);
396            if !scannable {
397                // The most common reason a user reports something as "not found".
398                log::debug!("skipping hidden {name}; use --all to descend into it");
399            }
400            scannable
401        } else {
402            true
403        }
404    }
405
406    fn stays_on_one_filesystem(&self, file: &FileInfo) -> bool {
407        match (self.options.one_file_system, *self.root_device.borrow()) {
408            (true, Some(root)) => self.fs.device_id(file).is_none_or(|device| device == root),
409            _ => true,
410        }
411    }
412}
413
414#[cfg(test)]
415mod tests;