From e057ecfcb24d1c3ec2817d0cf0d011337f10a44d Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 6 Nov 2026 01:41:17 +0000 Subject: [PATCH 1/5] Add tests for the Stache watcher handling deleted and renamed files These fail on 6.x: a file deleted on disk is never removed from the Stache, and a renamed file keeps pointing at its old path for the rest of the request. --- tests/Stache/BasicStoreTest.php | 91 +++++++++++++++++++++++- tests/Stache/Stores/EntriesStoreTest.php | 34 +++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/tests/Stache/BasicStoreTest.php b/tests/Stache/BasicStoreTest.php index abaabbe526f..9475ac9face 100644 --- a/tests/Stache/BasicStoreTest.php +++ b/tests/Stache/BasicStoreTest.php @@ -115,6 +115,71 @@ public function rebuilding_paths_caches_file_timestamps_for_the_watcher() $this->assertEquals([$this->tempDir.'/foo.yaml' => 1234567890], Cache::get('stache::timestamps::test')); } + + #[Test] + public function the_watcher_removes_an_item_whose_file_was_deleted() + { + config(['statamic.stache.watcher' => true]); + + file_put_contents($this->tempDir.'/foo.yaml', ''); + file_put_contents($this->tempDir.'/bar.yaml', ''); + $this->store->paths(); + + unlink($this->tempDir.'/bar.yaml'); + $store = $this->nextRequest(); + + $this->assertNull($store->getItem('bar')); + $this->assertEquals(['foo'], $store->paths()->keys()->all()); + $this->assertEquals(['foo'], $store->index('path')->keys()); + } + + #[Test] + public function the_watcher_keeps_an_item_whose_file_was_renamed() + { + config(['statamic.stache.watcher' => true]); + + $this->store = (new TestBasicStoreWithIds)->directory($this->tempDir); + file_put_contents($this->tempDir.'/a.yaml', "id: one\ntitle: First"); + $this->store->paths(); + + rename($this->tempDir.'/a.yaml', $this->tempDir.'/b.yaml'); + $store = $this->nextRequest(); + + $this->assertNotNull($item = $store->getItem('one')); + $this->assertEquals('First', $item->data()['title']); + $this->assertEquals(['one' => $this->tempDir.'/b.yaml'], $store->paths()->all()); + } + + #[Test] + public function the_watcher_handles_a_deleted_and_an_added_file_in_the_same_request() + { + config(['statamic.stache.watcher' => true]); + + file_put_contents($this->tempDir.'/foo.yaml', ''); + file_put_contents($this->tempDir.'/bar.yaml', ''); + $this->store->paths(); + + unlink($this->tempDir.'/bar.yaml'); + file_put_contents($this->tempDir.'/baz.yaml', ''); + $store = $this->nextRequest(); + + $this->assertNull($store->getItem('bar')); + $this->assertNotNull($store->getItem('baz')); + $this->assertEquals(['baz', 'foo'], $store->paths()->keys()->sort()->values()->all()); + } + + /** + * A new store instance with nothing memoized, as the next request would get. + * The cache (where the Stache keeps its data between requests) is untouched. + */ + private function nextRequest() + { + $class = get_class($this->store); + app('stache.indexes')->each->resetMemoizedState(); + app()->forgetInstance('stache.indexes'); + + return $this->store = (new $class)->directory($this->tempDir); + } } class TestBasicStore extends BasicStore @@ -134,7 +199,17 @@ public function makeItemFromFile($path, $contents) $id = Str::after($path, Path::tidy(__DIR__.'/tmp/')); $id = Str::before($id, '.yaml'); - return new TestBasicStoreItem($id, $data); + return new TestBasicStoreItem($id, $data, $path); + } +} + +class TestBasicStoreWithIds extends TestBasicStore +{ + public function makeItemFromFile($path, $contents) + { + $data = YAML::parse($contents); + + return new TestBasicStoreItem($data['id'], $data, $path); } } @@ -142,15 +217,27 @@ class TestBasicStoreItem { private $id; private $data; + private $path; - public function __construct($id, $data) + public function __construct($id, $data, $path = null) { $this->id = $id; $this->data = $data; + $this->path = $path; } public function id() { return $this->id; } + + public function data() + { + return $this->data; + } + + public function path() + { + return $this->path; + } } diff --git a/tests/Stache/Stores/EntriesStoreTest.php b/tests/Stache/Stores/EntriesStoreTest.php index 705a70f3c75..75e147a4595 100644 --- a/tests/Stache/Stores/EntriesStoreTest.php +++ b/tests/Stache/Stores/EntriesStoreTest.php @@ -35,6 +35,40 @@ public function setUp(): void Stache::store('collections')->directory($this->directory); } + #[Test] + public function the_watcher_removes_an_entry_whose_file_was_deleted() + { + config(['statamic.stache.watcher' => true]); + + $path = $this->directory.'/blog/2026-01-01.watcher-test.md'; + file_put_contents($path, "---\nid: watcher-test\ntitle: 'Watcher test'\n---\n"); + + try { + $this->assertNotNull(Facades\Entry::find('watcher-test')); + + unlink($path); + $this->nextRequest(); + + $this->assertNull(Facades\Entry::find('watcher-test')); + $this->assertNotContains('watcher-test', Facades\Entry::query()->where('collection', 'blog')->get()->map->id()->all()); + } finally { + @unlink($path); + } + } + + /** + * Fresh stores and indexes with nothing memoized, as the next request would get. + * The cache (where the Stache keeps its data between requests) is untouched. + */ + private function nextRequest() + { + app('stache.indexes')->each->resetMemoizedState(); + app()->forgetInstance('stache.indexes'); + + $this->parent = (new EntriesStore)->directory($this->directory); + Stache::registerStore($this->parent); + } + #[Test] public function it_gets_nested_files() { From c8883259d0eda1a0844c7206a2635c528e96cef4 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 6 Nov 2026 01:41:17 +0000 Subject: [PATCH 2/5] Fix the Stache watcher not removing deleted files Since #11863 the watcher's list of deleted files holds keys, but the loop that flushes them still looked each one up as a path, so nothing was removed. Deleted paths are now kept with their keys, and the decision is made after the modified files are read: a key that comes back under a new path was renamed, so only its old path is released; any other key is flushed. The memoized paths are also dropped once the path index is updated, so added and renamed files are found for the rest of the request. Fixes #12824 --- src/Stache/Stores/Store.php | 41 ++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index ee31e4236a9..9ff9c0b2a32 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -217,34 +217,43 @@ public function handleFileChanges() return $existingTimestamp < $timestamp; }); - // Get all the deleted files. + // Get all the deleted files, with the key each one had. // This would be any paths that exist in the cached array that aren't there anymore. $keys = $this->paths()->flip(); $deleted = $existing->keys() - ->map(fn ($path) => $keys->get($path)) - ->diff($files->keys()->map(fn ($path) => $keys->get($path))) - ->values(); + ->diff($files->keys()) + ->mapWithKeys(fn ($path) => [$path => $keys->get($path)]) + ->filter(); // If there are no modified or deleted files, there's nothing to update. if ($modified->isEmpty() && $deleted->isEmpty()) { return; } - // Flush cached instances of deleted items. - $deleted->each(function ($path) { - collect($this->getKeyFromPath($path))->each(function ($key) use ($path) { - $this->forgetItem($key); - $this->forgetPath($key); - $this->resolveIndexes()->filter->isCached()->each->forgetItem($key); - $this->handleDeletedItem($path, $key); - }); - }); - // Get items from every file that was modified. $modified = $modified->flatMap(function ($timestamp, $path) { return Arr::wrap($this->getItemFromModifiedPath($path)); }); + // A file that was renamed or moved shows up as a deleted path plus a modified one with + // the same key. That item still exists, so only its old path is released here. It'll be + // re-added under its new path along with the other modified items below. + $modifiedKeys = $modified->map(fn ($item) => $this->getItemKey($item))->flip(); + + $deleted->each(function ($key, $path) use ($modifiedKeys) { + if ($modifiedKeys->has($key)) { + $this->keys()->forget($key); + + return; + } + + // Flush cached instances of deleted items. + $this->forgetItem($key); + $this->forgetPath($key); + $this->resolveIndexes()->filter->isCached()->each->forgetItem($key); + $this->handleDeletedItem($path, $key); + }); + // Remove items with duplicate IDs/keys $modified = $modified->reject(function ($item) { try { @@ -280,6 +289,10 @@ public function handleFileChanges() }); }); + // The paths were memoized before the changes above were applied to the path index, + // so drop them; the next call reads the updated ones (with added and renamed files). + $this->paths = null; + $this->modified = $modified; } From 31414d163bc05496db9568ed6329c98c7582e933 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 6 Nov 2026 02:04:13 +0000 Subject: [PATCH 3/5] Remove stale PHPStan baseline entry for Store::getKeyFromPath() --- .phpstan/baseline.neon | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.phpstan/baseline.neon b/.phpstan/baseline.neon index b1f8dad6fab..74817c2f7f8 100644 --- a/.phpstan/baseline.neon +++ b/.phpstan/baseline.neon @@ -204,12 +204,6 @@ parameters: count: 2 path: ../src/Stache/Stores/Store.php - - - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:getKeyFromPath\(\)\.$#' - identifier: method.notFound - count: 1 - path: ../src/Stache/Stores/Store.php - - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:makeItemFromFile\(\)\.$#' identifier: method.notFound From 67c10ae83783bba4defaa875e9fcdc6de94655f2 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 6 Nov 2026 02:18:24 +0000 Subject: [PATCH 4/5] Test that deleting a term file removes all of its localizations --- tests/Stache/Stores/TermsStoreTest.php | 32 ++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/Stache/Stores/TermsStoreTest.php b/tests/Stache/Stores/TermsStoreTest.php index 09a8168d84f..57e4734431a 100644 --- a/tests/Stache/Stores/TermsStoreTest.php +++ b/tests/Stache/Stores/TermsStoreTest.php @@ -44,4 +44,36 @@ public function it_saves_to_disk() $this->assertEquals($path, $this->parent->store('tags')->paths()->get('en::test')); } + + #[Test] + public function the_watcher_removes_every_localization_of_a_term_whose_file_was_deleted() + { + config(['statamic.stache.watcher' => true]); + + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en_US'], + 'fr' => ['url' => '/fr/', 'locale' => 'fr_FR'], + ]); + $taxonomyFile = $this->directory.'/tags.yaml'; + $taxonomyYaml = file_get_contents($taxonomyFile); + Facades\Taxonomy::make('tags')->sites(['en', 'fr'])->save(); + + $path = $this->directory.'/tags/watcher-test.yaml'; + file_put_contents($path, "title: 'Watcher test'\nlocalizations:\n fr:\n title: 'Test du watcher'\n"); + + try { + $store = $this->parent->store('tags'); + $this->assertEquals($path, $store->paths()->get('en::watcher-test')); + $this->assertEquals($path, $store->paths()->get('fr::watcher-test')); + + unlink($path); + $store->handleFileChanges(); + + $this->assertNull($store->paths()->get('en::watcher-test')); + $this->assertNull($store->paths()->get('fr::watcher-test')); + } finally { + @unlink($path); + file_put_contents($taxonomyFile, $taxonomyYaml); + } + } } From 5e0cfe5aef21f901729b3942bf16b19fcaafcb28 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 6 Nov 2026 02:18:25 +0000 Subject: [PATCH 5/5] Forget every item a deleted file held, not just one A term file holds the term and its localizations, so several keys share one path. Flipping paths() kept only one key per path, leaving the other localizations cached. --- src/Stache/Stores/Store.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index 9ff9c0b2a32..a6c2d856f40 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -217,13 +217,14 @@ public function handleFileChanges() return $existingTimestamp < $timestamp; }); - // Get all the deleted files, with the key each one had. + // Get all the deleted files, with the keys each one had. // This would be any paths that exist in the cached array that aren't there anymore. - $keys = $this->paths()->flip(); + // One file can hold several items (e.g. a term and its localizations), so a path maps to a list of keys. + $keys = $this->paths()->mapToGroups(fn ($path, $key) => [$path => $key]); $deleted = $existing->keys() ->diff($files->keys()) - ->mapWithKeys(fn ($path) => [$path => $keys->get($path)]) - ->filter(); + ->flatMap(fn ($path) => $keys->get($path, collect())->map(fn ($key) => [$path, $key])) + ->values(); // If there are no modified or deleted files, there's nothing to update. if ($modified->isEmpty() && $deleted->isEmpty()) { @@ -240,7 +241,8 @@ public function handleFileChanges() // re-added under its new path along with the other modified items below. $modifiedKeys = $modified->map(fn ($item) => $this->getItemKey($item))->flip(); - $deleted->each(function ($key, $path) use ($modifiedKeys) { + $deleted->each(function ($deletion) use ($modifiedKeys) { + [$path, $key] = $deletion; if ($modifiedKeys->has($key)) { $this->keys()->forget($key);