whereNotNull('keep_days') ->where('keep_days', '>', 0) // Only where this machine can see the files. An SFTP destination is // somebody else's disk with somebody else's retention on it. ->where('driver', ExportTarget::LOCAL) ->get(); foreach ($targets as $target) { $this->prune($target); } return self::SUCCESS; } private function prune(ExportTarget $target): void { $root = rtrim($target->path, '/'); if (! is_dir($root)) { // The mount is gone. Saying nothing and doing nothing is right: // this is a cleanup, and a cleanup must never be the thing that // reports a broken mount — nor act on whatever is underneath it. $this->warn("{$target->name}: path is not there, skipping ({$root})"); return; } $cutoff = now()->subDays((int) $target->keep_days)->startOfDay(); $removed = 0; foreach ((array) scandir($root) as $entry) { if ($entry === '.' || $entry === '..' || ! is_dir($root.'/'.$entry)) { continue; } $date = $this->dateFrom($entry); // Not a dated folder: left alone, deliberately. A prune that // deletes what it does not recognise is how an archive loses // something nobody was watching. if ($date === null || $date->gte($cutoff)) { continue; } if ($this->option('dry-run')) { $this->line("would remove {$root}/{$entry}"); $removed++; continue; } $this->removeTree($root.'/'.$entry); $removed++; } $this->line("{$target->name}: {$removed} folder(s) older than {$target->keep_days} days."); } /** * Only the exact shapes this application writes. * * Matched with a pattern BEFORE parsing, not by asking Carbon and catching * what it does with nonsense: under strict mode createFromFormat throws * rather than returning false, so a folder somebody made by hand would end * the whole prune instead of being skipped. The pattern also says plainly * which two shapes are recognised, which is the thing a reader wants to * know here. */ private function dateFrom(string $name): ?Carbon { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $name, $m)) { return Carbon::createFromFormat('Y-m-d', $name)?->startOfDay(); } if (preg_match('/^\d{4}$/', $name)) { return Carbon::createFromFormat('Y-m-d', $name.'-12-31')?->startOfDay(); } return null; } private function removeTree(string $directory): void { foreach ((array) scandir($directory) as $entry) { if ($entry === '.' || $entry === '..') { continue; } $path = $directory.'/'.$entry; is_dir($path) ? $this->removeTree($path) : @unlink($path); } @rmdir($directory); } }