use std::borrow::Cow;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::SystemTime;
use ignore::gitignore::Gitignore;
use ignore::{DirEntry, WalkBuilder, WalkState};
use crate::detect::Detector;
use crate::fallback::{DEFAULT_MIN_SIZE, Fallback, FallbackReport};
use crate::git;
use crate::rules::{Kind, Rule, Ruleset};
use crate::size::{Measurer, Size, SizeMode};
use crate::tree::Tree;
pub const UNLABELLED: &str = "Gitignored, kind unknown";
pub const WORK_TREE_LABEL: &str = "Git · linked work tree";
pub const WORK_TREE_FLOOR: std::time::Duration = std::time::Duration::from_secs(14 * 24 * 60 * 60);
#[derive(Debug, Clone)]
pub enum Claim {
Rule(RuleClaim),
Ignored(IgnoredClaim),
IgnoredFile(IgnoredFileClaim),
WorkTree,
}
fn claims_work_tree(dir: &Path) -> bool {
if !git::is_work_tree_root(dir) {
return false;
}
let idle = dir
.symlink_metadata()
.ok()
.and_then(|metadata| metadata.modified().ok())
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
.is_some_and(|age| age >= WORK_TREE_FLOOR);
idle && git::checkout_at(dir) == Some(git::Checkout::Linked)
&& git::head_on_branch(dir)
&& git::is_clean(dir)
}
#[derive(Debug, Clone)]
pub struct RuleClaim {
pub rule: Arc<Rule>,
pub project_root: PathBuf,
}
#[derive(Debug, Clone)]
pub struct IgnoredClaim {
pub work_tree: PathBuf,
}
#[derive(Debug, Clone)]
pub struct IgnoredFileClaim {
pub work_tree: PathBuf,
pub kind: Option<Kind>,
}
#[derive(Debug, Clone)]
pub struct Hit {
pub path: PathBuf,
pub claim: Claim,
pub size: Size,
pub modified: Option<SystemTime>,
}
impl Hit {
#[must_use]
pub fn age(&self, now: SystemTime) -> Option<std::time::Duration> {
now.duration_since(self.modified?).ok()
}
#[must_use]
pub fn label(&self) -> Cow<'_, str> {
match &self.claim {
Claim::Rule(claim) => Cow::Owned(claim.rule.label()),
Claim::Ignored(_) => Cow::Borrowed(UNLABELLED),
Claim::IgnoredFile(claim) => match claim.kind {
Some(kind) => Cow::Owned(format!("Gitignored, {}", kind.short())),
None => Cow::Borrowed(UNLABELLED),
},
Claim::WorkTree => Cow::Borrowed(WORK_TREE_LABEL),
}
}
#[must_use]
pub fn kind(&self) -> Option<Kind> {
match &self.claim {
Claim::Rule(claim) => Some(claim.rule.kind),
Claim::IgnoredFile(claim) => claim.kind,
Claim::Ignored(_) | Claim::WorkTree => None,
}
}
#[must_use]
pub fn rule(&self) -> Option<&Rule> {
match &self.claim {
Claim::Rule(claim) => Some(&claim.rule),
Claim::Ignored(_) | Claim::IgnoredFile(_) | Claim::WorkTree => None,
}
}
#[must_use]
pub fn is_ignored_file(&self) -> bool {
matches!(self.claim, Claim::IgnoredFile(_))
}
}
#[derive(Debug)]
pub enum Found {
Claim(Hit),
Pricing(PathBuf),
Priced(Priced),
}
#[derive(Debug, Clone)]
pub struct Priced {
pub path: PathBuf,
pub size: Size,
}
struct Job {
path: PathBuf,
metadata: std::fs::Metadata,
}
#[derive(Debug)]
pub struct WalkError {
pub path: Option<PathBuf>,
pub message: String,
pub forbidden: bool,
}
impl WalkError {
#[must_use]
pub fn is_forbidden(&self) -> bool {
self.forbidden
}
}
#[derive(Debug, Default)]
pub struct WalkOutcome {
pub hits: usize,
pub reclaimable_bytes: u64,
pub unmeasured: usize,
pub fallback: FallbackReport,
pub errors: Vec<WalkError>,
pub excluded: usize,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub struct Walker {
root: PathBuf,
ruleset: Arc<Ruleset>,
threads: Option<usize>,
max_depth: Option<usize>,
follow_links: bool,
same_file_system: bool,
size_mode: SizeMode,
fallback: bool,
ignored_files: bool,
min_size: u64,
excludes: Arc<Gitignore>,
}
impl Walker {
#[must_use]
pub fn new(root: impl AsRef<Path>, ruleset: Arc<Ruleset>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
ruleset,
threads: None,
max_depth: None,
follow_links: false,
same_file_system: true,
size_mode: SizeMode::default(),
fallback: true,
ignored_files: false,
min_size: DEFAULT_MIN_SIZE,
excludes: Arc::new(Gitignore::empty()),
}
}
#[must_use]
pub fn excludes(mut self, excludes: Arc<Gitignore>) -> Self {
self.excludes = excludes;
self
}
#[must_use]
pub fn threads(mut self, threads: usize) -> Self {
self.threads = Some(threads);
self
}
#[must_use]
pub fn max_depth(mut self, max_depth: Option<usize>) -> Self {
self.max_depth = max_depth;
self
}
#[must_use]
pub fn follow_links(mut self, follow_links: bool) -> Self {
self.follow_links = follow_links;
self
}
#[must_use]
pub fn same_file_system(mut self, same_file_system: bool) -> Self {
self.same_file_system = same_file_system;
self
}
#[must_use]
pub fn size_mode(mut self, size_mode: SizeMode) -> Self {
self.size_mode = size_mode;
self
}
#[must_use]
pub fn fallback(mut self, fallback: bool) -> Self {
self.fallback = fallback;
self
}
#[must_use]
pub fn ignored_files(mut self, ignored_files: bool) -> Self {
self.ignored_files = ignored_files;
self
}
#[must_use]
pub fn min_size(mut self, min_size: u64) -> Self {
self.min_size = min_size;
self
}
pub fn run<F>(&self, on_found: F) -> WalkOutcome
where
F: Fn(Found) + Send + Sync,
{
let fallback = self
.fallback
.then(|| Fallback::new(&self.root, self.min_size, self.ignored_files));
let scan = Scan {
root: self.root.as_path(),
detector: self.ruleset.detector(),
measurer: Measurer::new(self.size_mode.clone()).same_file_system(self.same_file_system),
min_size: self.min_size,
on_found,
errors: Mutex::new(Vec::new()),
hits: AtomicUsize::new(0),
fallback_hits: AtomicUsize::new(0),
file_hits: AtomicUsize::new(0),
holding_a_checkout: AtomicUsize::new(0),
reclaimed: AtomicU64::new(0),
unmeasured: AtomicUsize::new(0),
};
let threads = self.threads.unwrap_or_else(|| {
std::thread::available_parallelism().map_or(1, std::num::NonZero::get)
});
let pricers = if self.size_mode == SizeMode::Skip {
0
} else {
threads
};
let excluded = Arc::new(AtomicUsize::new(0));
let builder = self.builder(threads, &excluded);
let (submit, queue) = std::sync::mpsc::channel::<Job>();
let queue = Mutex::new(queue);
std::thread::scope(|pool| {
for _ in 0..pricers {
pool.spawn(|| scan.price(&queue));
}
{
let submit = submit;
builder.build_parallel().run(|| {
let mut tier_two = fallback.as_ref().map(Fallback::thread);
let scan = &scan;
let submit = submit.clone();
Box::new(move |result| scan.visit(tier_two.as_mut(), &submit, result))
});
}
});
let mut errors = std::mem::take(&mut *lock(&scan.errors));
let fallback_hits = scan.fallback_hits.load(Ordering::Relaxed);
let fallback = match &fallback {
Some(fallback) => {
let (report, mut inert) = fallback.finish(
fallback_hits,
scan.file_hits.load(Ordering::Relaxed),
scan.holding_a_checkout.load(Ordering::Relaxed),
);
errors.append(&mut inert);
report
}
None => FallbackReport {
min_size: self.min_size,
..FallbackReport::default()
},
};
WalkOutcome {
hits: scan.hits.load(Ordering::Relaxed),
reclaimable_bytes: scan.reclaimed.load(Ordering::Relaxed),
unmeasured: scan.unmeasured.load(Ordering::Relaxed),
fallback,
errors,
excluded: excluded.load(Ordering::Relaxed),
}
}
fn builder(&self, threads: usize, excluded: &Arc<AtomicUsize>) -> WalkBuilder {
let mut builder = WalkBuilder::new(self.root.as_path());
let matcher = Arc::clone(&self.excludes);
let counted = Arc::clone(excluded);
builder
.hidden(false)
.parents(false)
.ignore(false)
.git_global(false)
.git_ignore(false)
.git_exclude(false)
.follow_links(self.follow_links)
.same_file_system(self.same_file_system)
.threads(threads)
.max_depth(self.max_depth)
.filter_entry(move |entry| {
if entry.file_name() == OsStr::new(".git") {
return false;
}
let directory = entry.file_type().is_some_and(|kind| kind.is_dir());
if matcher.matched(entry.path(), directory).is_ignore() {
counted.fetch_add(1, Ordering::Relaxed);
return false;
}
true
});
builder
}
#[must_use]
pub fn run_to_tree(&self) -> (Tree, WalkOutcome) {
let tree = Mutex::new(Tree::new(&self.root));
let stray = Mutex::new(Vec::new());
let mut outcome = self.run(|found| match found {
Found::Claim(hit) => {
let path = hit.path.clone();
if lock(&tree).insert(hit).is_none() {
lock(&stray).push(WalkError {
path: Some(path),
message: "claimed directory is not under the scan root".to_owned(),
forbidden: false,
});
}
}
Found::Pricing(_) => {}
Found::Priced(priced) => {
if lock(&tree).price(&priced.path, priced.size).is_none() {
lock(&stray).push(WalkError {
path: Some(priced.path),
message: "priced directory is not an unpriced claim in this tree"
.to_owned(),
forbidden: false,
});
}
}
});
outcome.errors.append(&mut lock(&stray));
let tree = tree.into_inner().unwrap_or_else(PoisonError::into_inner);
(tree, outcome)
}
}
struct Scan<'a, F> {
root: &'a Path,
detector: &'a Detector,
measurer: Measurer,
min_size: u64,
on_found: F,
errors: Mutex<Vec<WalkError>>,
hits: AtomicUsize,
fallback_hits: AtomicUsize,
file_hits: AtomicUsize,
holding_a_checkout: AtomicUsize,
reclaimed: AtomicU64,
unmeasured: AtomicUsize,
}
impl<F> Scan<'_, F>
where
F: Fn(Found) + Send + Sync,
{
fn visit(
&self,
tier_two: Option<&mut crate::fallback::Thread<'_>>,
submit: &std::sync::mpsc::Sender<Job>,
result: Result<DirEntry, ignore::Error>,
) -> WalkState {
let entry = match result {
Ok(entry) => entry,
Err(err) => {
let path = error_path(&err);
match err.io_error() {
Some(io) => self.fail_io(path, io),
None => self.fail(path, err.to_string()),
}
return WalkState::Continue;
}
};
if entry.depth() == 0 {
return WalkState::Continue;
}
let Some(file_type) = entry.file_type() else {
return WalkState::Continue;
};
let leaf = !file_type.is_dir();
let Some(claim) = self.judge(tier_two, &entry, leaf) else {
return WalkState::Continue;
};
let metadata = match entry.metadata() {
Ok(metadata) => metadata,
Err(err) => {
let path = Some(entry.path().to_path_buf());
match err.io_error() {
Some(io) => self.fail_io(path, io),
None => self.fail(path, err.to_string()),
}
return WalkState::Skip;
}
};
let queued = matches!(claim, Claim::Rule(_) | Claim::WorkTree)
&& self.measurer.traverses(entry.path(), &metadata);
let size = match &claim {
Claim::Rule(_) | Claim::WorkTree if queued => Size::Unmeasured,
Claim::Rule(_) | Claim::WorkTree => {
let measured = self.measurer.measure(entry.path(), &metadata);
self.report_blind_spots(
measured.unreadable,
"unreadable, so this size is a lower bound",
);
measured.size
}
Claim::Ignored(_) => match self.survey(entry.path(), &metadata) {
Some(size) => size,
None => return WalkState::Continue,
},
Claim::IgnoredFile(_) => self.measurer.measure(entry.path(), &metadata).size,
};
self.hits.fetch_add(1, Ordering::Relaxed);
match &claim {
Claim::Rule(_) | Claim::WorkTree => {}
Claim::Ignored(_) => {
self.fallback_hits.fetch_add(1, Ordering::Relaxed);
}
Claim::IgnoredFile(_) => {
self.fallback_hits.fetch_add(1, Ordering::Relaxed);
self.file_hits.fetch_add(1, Ordering::Relaxed);
}
}
match size.bytes() {
Some(bytes) => {
self.reclaimed.fetch_add(bytes, Ordering::Relaxed);
}
None => {
self.unmeasured.fetch_add(1, Ordering::Relaxed);
}
}
let after = if leaf {
WalkState::Continue
} else {
WalkState::Skip
};
let path = entry.into_path();
let queued = queued.then(|| path.clone());
(self.on_found)(Found::Claim(Hit {
path,
claim,
size,
modified: metadata.modified().ok(),
}));
if let Some(path) = queued {
if let Err(returned) = submit.send(Job { path, metadata }) {
self.fail(
Some(returned.0.path),
"could not be queued for pricing".to_owned(),
);
}
}
after
}
fn judge(
&self,
tier_two: Option<&mut crate::fallback::Thread<'_>>,
entry: &DirEntry,
leaf: bool,
) -> Option<Claim> {
let wanted = tier_two
.as_ref()
.is_some_and(|tier_two| tier_two.claims_files());
if leaf && !wanted && entry.file_type().is_some_and(|kind| !kind.is_symlink()) {
return None;
}
if let Some(rule) = self.detector.detect(entry.path(), self.root, entry.depth()) {
return Some(Claim::Rule(rule));
}
if !leaf && claims_work_tree(entry.path()) {
return Some(Claim::WorkTree);
}
let work_tree = tier_two
.filter(|_| !leaf || wanted)
.and_then(|tier_two| tier_two.judge(entry.path(), !leaf))?;
Some(if leaf {
Claim::IgnoredFile(IgnoredFileClaim {
work_tree,
kind: entry.file_name().to_str().and_then(Kind::of_ignored_file),
})
} else {
Claim::Ignored(IgnoredClaim { work_tree })
})
}
fn survey(&self, path: &Path, metadata: &std::fs::Metadata) -> Option<Size> {
let surveyed = self.measurer.survey(path, metadata);
let blind_spots = !surveyed.unreadable.is_empty() || !surveyed.not_crossed.is_empty();
self.report_blind_spots(
surveyed.unreadable,
"unreadable, so this subtree could not be judged reclaimable",
);
self.report_blind_spots(
surveyed.not_crossed,
"on another filesystem, so this subtree could not be judged reclaimable",
);
if blind_spots {
return None;
}
if surveyed.nested_repo.is_some() {
self.holding_a_checkout.fetch_add(1, Ordering::Relaxed);
return None;
}
surveyed
.size
.bytes()
.is_some_and(|bytes| bytes >= self.min_size)
.then_some(surveyed.size)
}
fn price(&self, queue: &Mutex<std::sync::mpsc::Receiver<Job>>) {
loop {
let job = lock(queue).recv();
let Ok(job) = job else { return };
(self.on_found)(Found::Pricing(job.path.clone()));
let measured = self.measurer.measure(&job.path, &job.metadata);
self.report_blind_spots(
measured.unreadable,
"unreadable, so this size is a lower bound",
);
if let Some(bytes) = measured.size.bytes() {
self.reclaimed.fetch_add(bytes, Ordering::Relaxed);
self.unmeasured.fetch_sub(1, Ordering::Relaxed);
}
(self.on_found)(Found::Priced(Priced {
path: job.path,
size: measured.size,
}));
}
}
fn fail(&self, path: Option<PathBuf>, message: String) {
lock(&self.errors).push(WalkError {
path,
message,
forbidden: false,
});
}
fn fail_io(&self, path: Option<PathBuf>, err: &std::io::Error) {
lock(&self.errors).push(WalkError {
path,
message: err.to_string(),
forbidden: err.kind() == std::io::ErrorKind::PermissionDenied,
});
}
fn report_blind_spots(&self, paths: Vec<PathBuf>, message: &str) {
if paths.is_empty() {
return;
}
let mut errors = lock(&self.errors);
for path in paths {
errors.push(WalkError {
path: Some(path),
forbidden: false,
message: message.to_owned(),
});
}
}
}
fn error_path(err: &ignore::Error) -> Option<PathBuf> {
match err {
ignore::Error::WithPath { path, .. } => Some(path.clone()),
ignore::Error::WithDepth { err, .. } | ignore::Error::WithLineNumber { err, .. } => {
error_path(err)
}
ignore::Error::Loop { child, .. } => Some(child.clone()),
_ => None,
}
}
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}