From 5f0e1b72c53eb039e63b7621dbace8ab2d2de67c Mon Sep 17 00:00:00 2001 From: Matteo Tognolini Date: Tue, 22 Sep 2026 11:01:41 +0200 Subject: [PATCH 1/3] Delete an entry's revisions when the entry is deleted. Revisions and the working copy were left in the revisions store after an entry was removed. Delete them through the revision repository so the configured storage path and the Eloquent driver are both respected, and drop the entry directory once it is empty. Co-authored-by: Cursor --- src/Entries/Entry.php | 2 + src/Revisions/Revisable.php | 7 + src/Revisions/RevisionRepository.php | 6 + tests/Feature/Entries/EntryRevisionsTest.php | 137 +++++++++++++++++++ 4 files changed, 152 insertions(+) diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index b1113005624..abcc1d3453d 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -281,6 +281,8 @@ public function delete() Facades\Entry::delete($this); + $this->deleteRevisions(); + if ($withEvents) { EntryDeleted::dispatch($this); } diff --git a/src/Revisions/Revisable.php b/src/Revisions/Revisable.php index 8091eaa1d73..5d225ec2eb1 100644 --- a/src/Revisions/Revisable.php +++ b/src/Revisions/Revisable.php @@ -81,6 +81,13 @@ public function deleteWorkingCopy() return optional($this->workingCopy())->delete(); } + public function deleteRevisions() + { + $this->revisions()->each->delete(); + + $this->deleteWorkingCopy(); + } + public function publishWorkingCopy($options = []) { $item = $this->fromWorkingCopy(); diff --git a/src/Revisions/RevisionRepository.php b/src/Revisions/RevisionRepository.php index 295417e112f..d9119fece2c 100644 --- a/src/Revisions/RevisionRepository.php +++ b/src/Revisions/RevisionRepository.php @@ -58,6 +58,12 @@ public function save(RevisionContract $revision) public function delete(RevisionContract $revision) { $this->store->delete($revision); + + $directory = $this->directory().'/'.$revision->key(); + + if (File::exists($directory) && File::isEmpty($directory)) { + File::delete($directory); + } } public function query() diff --git a/tests/Feature/Entries/EntryRevisionsTest.php b/tests/Feature/Entries/EntryRevisionsTest.php index 8953729b239..b25a45ebc97 100644 --- a/tests/Feature/Entries/EntryRevisionsTest.php +++ b/tests/Feature/Entries/EntryRevisionsTest.php @@ -5,10 +5,16 @@ use Facades\Statamic\Fields\BlueprintRepository; use Facades\Tests\Factories\EntryFactory; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Event; use PHPUnit\Framework\Attributes\Test; +use Statamic\Events\EntryDeleted; +use Statamic\Events\EntryDeleting; +use Statamic\Events\RevisionDeleted; use Statamic\Facades\Collection; use Statamic\Facades\Entry; use Statamic\Facades\Folder; +use Statamic\Facades\Revision as Revisions; +use Statamic\Facades\Stache; use Statamic\Facades\User; use Statamic\Fields\Blueprint; use Statamic\Revisions\Revision; @@ -868,6 +874,137 @@ public function revision_localizations_only_includes_authorized_sites() $this->assertEquals(['en', 'fr'], array_column($localizations, 'handle')); } + #[Test] + public function it_deletes_revisions_and_the_working_copy_when_the_entry_is_deleted() + { + Event::fake([RevisionDeleted::class]); + + [$entry, $revisions, $workingCopy] = $this->entryWithRevisions('1'); + + $this->assertRevisionFilesExist($entry, $revisions, $workingCopy); + + $entry->delete(); + + $this->assertRevisionFilesAreGone($entry, $revisions, $workingCopy); + $this->assertCount(0, $entry->revisions()); + $this->assertNull($entry->workingCopy()); + Event::assertDispatchedTimes(RevisionDeleted::class, 3); + } + + #[Test] + public function it_leaves_revisions_belonging_to_other_entries() + { + [$entry, $revisions, $workingCopy] = $this->entryWithRevisions('1'); + [$other, $otherRevisions, $otherWorkingCopy] = $this->entryWithRevisions('2'); + + $entry->delete(); + + $this->assertRevisionFilesAreGone($entry, $revisions, $workingCopy); + $this->assertRevisionFilesExist($other, $otherRevisions, $otherWorkingCopy); + $this->assertCount(2, $other->revisions()); + $this->assertNotNull($other->workingCopy()); + } + + #[Test] + public function it_keeps_revisions_when_entry_deletion_is_cancelled() + { + Event::fake([RevisionDeleted::class]); + + Event::listen(EntryDeleting::class, function () { + return false; + }); + + [$entry, $revisions, $workingCopy] = $this->entryWithRevisions('1'); + + $this->assertFalse($entry->delete()); + + $this->assertRevisionFilesExist($entry, $revisions, $workingCopy); + $this->assertCount(2, $entry->revisions()); + $this->assertNotNull($entry->workingCopy()); + Event::assertNotDispatched(RevisionDeleted::class); + } + + #[Test] + public function it_deletes_revisions_when_the_entry_is_deleted_quietly() + { + Event::fake([EntryDeleted::class, RevisionDeleted::class]); + + [$entry, $revisions, $workingCopy] = $this->entryWithRevisions('1'); + + $entry->deleteQuietly(); + + $this->assertRevisionFilesAreGone($entry, $revisions, $workingCopy); + Event::assertNotDispatched(EntryDeleted::class); + Event::assertDispatchedTimes(RevisionDeleted::class, 3); + } + + #[Test] + public function it_deletes_revisions_from_a_custom_revisions_path() + { + $custom = $this->dir.'/revisions'; + + config(['statamic.revisions.path' => $custom]); + + Stache::store('revisions')->directory(config('statamic.revisions.path')); + + [$entry, $revisions, $workingCopy] = $this->entryWithRevisions('1'); + + $this->assertStringStartsWith($custom, $revisions[0]->path()); + $this->assertRevisionFilesExist($entry, $revisions, $workingCopy); + + $entry->delete(); + + $this->assertRevisionFilesAreGone($entry, $revisions, $workingCopy); + $this->assertFileDoesNotExist($this->fakeStacheDirectory.'/revisions/collections/blog/en/1'); + } + + private function entryWithRevisions(string $id): array + { + $entry = EntryFactory::id($id) + ->slug('entry-'.$id) + ->collection('blog') + ->date('2010-12-25') + ->data(['title' => 'Title '.$id]) + ->create(); + + $revisions = collect([ + $entry->makeRevision()->message('Revision one')->date(Carbon::parse('2017-02-01')), + $entry->makeRevision()->message('Revision two')->date(Carbon::parse('2017-02-03')), + ]); + + $revisions->each->save(); + + $workingCopy = $entry->makeWorkingCopy(); + $workingCopy->save(); + + return [$entry, $revisions->all(), $workingCopy]; + } + + private function assertRevisionFilesExist($entry, array $revisions, Revision $workingCopy): void + { + foreach ($revisions as $revision) { + $this->assertFileExists($revision->path()); + } + + $this->assertFileExists($workingCopy->path()); + $this->assertDirectoryExists($this->revisionDirectory($entry)); + } + + private function assertRevisionFilesAreGone($entry, array $revisions, Revision $workingCopy): void + { + foreach ($revisions as $revision) { + $this->assertFileDoesNotExist($revision->path()); + } + + $this->assertFileDoesNotExist($workingCopy->path()); + $this->assertDirectoryDoesNotExist($this->revisionDirectory($entry)); + } + + private function revisionDirectory($entry): string + { + return Revisions::directory().'/collections/blog/en/'.$entry->id(); + } + private function setTestBlueprint($handle, $fields) { $blueprint = Blueprint::makeFromFields($fields)->setHandle($handle); From 6fdff8d24720f5aafed24b370082e6f08629f662 Mon Sep 17 00:00:00 2001 From: Matteo Tognolini Date: Tue, 22 Sep 2026 11:28:39 +0200 Subject: [PATCH 2/3] Normalize the custom revisions path in tests. Windows keeps backslashes from __DIR__, while the revisions store tidies paths to forward slashes, so the prefix assertion failed on that platform. Co-authored-by: Cursor --- tests/Feature/Entries/EntryRevisionsTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Feature/Entries/EntryRevisionsTest.php b/tests/Feature/Entries/EntryRevisionsTest.php index b25a45ebc97..94b2fd8ba1c 100644 --- a/tests/Feature/Entries/EntryRevisionsTest.php +++ b/tests/Feature/Entries/EntryRevisionsTest.php @@ -13,6 +13,7 @@ use Statamic\Facades\Collection; use Statamic\Facades\Entry; use Statamic\Facades\Folder; +use Statamic\Facades\Path; use Statamic\Facades\Revision as Revisions; use Statamic\Facades\Stache; use Statamic\Facades\User; @@ -941,7 +942,7 @@ public function it_deletes_revisions_when_the_entry_is_deleted_quietly() #[Test] public function it_deletes_revisions_from_a_custom_revisions_path() { - $custom = $this->dir.'/revisions'; + $custom = Path::tidy($this->dir.'/revisions'); config(['statamic.revisions.path' => $custom]); From 40a56be5ebc81440cf15d31b01401c7af7d721b3 Mon Sep 17 00:00:00 2001 From: Matteo Tognolini Date: Wed, 23 Sep 2026 12:40:32 +0200 Subject: [PATCH 3/3] Delete revisions quietly when an entry is deleted quietly. A quiet entry delete was still firing RevisionDeleted for each leftover revision and working copy. Follow the core withEvents pattern so those deletes stay silent. Co-authored-by: Cursor --- src/Entries/Entry.php | 2 +- src/Revisions/Revisable.php | 7 +++++++ src/Revisions/Revision.php | 15 ++++++++++++++- tests/Feature/Entries/EntryRevisionsTest.php | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index abcc1d3453d..ca3f06fe95a 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -281,7 +281,7 @@ public function delete() Facades\Entry::delete($this); - $this->deleteRevisions(); + $withEvents ? $this->deleteRevisions() : $this->deleteRevisionsQuietly(); if ($withEvents) { EntryDeleted::dispatch($this); diff --git a/src/Revisions/Revisable.php b/src/Revisions/Revisable.php index 5d225ec2eb1..17595a375db 100644 --- a/src/Revisions/Revisable.php +++ b/src/Revisions/Revisable.php @@ -88,6 +88,13 @@ public function deleteRevisions() $this->deleteWorkingCopy(); } + public function deleteRevisionsQuietly() + { + $this->revisions()->each->deleteQuietly(); + + optional($this->workingCopy())->deleteQuietly(); + } + public function publishWorkingCopy($options = []) { $item = $this->fromWorkingCopy(); diff --git a/src/Revisions/Revision.php b/src/Revisions/Revision.php index 94b660815d4..0e3f9b298de 100644 --- a/src/Revisions/Revision.php +++ b/src/Revisions/Revision.php @@ -30,6 +30,7 @@ class Revision implements Arrayable, ContainsQueryableValues, Contract protected $message; protected $action = 'revision'; protected $attributes = []; + protected $withEvents = true; public function id() { @@ -147,11 +148,23 @@ public function save() return true; } + public function deleteQuietly() + { + $this->withEvents = false; + + return $this->delete(); + } + public function delete() { + $withEvents = $this->withEvents; + $this->withEvents = true; + Revisions::delete($this); - RevisionDeleted::dispatch($this); + if ($withEvents) { + RevisionDeleted::dispatch($this); + } } public function isWorkingCopy(): bool diff --git a/tests/Feature/Entries/EntryRevisionsTest.php b/tests/Feature/Entries/EntryRevisionsTest.php index 94b2fd8ba1c..9c7280922bc 100644 --- a/tests/Feature/Entries/EntryRevisionsTest.php +++ b/tests/Feature/Entries/EntryRevisionsTest.php @@ -936,7 +936,7 @@ public function it_deletes_revisions_when_the_entry_is_deleted_quietly() $this->assertRevisionFilesAreGone($entry, $revisions, $workingCopy); Event::assertNotDispatched(EntryDeleted::class); - Event::assertDispatchedTimes(RevisionDeleted::class, 3); + Event::assertNotDispatched(RevisionDeleted::class); } #[Test]