Skip to content

Commit 27f94d7

Browse files
[6.x] Clean Asset Meta Command (#13934)
* Clean Asset Meta Command * avoid mocking * reorder * be consistent. use the constant * simplify * avoid duplicate queries. only lookup containers once * types * don't need to translate strings in commands --------- Co-authored-by: Duncan McClean <duncan@duncanmcclean.com>
1 parent d5de76d commit 27f94d7

3 files changed

Lines changed: 240 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
namespace Statamic\Console\Commands;
4+
5+
use Illuminate\Console\Command;
6+
use Illuminate\Support\Collection;
7+
use Illuminate\Support\Str;
8+
use Statamic\Assets\AssetContainer as AssetsContainer;
9+
use Statamic\Console\RunsInPlease;
10+
use Statamic\Facades\AssetContainer;
11+
12+
use function Laravel\Prompts\progress;
13+
14+
class AssetsMetaClean extends Command
15+
{
16+
use RunsInPlease;
17+
18+
protected $signature = 'statamic:assets:meta-clean
19+
{ container? : Handle of a container }
20+
{ --dry-run : List orphaned files without deleting }';
21+
22+
protected $description = 'Clean orphaned asset metadata files';
23+
24+
public function handle(): int
25+
{
26+
$containers = $this->getContainers()->keyBy->handle();
27+
28+
$orphanedMetaFilesByContainer = $containers->map(fn ($container) => $this->getOrphanedMetaFiles($container));
29+
$orphanedMetaFilesCount = $orphanedMetaFilesByContainer->sum->count();
30+
31+
if ($orphanedMetaFilesCount === 0) {
32+
$this->components->info('No orphaned metadata files were found.');
33+
34+
return self::SUCCESS;
35+
}
36+
37+
$flatOrphanedMetaFiles = $orphanedMetaFilesByContainer
38+
->flatMap(fn (Collection $paths, string $container) => $paths->map(fn ($path) => [
39+
'container' => $container,
40+
'path' => $path,
41+
]))
42+
->values();
43+
44+
if ($this->option('dry-run')) {
45+
$this->components->warn("Found {$orphanedMetaFilesCount} orphaned metadata ".Str::plural('file', $orphanedMetaFilesCount));
46+
47+
$flatOrphanedMetaFiles->each(function (array $metaFile) {
48+
$this->line("[{$metaFile['container']}] {$metaFile['path']}");
49+
});
50+
51+
return self::SUCCESS;
52+
}
53+
54+
progress(
55+
label: 'Deleting orphaned asset metadata...',
56+
steps: $flatOrphanedMetaFiles,
57+
callback: function (array $metaFile, $progress) use ($containers) {
58+
$containers->get($metaFile['container'])->disk()->delete($metaFile['path']);
59+
$progress->advance();
60+
}
61+
);
62+
63+
$orphanedMetaFilesByContainer->each(function (Collection $metaFiles, string $container) use ($containers) {
64+
$this->deleteEmptyMetaDirectories($containers->get($container), $metaFiles);
65+
});
66+
67+
$this->components->warn("Deleted {$orphanedMetaFilesCount} orphaned metadata ".Str::plural('file', $orphanedMetaFilesCount));
68+
69+
return self::SUCCESS;
70+
}
71+
72+
private function getContainers(): Collection
73+
{
74+
if (! $container = $this->argument('container')) {
75+
return AssetContainer::all();
76+
}
77+
78+
return collect([AssetContainer::findOrFail($container)]);
79+
}
80+
81+
private function getOrphanedMetaFiles(AssetsContainer $container): Collection
82+
{
83+
$assetPaths = $container->files()->flip();
84+
85+
return $container->metaFiles()
86+
->filter(fn (string $path) => Str::endsWith($path, '.yaml'))
87+
->reject(fn (string $path) => $assetPaths->has($this->metaPathToAssetPath($path)))
88+
->values();
89+
}
90+
91+
private function metaPathToAssetPath(string $metaPath): string
92+
{
93+
$pathWithoutYamlExtension = Str::endsWith($metaPath, '.yaml')
94+
? substr($metaPath, 0, -5)
95+
: $metaPath;
96+
97+
$pathWithoutMetaDirectory = str_replace('/.meta/', '/', $pathWithoutYamlExtension);
98+
99+
if (Str::startsWith($pathWithoutMetaDirectory, '.meta/')) {
100+
$pathWithoutMetaDirectory = Str::replaceFirst('.meta/', '', $pathWithoutMetaDirectory);
101+
}
102+
103+
return ltrim($pathWithoutMetaDirectory, '/');
104+
}
105+
106+
private function deleteEmptyMetaDirectories(AssetsContainer $container, Collection $metaFiles): void
107+
{
108+
$metaDirectories = $metaFiles
109+
->map(fn (string $metaFile) => dirname($metaFile))
110+
->unique()
111+
->sortByDesc(fn (string $directory) => substr_count($directory, '/'));
112+
113+
$metaDirectories->each(function (string $metaDirectory) use ($container) {
114+
$disk = $container->disk();
115+
116+
if (! $disk->exists($metaDirectory)) {
117+
return;
118+
}
119+
120+
if ($disk->isEmpty($metaDirectory)) {
121+
$disk->filesystem()->deleteDirectory($metaDirectory);
122+
}
123+
});
124+
}
125+
}

src/Providers/ConsoleServiceProvider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class ConsoleServiceProvider extends ServiceProvider
1414
Commands\AssetsCacheClear::class,
1515
Commands\AssetsGeneratePresets::class,
1616
Commands\AssetsMeta::class,
17+
Commands\AssetsMetaClean::class,
1718
Commands\GlideClear::class,
1819
Commands\Install::class,
1920
Commands\InstallCollaboration::class,
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
namespace Tests\Console\Commands;
4+
5+
use Illuminate\Support\Facades\Storage;
6+
use PHPUnit\Framework\Attributes\Test;
7+
use Statamic\Facades\AssetContainer;
8+
use Tests\PreventSavingStacheItemsToDisk;
9+
use Tests\TestCase;
10+
11+
class AssetsMetaCleanTest extends TestCase
12+
{
13+
use PreventSavingStacheItemsToDisk;
14+
15+
public function setUp(): void
16+
{
17+
parent::setUp();
18+
19+
Storage::fake('test');
20+
Storage::fake('test_two');
21+
}
22+
23+
#[Test]
24+
public function dry_run_lists_orphaned_files_without_deleting_them()
25+
{
26+
AssetContainer::make('test')->disk('test')->save();
27+
28+
Storage::disk('test')->put('.meta/root.txt.yaml', 'size: 123');
29+
30+
$this->artisan('statamic:assets:meta-clean test --dry-run')
31+
->expectsOutputToContain('Found 1 orphaned metadata file.')
32+
->expectsOutputToContain('[test] .meta/root.txt.yaml');
33+
34+
$this->assertTrue(Storage::disk('test')->exists('.meta/root.txt.yaml'));
35+
}
36+
37+
#[Test]
38+
public function it_deletes_orphaned_meta_files_and_cleans_up_empty_meta_directories()
39+
{
40+
AssetContainer::make('test')->disk('test')->save();
41+
42+
Storage::disk('test')->put('foo/.meta/bar.txt.yaml', 'size: 123');
43+
44+
$this->assertTrue(Storage::disk('test')->exists('foo/.meta/bar.txt.yaml'));
45+
$this->assertTrue(Storage::disk('test')->exists('foo/.meta'));
46+
47+
$this->artisan('statamic:assets:meta-clean test')
48+
->expectsOutputToContain('Deleted 1 orphaned metadata file.');
49+
50+
$this->assertFalse(Storage::disk('test')->exists('foo/.meta/bar.txt.yaml'));
51+
$this->assertFalse(Storage::disk('test')->exists('foo/.meta'));
52+
}
53+
54+
#[Test]
55+
public function it_preserves_meta_files_with_matching_assets()
56+
{
57+
AssetContainer::make('test')->disk('test')->save();
58+
59+
Storage::disk('test')->put('foo/bar.txt', 'bar');
60+
Storage::disk('test')->put('foo/.meta/bar.txt.yaml', 'size: 123');
61+
62+
$this->artisan('statamic:assets:meta-clean test')
63+
->expectsOutputToContain('No orphaned metadata files were found.');
64+
65+
$this->assertTrue(Storage::disk('test')->exists('foo/.meta/bar.txt.yaml'));
66+
}
67+
68+
#[Test]
69+
public function it_only_cleans_the_requested_container()
70+
{
71+
AssetContainer::make('one')->disk('test')->save();
72+
AssetContainer::make('two')->disk('test_two')->save();
73+
74+
Storage::disk('test')->put('foo/.meta/one.jpg.yaml', 'size: 1');
75+
Storage::disk('test_two')->put('foo/.meta/two.jpg.yaml', 'size: 2');
76+
77+
$this->artisan('statamic:assets:meta-clean one')
78+
->expectsOutputToContain('Deleted 1 orphaned metadata file.');
79+
80+
$this->assertFalse(Storage::disk('test')->exists('foo/.meta/one.jpg.yaml'));
81+
$this->assertTrue(Storage::disk('test_two')->exists('foo/.meta/two.jpg.yaml'));
82+
}
83+
84+
#[Test]
85+
public function it_cleans_all_containers_when_no_container_argument_is_provided()
86+
{
87+
AssetContainer::make('one')->disk('test')->save();
88+
AssetContainer::make('two')->disk('test_two')->save();
89+
90+
Storage::disk('test')->put('foo/.meta/one.jpg.yaml', 'size: 1');
91+
Storage::disk('test_two')->put('foo/.meta/two.jpg.yaml', 'size: 2');
92+
93+
$this->artisan('statamic:assets:meta-clean')
94+
->expectsOutputToContain('Deleted 2 orphaned metadata files.');
95+
96+
$this->assertFalse(Storage::disk('test')->exists('foo/.meta/one.jpg.yaml'));
97+
$this->assertFalse(Storage::disk('test_two')->exists('foo/.meta/two.jpg.yaml'));
98+
}
99+
100+
#[Test]
101+
public function it_detects_orphaned_meta_files_in_root_and_nested_meta_directories()
102+
{
103+
AssetContainer::make('test')->disk('test')->save();
104+
105+
Storage::disk('test')->put('.meta/root.jpg.yaml', 'size: 1');
106+
Storage::disk('test')->put('foo/.meta/nested.jpg.yaml', 'size: 2');
107+
108+
$this->artisan('statamic:assets:meta-clean test')
109+
->expectsOutputToContain('Deleted 2 orphaned metadata files.');
110+
111+
$this->assertFalse(Storage::disk('test')->exists('.meta/root.jpg.yaml'));
112+
$this->assertFalse(Storage::disk('test')->exists('foo/.meta/nested.jpg.yaml'));
113+
}
114+
}

0 commit comments

Comments
 (0)