From 8e023d00ea241152ebf64c82e51210cbefe14c2b Mon Sep 17 00:00:00 2001 From: Daniel Weaver Date: Tue, 31 Mar 2026 17:02:51 -0400 Subject: [PATCH 1/6] Initial attempt --- src/Console/Commands/StacheRefresh.php | 119 ++++++- src/Console/Processes/Git.php | 42 +++ src/Facades/Git.php | 6 + src/Git/Git.php | 152 ++++++++- src/Stache/GitPathMapper.php | 171 ++++++++++ src/Stache/Stores/Store.php | 44 +++ tests/Console/Commands/StacheRefreshTest.php | 150 +++++++++ tests/Git/GitTest.php | 92 +++++- tests/Stache/GitPathMapperTest.php | 314 +++++++++++++++++++ 9 files changed, 1074 insertions(+), 16 deletions(-) create mode 100644 src/Stache/GitPathMapper.php create mode 100644 tests/Stache/GitPathMapperTest.php diff --git a/src/Console/Commands/StacheRefresh.php b/src/Console/Commands/StacheRefresh.php index d1fe60e645c..180c686a50f 100644 --- a/src/Console/Commands/StacheRefresh.php +++ b/src/Console/Commands/StacheRefresh.php @@ -6,6 +6,7 @@ use Statamic\Console\Commands\Concerns\HasStacheExcludes; use Statamic\Console\RunsInPlease; use Statamic\Facades\Stache; +use Statamic\Git\Git; use function Laravel\Prompts\spin; @@ -13,7 +14,10 @@ class StacheRefresh extends Command { use HasStacheExcludes, RunsInPlease; - protected $signature = 'statamic:stache:refresh {--exclude= : Comma-separated list of store keys to exclude}'; + protected $signature = 'statamic:stache:refresh + {--exclude= : Comma-separated list of store keys to exclude} + {--git : Perform a targeted, git-diff-based cache refresh instead of a full clear and warm} + {--include-dirty : Also process staged and unstaged dirty files (requires --git)}'; protected $description = 'Clear and rebuild the "Stache" cache'; @@ -21,9 +25,122 @@ public function handle() { $this->addExcludes($this->option('exclude')); + if ($this->option('git')) { + return $this->handleGitRefresh(); + } + spin(callback: fn () => Stache::clear(), message: 'Clearing the Stache...'); spin(callback: fn () => Stache::warm(), message: 'Warming the Stache...'); $this->components->info('You have trimmed and polished the Stache. It is handsome, warm, and ready.'); } + + protected function handleGitRefresh(): int + { + $git = app(Git::class); + + if (! $git->isRepo()) { + $this->components->error('Not a git repository. Cannot use --git flag.'); + + return self::FAILURE; + } + + // First run: no ref file → full refresh, bootstrap. + if ($git->getStacheRef() === null) { + $this->components->warn('No stache git ref found. Performing full refresh to bootstrap.'); + + spin(callback: fn () => Stache::clear(), message: 'Clearing the Stache...'); + spin(callback: fn () => Stache::warm(), message: 'Warming the Stache...'); + + $git->setStacheRef($git->currentSha()); + + $this->components->info('Stache bootstrapped from HEAD. Future --git runs will be targeted.'); + + return self::SUCCESS; + } + + $includeDirty = (bool) $this->option('include-dirty'); + $actions = $git->stacheDiff($includeDirty); + + if ($actions->isEmpty()) { + $this->components->info('No changes detected since last stache refresh.'); + $git->setStacheRef($git->currentSha()); + + return self::SUCCESS; + } + + // Any unrecognized file triggers a full refresh fallback. + if ($actions->contains(fn ($a) => $a['type'] === 'full-refresh')) { + if ($this->getOutput()->isVerbose()) { + $this->components->warn('Unrecognized file(s) detected. Falling back to full refresh.'); + $this->output->listing( + $actions->filter(fn ($a) => $a['type'] === 'full-refresh')->pluck('displayPath')->all() + ); + } + + spin(callback: fn () => Stache::clear(), message: 'Clearing the Stache...'); + spin(callback: fn () => Stache::warm(), message: 'Warming the Stache...'); + + $git->setStacheRef($git->currentSha()); + $this->components->info('You have trimmed and polished the Stache. It is handsome, warm, and ready.'); + + return self::SUCCESS; + } + + $this->executeActions($actions); + + $git->setStacheRef($git->currentSha()); + + if ($this->getOutput()->isVerbose()) { + $this->outputVerboseTable($actions); + } + + $this->components->info('The Stache has been selectively groomed. Targeted and precise.'); + + return self::SUCCESS; + } + + protected function executeActions($actions): void + { + $actions + ->filter(fn ($a) => in_array($a['type'], ['update-item', 'forget-item'])) + ->each(function ($action) { + $store = Stache::store($action['storeKey']); + + if (! $store) { + return; + } + + if ($action['type'] === 'update-item') { + spin( + callback: fn () => $store->updateItemFromPath($action['absolutePath']), + message: 'Updating '.$action['displayPath'].'...' + ); + } else { + spin( + callback: fn () => $store->forgetItemByPath($action['absolutePath']), + message: 'Removing '.$action['displayPath'].'...' + ); + } + }); + + $actions + ->filter(fn ($a) => $a['type'] === 'warm-store') + ->pluck('storeKey') + ->unique() + ->each(function ($storeKey) { + spin( + callback: fn () => Stache::store($storeKey)?->warm(), + message: 'Warming '.$storeKey.'...' + ); + }); + } + + protected function outputVerboseTable($actions): void + { + $this->table( + ['Path', 'Store', 'Action'], + $actions->map(fn ($a) => [$a['displayPath'], $a['storeKey'] ?? '-', $a['type']])->all() + ); + } } diff --git a/src/Console/Processes/Git.php b/src/Console/Processes/Git.php index 673581c46bb..0e9e257aa7d 100644 --- a/src/Console/Processes/Git.php +++ b/src/Console/Processes/Git.php @@ -49,6 +49,48 @@ public function push() return $this->runGitCommand('push', '--porcelain'); } + /** + * Get git diff --name-status between two refs. + * + * @param string $from Base commit SHA or ref + * @param string $to Target commit SHA or ref (e.g. 'HEAD') + * @return string + */ + public function diff(string $from, string $to = 'HEAD') + { + return $this->runGitCommand('diff', '--name-status', $from, $to); + } + + /** + * Get git diff --name-status for dirty (unstaged) files. + * + * @return string + */ + public function diffDirty() + { + return $this->runGitCommand('diff', '--name-status', 'HEAD'); + } + + /** + * Get git diff --name-status for staged (cached) files. + * + * @return string + */ + public function diffStaged() + { + return $this->runGitCommand('diff', '--name-status', '--cached', 'HEAD'); + } + + /** + * Get the current HEAD commit SHA. + * + * @return string + */ + public function currentSha() + { + return $this->runGitCommand('rev-parse', 'HEAD'); + } + /** * Run git command. * diff --git a/src/Facades/Git.php b/src/Facades/Git.php index da85d6b4750..5c797e27c2b 100644 --- a/src/Facades/Git.php +++ b/src/Facades/Git.php @@ -13,6 +13,12 @@ * @method static void dispatchCommit(string $message = null) * @method static string gitUserName() * @method static string gitUserEmail() + * @method static bool isRepo() + * @method static string currentSha() + * @method static string|null getStacheRef() + * @method static void setStacheRef(string $sha) + * @method static \Illuminate\Support\Collection parseDiffOutput(?string $output) + * @method static \Illuminate\Support\Collection|null stacheDiff(bool $includeDirty = false) * * @see \Statamic\Git\Git */ diff --git a/src/Git/Git.php b/src/Git/Git.php index 994c0124a98..3e3836ff0e4 100644 --- a/src/Git/Git.php +++ b/src/Git/Git.php @@ -11,6 +11,7 @@ use Statamic\Facades\Parse; use Statamic\Facades\Path; use Statamic\Facades\User; +use Statamic\Stache\GitPathMapper; use Statamic\Support\Str; use function Statamic\trans as __; @@ -19,16 +20,6 @@ class Git { private ?UserContract $authenticatedUser; - /** - * Instantiate git tracked content manager. - */ - public function __construct() - { - if (! config('statamic.git.enabled')) { - throw new \Exception(__('statamic::messages.git_disabled')); - } - } - /** * Listen to custom addon event. * @@ -36,13 +27,14 @@ public function __construct() */ public function listen($event) { + $this->ensureEnabled(); \Illuminate\Support\Facades\Event::listen($event, Subscriber::class.'@commit'); } /** * Get statuses of tracked content paths. * - * @return \Illuminate\Support\Collection|null + * @return Collection|null */ public function statuses() { @@ -79,6 +71,7 @@ public function as(?UserContract $user): static */ public function commit($message = null) { + $this->ensureEnabled(); $this->groupTrackedContentPathsByRepo()->each(function ($paths, $gitRoot) use ($message) { $this->runConfiguredCommands($gitRoot, $paths, $message ?? __('Content saved')); }); @@ -89,6 +82,7 @@ public function commit($message = null) */ public function dispatchCommit($message = null) { + $this->ensureEnabled(); if ($delay = config('statamic.git.dispatch_delay')) { $delayInMinutes = now()->addMinutes((int) $delay); $message = null; @@ -143,7 +137,7 @@ private function authenticatedUser(): ?UserContract /** * Group tracked content paths by repo. * - * @return \Illuminate\Support\Collection + * @return Collection */ protected function groupTrackedContentPathsByRepo() { @@ -304,4 +298,138 @@ protected function shellQuotePaths(Collection $paths): string ->map(fn ($path) => '"'.$path.'"') ->implode(' '); } + + /** + * Throw if the git integration is not enabled. + */ + protected function ensureEnabled(): void + { + if (! config('statamic.git.enabled')) { + throw new \Exception(__('statamic::messages.git_disabled')); + } + } + + /** + * Get the path to the stache git ref file. + */ + public function stacheRefFilePath(): string + { + return storage_path('statamic/.stache-git-ref'); + } + + /** + * Read the stored stache git ref SHA, or null if none exists. + */ + public function getStacheRef(): ?string + { + $path = $this->stacheRefFilePath(); + + return file_exists($path) ? trim(file_get_contents($path)) : null; + } + + /** + * Write a SHA to the stache git ref file. + */ + public function setStacheRef(string $sha): void + { + $path = $this->stacheRefFilePath(); + $dir = dirname($path); + + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($path, $sha); + } + + /** + * Get the current HEAD SHA. + */ + public function currentSha(): string + { + return GitProcess::create(base_path())->currentSha(); + } + + /** + * Determine whether the current directory is a git repository. + */ + public function isRepo(): bool + { + return GitProcess::create(base_path())->isRepo(); + } + + /** + * Parse a git diff --name-status output string into a collection of changes. + * + * Each item is an array with keys: 'status' (A|M|D) and 'path' (relative to git root). + * Rename lines (R100\told\tnew) are normalized into a delete + add pair. + * + * @return Collection + */ + public function parseDiffOutput(?string $output): Collection + { + return collect(explode("\n", trim((string) $output))) + ->filter() + ->map(function ($line) { + if (preg_match('/^R\d*\t(.+)\t(.+)$/', $line, $m)) { + return [ + ['status' => 'D', 'path' => $m[1]], + ['status' => 'A', 'path' => $m[2]], + ]; + } + + $parts = explode("\t", $line, 2); + + if (count($parts) < 2) { + return null; + } + + return [['status' => $parts[0], 'path' => $parts[1]]]; + }) + ->filter() + ->flatten(1) + ->values(); + } + + /** + * Get all git changes since the stored stache ref as a collection of stache actions. + * + * Pass $includeDirty = true to also include staged and unstaged working-tree changes. + * + * Returns null when there is no stored ref (first run), indicating a full refresh is needed. + * + * Each action is an array with keys: type, storeKey, absolutePath, displayPath. + * + * @return Collection|null + */ + public function stacheDiff(bool $includeDirty = false): ?Collection + { + $fromSha = $this->getStacheRef(); + + if ($fromSha === null) { + return null; + } + + $process = GitProcess::create(base_path()); + + $changes = $this->parseDiffOutput($process->diff($fromSha, 'HEAD')); + + if ($includeDirty) { + $changes = $changes + ->merge($this->parseDiffOutput($process->diffDirty())) + ->merge($this->parseDiffOutput($process->diffStaged())) + ->unique(fn ($c) => $c['status'].':'.$c['path']); + } + + if ($changes->isEmpty()) { + return collect(); + } + + $storeDirectories = collect(config('statamic.stache.stores', [])) + ->filter(fn ($config) => isset($config['directory'])) + ->map(fn ($config) => rtrim($config['directory'], '/')) + ->all(); + + return (new GitPathMapper)->map($changes, base_path(), $storeDirectories); + } } diff --git a/src/Stache/GitPathMapper.php b/src/Stache/GitPathMapper.php new file mode 100644 index 00000000000..6db83e00286 --- /dev/null +++ b/src/Stache/GitPathMapper.php @@ -0,0 +1,171 @@ + $changes + */ + public function map(Collection $changes, string $basePath, array $storeDirectories): Collection + { + $basePath = rtrim($basePath, '/'); + + return $changes->flatMap(function ($change) use ($basePath, $storeDirectories) { + return $this->mapChange($change['status'], $change['path'], $basePath, $storeDirectories); + }); + } + + private function mapChange(string $status, string $relativePath, string $basePath, array $storeDirectories): array + { + $absolutePath = $basePath.'/'.$relativePath; + + // --- Entries: content/collections/{collection}/... + $entriesDir = rtrim($storeDirectories['entries'] ?? $basePath.'/content/collections', '/'); + if (Str::startsWith($absolutePath, $entriesDir.'/')) { + $remainder = Str::after($absolutePath, $entriesDir.'/'); + $parts = explode('/', $remainder, 2); + $collection = $parts[0]; + + // e.g. content/collections/blog.yaml → collections store (the collection config itself) + // e.g. content/collections/blog/... → entries::blog store + if (isset($parts[1])) { + return [$this->makeAction($status, 'entries::'.$collection, $absolutePath, $relativePath)]; + } + } + + // --- Collections config: content/collections/{handle}.yaml (files in the dir root) + $collectionsDir = rtrim($storeDirectories['collections'] ?? $basePath.'/content/collections', '/'); + if (Str::startsWith($absolutePath, $collectionsDir.'/')) { + $remainder = Str::after($absolutePath, $collectionsDir.'/'); + // Only match files directly in the directory (not subdirectories = entry files) + if (! Str::contains($remainder, '/') && Str::endsWith($remainder, '.yaml')) { + return [$this->makeAction($status, 'collections', $absolutePath, $relativePath)]; + } + } + + // --- Terms: content/taxonomies/{taxonomy}/... + $termsDir = rtrim($storeDirectories['terms'] ?? $basePath.'/content/taxonomies', '/'); + if (Str::startsWith($absolutePath, $termsDir.'/')) { + $remainder = Str::after($absolutePath, $termsDir.'/'); + $parts = explode('/', $remainder, 2); + $taxonomy = $parts[0]; + + if (isset($parts[1])) { + // A term file inside a taxonomy subdirectory + return [$this->makeAction($status, 'terms::'.$taxonomy, $absolutePath, $relativePath)]; + } + } + + // --- Taxonomies config: content/taxonomies/{handle}.yaml (files in the dir root) + $taxonomiesDir = rtrim($storeDirectories['taxonomies'] ?? $basePath.'/content/taxonomies', '/'); + if (Str::startsWith($absolutePath, $taxonomiesDir.'/')) { + $remainder = Str::after($absolutePath, $taxonomiesDir.'/'); + if (! Str::contains($remainder, '/') && Str::endsWith($remainder, '.yaml')) { + return [$this->makeAction($status, 'taxonomies', $absolutePath, $relativePath)]; + } + } + + // --- Global variables: content/globals/{site}/{handle}.yaml (one slash in remainder) + // --- Globals base: content/globals/{handle}.yaml (no slash in remainder) + $globalsDir = rtrim($storeDirectories['globals'] ?? $basePath.'/content/globals', '/'); + if (Str::startsWith($absolutePath, $globalsDir.'/')) { + $remainder = Str::after($absolutePath, $globalsDir.'/'); + if (Str::contains($remainder, '/')) { + return [$this->makeAction($status, 'global-variables', $absolutePath, $relativePath)]; + } elseif (Str::endsWith($remainder, '.yaml')) { + return [$this->makeAction($status, 'globals', $absolutePath, $relativePath)]; + } + } + + // --- Navigation: content/navigation/{handle}.yaml + $navigationDir = rtrim($storeDirectories['navigation'] ?? $basePath.'/content/navigation', '/'); + if (Str::startsWith($absolutePath, $navigationDir.'/')) { + return [$this->makeAction($status, 'navigation', $absolutePath, $relativePath)]; + } + + // --- Collection trees: content/trees/collections/... + $collectionTreesDir = rtrim($storeDirectories['collection-trees'] ?? $basePath.'/content/trees/collections', '/'); + if (Str::startsWith($absolutePath, $collectionTreesDir.'/')) { + return [$this->makeAction($status, 'collection-trees', $absolutePath, $relativePath)]; + } + + // --- Nav trees: content/trees/navigation/... + $navTreesDir = rtrim($storeDirectories['nav-trees'] ?? $basePath.'/content/trees/navigation', '/'); + if (Str::startsWith($absolutePath, $navTreesDir.'/')) { + return [$this->makeAction($status, 'nav-trees', $absolutePath, $relativePath)]; + } + + // --- Asset containers: content/assets/{handle}.yaml + $assetContainersDir = rtrim($storeDirectories['asset-containers'] ?? $basePath.'/content/assets', '/'); + if (Str::startsWith($absolutePath, $assetContainersDir.'/')) { + return [$this->makeAction($status, 'asset-containers', $absolutePath, $relativePath)]; + } + + // --- Users: users/{email}.yaml + $usersDir = rtrim($storeDirectories['users'] ?? $basePath.'/users', '/'); + if (Str::startsWith($absolutePath, $usersDir.'/')) { + return [$this->makeAction($status, 'users', $absolutePath, $relativePath)]; + } + + // --- Blueprints: resources/blueprints/... + $blueprintsDir = $basePath.'/resources/blueprints'; + if (Str::startsWith($absolutePath, $blueprintsDir.'/')) { + return [$this->mapBlueprintChange($status, $absolutePath, $relativePath, $blueprintsDir)]; + } + + // --- Anything else is unrecognized → trigger full refresh + return [['type' => 'full-refresh', 'storeKey' => null, 'absolutePath' => null, 'displayPath' => $relativePath]]; + } + + private function mapBlueprintChange(string $status, string $absolutePath, string $relativePath, string $blueprintsDir): array + { + $remainder = Str::after($absolutePath, $blueprintsDir.'/'); + $parts = explode('/', $remainder); + + // resources/blueprints/collections/{collection}/... → warm entries::{collection} store + if (isset($parts[0]) && $parts[0] === 'collections' && isset($parts[1])) { + return ['type' => 'warm-store', 'storeKey' => 'entries::'.$parts[1], 'absolutePath' => null, 'displayPath' => $relativePath]; + } + + // resources/blueprints/taxonomies/{taxonomy}/... → warm terms::{taxonomy} store + if (isset($parts[0]) && $parts[0] === 'taxonomies' && isset($parts[1])) { + return ['type' => 'warm-store', 'storeKey' => 'terms::'.$parts[1], 'absolutePath' => null, 'displayPath' => $relativePath]; + } + + // resources/blueprints/assets/... → warm asset-containers store + if (isset($parts[0]) && $parts[0] === 'assets') { + return ['type' => 'warm-store', 'storeKey' => 'asset-containers', 'absolutePath' => null, 'displayPath' => $relativePath]; + } + + // Any other blueprint change (e.g. user.yaml, default.yaml, fieldsets) → full refresh + return ['type' => 'full-refresh', 'storeKey' => null, 'absolutePath' => null, 'displayPath' => $relativePath]; + } + + private function makeAction(string $gitStatus, string $storeKey, string $absolutePath, string $displayPath): array + { + $type = $gitStatus === 'D' ? 'forget-item' : 'update-item'; + + return [ + 'type' => $type, + 'storeKey' => $storeKey, + 'absolutePath' => $absolutePath, + 'displayPath' => $displayPath, + ]; + } +} diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index b9b067a76a6..87804884544 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -15,16 +15,27 @@ abstract class Store { protected $directory; + protected $valueIndex = Indexes\Value::class; + protected $customIndexes = []; + protected $defaultIndexes = ['id', 'path']; + protected $storeIndexes = []; + protected $usedIndexes; + protected $fileChangesHandled = false; + protected $paths; + protected $fileItems; + protected $shouldCacheFileItems = false; + protected $modified; + protected $keys; /** @@ -279,6 +290,39 @@ public function handleFileChanges() $this->modified = $modified; } + /** + * Update the stache cache for a single file that was added or modified on disk. + * This is a read-only stache operation — it does not write to disk. + */ + public function updateItemFromPath(string $path): void + { + $item = $this->makeItemFromFile($path, File::get($path)); + $key = $this->getItemKey($item); + + $this->forgetItem($key); + $this->setPath($key, $item->path()); + $this->cacheItem($item); + $this->handleModifiedItem($item); + + $this->resolveIndexes()->filter->isCached()->each(function ($index) use ($item) { + $index->updateItem($item); + }); + } + + /** + * Remove a single item from the stache cache by its file path. + * Used when a file has been deleted from disk. + */ + public function forgetItemByPath(string $path): void + { + 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); + }); + } + protected function handleModifiedItem($item) { // diff --git a/tests/Console/Commands/StacheRefreshTest.php b/tests/Console/Commands/StacheRefreshTest.php index 3d70439a76f..64be7b287de 100644 --- a/tests/Console/Commands/StacheRefreshTest.php +++ b/tests/Console/Commands/StacheRefreshTest.php @@ -2,13 +2,51 @@ namespace Tests\Console\Commands; +use Mockery; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Commands\StacheRefresh; use Statamic\Facades\Stache; +use Statamic\Git\Git; +use Statamic\Stache\Stores\ChildStore; use Tests\TestCase; class StacheRefreshTest extends TestCase { + private string $refFile; + + protected function setUp(): void + { + parent::setUp(); + + $this->refFile = storage_path('statamic/.stache-git-ref'); + + if (file_exists($this->refFile)) { + unlink($this->refFile); + } + } + + public function tearDown(): void + { + if (file_exists($this->refFile)) { + unlink($this->refFile); + } + + parent::tearDown(); + } + + private function mockGit(array $expectations): Git + { + $git = Mockery::mock(Git::class)->makePartial(); + + foreach ($expectations as $method => $return) { + $git->shouldReceive($method)->andReturn($return); + } + + app()->instance(Git::class, $git); + + return $git; + } + #[Test] public function it_doesnt_add_any_exclusion_if_no_parameter() { @@ -39,4 +77,116 @@ public function it_adds_multiple_excludes() $this->artisan(StacheRefresh::class, ['--exclude' => 'foo,bar']); } + + #[Test] + public function it_fails_gracefully_when_not_in_a_git_repo() + { + $this->mockGit(['isRepo' => false]); + + $this->artisan('statamic:stache:refresh', ['--git' => true]) + ->expectsOutputToContain('Not a git repository') + ->assertExitCode(1); + } + + #[Test] + public function it_performs_full_refresh_and_bootstraps_ref_file_on_first_git_run() + { + $git = $this->mockGit([ + 'isRepo' => true, + 'getStacheRef' => null, + 'currentSha' => 'abc1234', + ]); + $git->shouldReceive('setStacheRef')->once()->with('abc1234'); + + Stache::shouldReceive('clear')->once(); + Stache::shouldReceive('warm')->once(); + + $this->artisan('statamic:stache:refresh', ['--git' => true]) + ->expectsOutputToContain('bootstrapped') + ->assertExitCode(0); + } + + #[Test] + public function it_reports_no_changes_when_diff_is_empty() + { + $git = $this->mockGit([ + 'isRepo' => true, + 'getStacheRef' => 'abc1234', + 'stacheDiff' => collect(), + 'currentSha' => 'abc1234', + ]); + $git->shouldReceive('setStacheRef')->once()->with('abc1234'); + + Stache::shouldReceive('clear')->never(); + Stache::shouldReceive('warm')->never(); + + $this->artisan('statamic:stache:refresh', ['--git' => true]) + ->expectsOutputToContain('No changes detected') + ->assertExitCode(0); + } + + #[Test] + public function it_falls_back_to_full_refresh_for_unrecognized_files() + { + $actions = collect([ + ['type' => 'full-refresh', 'storeKey' => null, 'absolutePath' => null, 'displayPath' => 'resources/views/some-template.antlers.html'], + ]); + + $git = $this->mockGit([ + 'isRepo' => true, + 'getStacheRef' => 'abc1234', + 'stacheDiff' => $actions, + 'currentSha' => 'def5678', + ]); + $git->shouldReceive('setStacheRef')->once()->with('def5678'); + + Stache::shouldReceive('clear')->once(); + Stache::shouldReceive('warm')->once(); + + $this->artisan('statamic:stache:refresh', ['--git' => true]) + ->assertExitCode(0); + } + + #[Test] + public function it_runs_targeted_refresh_and_saves_updated_ref() + { + $actions = collect([ + ['type' => 'warm-store', 'storeKey' => 'entries::blog', 'absolutePath' => null, 'displayPath' => 'resources/blueprints/collections/blog/article.yaml'], + ]); + + $git = $this->mockGit([ + 'isRepo' => true, + 'getStacheRef' => 'abc1234', + 'stacheDiff' => $actions, + 'currentSha' => 'def5678', + ]); + $git->shouldReceive('setStacheRef')->once()->with('def5678'); + + $storeMock = Mockery::mock(ChildStore::class); + $storeMock->shouldReceive('warm')->once(); + + Stache::shouldReceive('store')->with('entries::blog')->andReturn($storeMock); + Stache::shouldReceive('clear')->never(); + Stache::shouldReceive('warm')->never(); + + $this->artisan('statamic:stache:refresh', ['--git' => true]) + ->expectsOutputToContain('selectively groomed') + ->assertExitCode(0); + } + + #[Test] + public function it_passes_include_dirty_flag_to_stache_diff() + { + $git = Mockery::mock(Git::class)->makePartial(); + $git->shouldReceive('isRepo')->andReturn(true); + $git->shouldReceive('getStacheRef')->andReturn('abc1234'); + $git->shouldReceive('stacheDiff')->once()->with(true)->andReturn(collect()); + $git->shouldReceive('currentSha')->andReturn('abc1234'); + $git->shouldReceive('setStacheRef')->once(); + app()->instance(Git::class, $git); + + $this->artisan('statamic:stache:refresh', ['--git' => true, '--include-dirty' => true]) + ->expectsOutputToContain('No changes detected') + ->assertExitCode(0); + } } diff --git a/tests/Git/GitTest.php b/tests/Git/GitTest.php index 5facba3e872..a9f4e55af45 100644 --- a/tests/Git/GitTest.php +++ b/tests/Git/GitTest.php @@ -3,6 +3,7 @@ namespace Tests\Git; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Queue; use PHPUnit\Framework\Attributes\Test; @@ -12,6 +13,7 @@ use Statamic\Facades\Git; use Statamic\Facades\Path; use Statamic\Facades\User; +use Statamic\Git\CommitJob; use Tests\TestCase; class GitTest extends TestCase @@ -57,13 +59,13 @@ public function tearDown(): void } #[Test] - public function it_wont_run_if_git_integration_is_not_enabled() + public function it_wont_run_commit_if_git_integration_is_not_enabled() { Config::set('statamic.git.enabled', false); $this->expectExceptionMessage('Statamic Git integration is disabled.'); - Git::anything(); + Git::commit(); } #[Test] @@ -393,7 +395,7 @@ public function it_dispatches_commit_job() Git::dispatchCommit(); - Queue::assertPushed(\Statamic\Git\CommitJob::class, 1); + Queue::assertPushed(CommitJob::class, 1); } #[Test] @@ -538,6 +540,90 @@ public function it_can_push_after_a_commit() Git::commit(); } + #[Test] + public function it_can_parse_diff_output_into_changes() + { + $output = implode("\n", [ + 'M content/collections/blog/post.md', + 'A content/collections/news/article.md', + 'D content/taxonomies/tags/laravel.yaml', + ]); + + $changes = Git::parseDiffOutput($output); + + $this->assertCount(3, $changes); + + $this->assertEquals(['status' => 'M', 'path' => 'content/collections/blog/post.md'], $changes[0]); + $this->assertEquals(['status' => 'A', 'path' => 'content/collections/news/article.md'], $changes[1]); + $this->assertEquals(['status' => 'D', 'path' => 'content/taxonomies/tags/laravel.yaml'], $changes[2]); + } + + #[Test] + public function it_normalizes_rename_lines_into_delete_and_add() + { + $output = "R100\tcontent/collections/blog/old-name.md\tcontent/collections/blog/new-name.md"; + + $changes = Git::parseDiffOutput($output); + + $this->assertCount(2, $changes); + $this->assertEquals(['status' => 'D', 'path' => 'content/collections/blog/old-name.md'], $changes[0]); + $this->assertEquals(['status' => 'A', 'path' => 'content/collections/blog/new-name.md'], $changes[1]); + } + + #[Test] + public function it_returns_empty_collection_for_empty_diff_output() + { + $this->assertCount(0, Git::parseDiffOutput('')); + $this->assertCount(0, Git::parseDiffOutput(' ')); + } + + #[Test] + public function it_reads_and_writes_the_stache_ref_file() + { + $refFile = storage_path('statamic/.stache-git-ref'); + + if (file_exists($refFile)) { + unlink($refFile); + } + + $this->assertNull(Git::getStacheRef()); + + Git::setStacheRef('abc1234'); + + $this->assertFileExists($refFile); + $this->assertEquals('abc1234', Git::getStacheRef()); + + unlink($refFile); + } + + #[Test] + public function it_returns_null_stache_diff_when_no_ref_file_exists() + { + $refFile = storage_path('statamic/.stache-git-ref'); + + if (file_exists($refFile)) { + unlink($refFile); + } + + $this->assertNull(Git::stacheDiff()); + } + + #[Test] + public function it_returns_empty_collection_when_no_changes_since_ref() + { + $sha = Git::currentSha(); + + Git::setStacheRef($sha); + + $actions = Git::stacheDiff(); + + // No changes since HEAD — should be an empty collection. + $this->assertInstanceOf(Collection::class, $actions); + $this->assertCount(0, $actions); + + unlink(storage_path('statamic/.stache-git-ref')); + } + private function showLastCommit($path) { return Process::create($path)->run('git show'); diff --git a/tests/Stache/GitPathMapperTest.php b/tests/Stache/GitPathMapperTest.php new file mode 100644 index 00000000000..42e485ccc52 --- /dev/null +++ b/tests/Stache/GitPathMapperTest.php @@ -0,0 +1,314 @@ +mapper = new GitPathMapper; + $this->basePath = '/var/www/site'; + $this->storeDirectories = [ + 'taxonomies' => '/var/www/site/content/taxonomies', + 'terms' => '/var/www/site/content/taxonomies', + 'collections' => '/var/www/site/content/collections', + 'entries' => '/var/www/site/content/collections', + 'navigation' => '/var/www/site/content/navigation', + 'collection-trees' => '/var/www/site/content/trees/collections', + 'nav-trees' => '/var/www/site/content/trees/navigation', + 'globals' => '/var/www/site/content/globals', + 'global-variables' => '/var/www/site/content/globals', + 'asset-containers' => '/var/www/site/content/assets', + 'users' => '/var/www/site/users', + ]; + } + + private function map(array $changes): Collection + { + return $this->mapper->map(collect($changes), $this->basePath, $this->storeDirectories); + } + + #[Test] + public function it_maps_an_added_entry_to_update_item() + { + $actions = $this->map([ + ['status' => 'A', 'path' => 'content/collections/blog/my-post.md'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('entries::blog', $actions[0]['storeKey']); + $this->assertEquals('/var/www/site/content/collections/blog/my-post.md', $actions[0]['absolutePath']); + $this->assertEquals('content/collections/blog/my-post.md', $actions[0]['displayPath']); + } + + #[Test] + public function it_maps_a_modified_entry_to_update_item() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/collections/blog/my-post.md'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('entries::blog', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_a_deleted_entry_to_forget_item() + { + $actions = $this->map([ + ['status' => 'D', 'path' => 'content/collections/blog/my-post.md'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('forget-item', $actions[0]['type']); + $this->assertEquals('entries::blog', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_a_collection_config_yaml_to_collections_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/collections/blog.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('collections', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_a_taxonomy_config_yaml_to_taxonomies_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/taxonomies/tags.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('taxonomies', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_a_term_to_terms_store() + { + $actions = $this->map([ + ['status' => 'A', 'path' => 'content/taxonomies/tags/laravel.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('terms::tags', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_a_deleted_term_to_forget_item() + { + $actions = $this->map([ + ['status' => 'D', 'path' => 'content/taxonomies/tags/laravel.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('forget-item', $actions[0]['type']); + $this->assertEquals('terms::tags', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_global_config_to_globals_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/globals/settings.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('globals', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_global_variables_to_global_variables_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/globals/en/settings.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('global-variables', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_navigation_to_navigation_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/navigation/main.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('navigation', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_collection_tree_to_collection_trees_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/trees/collections/pages.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('collection-trees', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_nav_tree_to_nav_trees_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/trees/navigation/main.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('nav-trees', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_asset_container_yaml_to_asset_containers_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/assets/main.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('asset-containers', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_a_user_to_users_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'users/john@example.com.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('users', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_a_collection_blueprint_change_to_warm_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'resources/blueprints/collections/blog/article.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('warm-store', $actions[0]['type']); + $this->assertEquals('entries::blog', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_a_taxonomy_blueprint_change_to_warm_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'resources/blueprints/taxonomies/tags/default.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('warm-store', $actions[0]['type']); + $this->assertEquals('terms::tags', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_an_assets_blueprint_change_to_warm_store() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'resources/blueprints/assets/main.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('warm-store', $actions[0]['type']); + $this->assertEquals('asset-containers', $actions[0]['storeKey']); + } + + #[Test] + public function it_maps_other_blueprint_changes_to_full_refresh() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'resources/blueprints/user.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('full-refresh', $actions[0]['type']); + $this->assertNull($actions[0]['storeKey']); + } + + #[Test] + public function it_maps_unrecognized_files_to_full_refresh() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'resources/views/some-template.antlers.html'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('full-refresh', $actions[0]['type']); + } + + #[Test] + public function it_maps_multiple_changes_in_one_call() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'content/collections/blog/post-1.md'], + ['status' => 'D', 'path' => 'content/collections/blog/post-2.md'], + ['status' => 'A', 'path' => 'content/collections/news/article-1.md'], + ]); + + $this->assertCount(3, $actions); + + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('entries::blog', $actions[0]['storeKey']); + + $this->assertEquals('forget-item', $actions[1]['type']); + $this->assertEquals('entries::blog', $actions[1]['storeKey']); + + $this->assertEquals('update-item', $actions[2]['type']); + $this->assertEquals('entries::news', $actions[2]['storeKey']); + } + + #[Test] + public function it_returns_empty_collection_for_empty_changes() + { + $actions = $this->map([]); + + $this->assertCount(0, $actions); + } + + #[Test] + public function base_path_trailing_slash_is_normalized() + { + $actions = $this->mapper->map( + collect([['status' => 'M', 'path' => 'content/collections/blog/post.md']]), + '/var/www/site/', // trailing slash + $this->storeDirectories + ); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + } +} From 97cecb405e1c63e7bfc61fa01d4980d23357302e Mon Sep 17 00:00:00 2001 From: Daniel Weaver Date: Wed, 2 Sep 2026 14:44:51 -0400 Subject: [PATCH 2/6] fix(stache): ignore non-stache paths in git refresh Unmapped files no longer force a full rebuild, so a mixed deploy can still do a targeted --git refresh. Fieldsets, config/statamic, and other blueprints still invalidate the whole stache. --- src/Stache/GitPathMapper.php | 23 +++++++++++--- tests/Stache/GitPathMapperTest.php | 48 +++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/Stache/GitPathMapper.php b/src/Stache/GitPathMapper.php index 6db83e00286..56291d4f982 100644 --- a/src/Stache/GitPathMapper.php +++ b/src/Stache/GitPathMapper.php @@ -129,8 +129,18 @@ private function mapChange(string $status, string $relativePath, string $basePat return [$this->mapBlueprintChange($status, $absolutePath, $relativePath, $blueprintsDir)]; } - // --- Anything else is unrecognized → trigger full refresh - return [['type' => 'full-refresh', 'storeKey' => null, 'absolutePath' => null, 'displayPath' => $relativePath]]; + $fieldsetsDir = $basePath.'/resources/fieldsets'; + if (Str::startsWith($absolutePath, $fieldsetsDir.'/')) { + return [$this->fullRefreshAction($relativePath)]; + } + + $configDir = $basePath.'/config/statamic'; + if (Str::startsWith($absolutePath, $configDir.'/')) { + return [$this->fullRefreshAction($relativePath)]; + } + + // Unmapped paths are not stache stores. Ignore them so a mixed deploy stays targeted. + return []; } private function mapBlueprintChange(string $status, string $absolutePath, string $relativePath, string $blueprintsDir): array @@ -153,8 +163,13 @@ private function mapBlueprintChange(string $status, string $absolutePath, string return ['type' => 'warm-store', 'storeKey' => 'asset-containers', 'absolutePath' => null, 'displayPath' => $relativePath]; } - // Any other blueprint change (e.g. user.yaml, default.yaml, fieldsets) → full refresh - return ['type' => 'full-refresh', 'storeKey' => null, 'absolutePath' => null, 'displayPath' => $relativePath]; + // Any other blueprint change (e.g. user.yaml, default.yaml) → full refresh + return $this->fullRefreshAction($relativePath); + } + + private function fullRefreshAction(string $displayPath): array + { + return ['type' => 'full-refresh', 'storeKey' => null, 'absolutePath' => null, 'displayPath' => $displayPath]; } private function makeAction(string $gitStatus, string $storeKey, string $absolutePath, string $displayPath): array diff --git a/tests/Stache/GitPathMapperTest.php b/tests/Stache/GitPathMapperTest.php index 42e485ccc52..32640e0ebfa 100644 --- a/tests/Stache/GitPathMapperTest.php +++ b/tests/Stache/GitPathMapperTest.php @@ -260,16 +260,62 @@ public function it_maps_other_blueprint_changes_to_full_refresh() } #[Test] - public function it_maps_unrecognized_files_to_full_refresh() + public function it_maps_unrecognized_files_to_no_actions() { $actions = $this->map([ ['status' => 'M', 'path' => 'resources/views/some-template.antlers.html'], ]); + $this->assertCount(0, $actions); + } + + #[Test] + public function it_ignores_php_src_changes() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'src/Foo.php'], + ]); + + $this->assertCount(0, $actions); + } + + #[Test] + public function it_maps_fieldset_changes_to_full_refresh() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'resources/fieldsets/common.yaml'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('full-refresh', $actions[0]['type']); + $this->assertNull($actions[0]['storeKey']); + } + + #[Test] + public function it_maps_statamic_config_changes_to_full_refresh() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'config/statamic/stache.php'], + ]); + $this->assertCount(1, $actions); $this->assertEquals('full-refresh', $actions[0]['type']); } + #[Test] + public function it_maps_mixed_src_and_entry_changes_to_only_the_entry_action() + { + $actions = $this->map([ + ['status' => 'M', 'path' => 'src/Foo.php'], + ['status' => 'M', 'path' => 'content/collections/blog/my-post.md'], + ]); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('entries::blog', $actions[0]['storeKey']); + $this->assertEquals('content/collections/blog/my-post.md', $actions[0]['displayPath']); + } + #[Test] public function it_maps_multiple_changes_in_one_call() { From 331133bcd0c8e26a30a315e3cee59918030733b7 Mon Sep 17 00:00:00 2001 From: Daniel Weaver Date: Wed, 2 Sep 2026 14:45:07 -0400 Subject: [PATCH 3/6] fix(stache): fail git refresh on bad diffs and include untracked copies A failed git diff no longer looks like an empty change set, so a bad stored SHA cannot skip content updates. --include-dirty now lists untracked files, copy lines add the new path, and forgetItemByPath matches tidy path variants. --- src/Console/Commands/StacheRefresh.php | 14 +++- src/Console/Processes/Git.php | 53 ++++++++++++- src/Facades/Git.php | 1 + src/Git/Git.php | 28 ++++++- src/Stache/Stores/Store.php | 40 +++++++++- tests/Console/Commands/StacheRefreshTest.php | 80 +++++++++++++++++++- tests/Git/GitProcessTest.php | 12 +++ tests/Git/GitTest.php | 66 ++++++++++++++++ tests/Stache/Stores/EntriesStoreTest.php | 39 ++++++++++ 9 files changed, 323 insertions(+), 10 deletions(-) diff --git a/src/Console/Commands/StacheRefresh.php b/src/Console/Commands/StacheRefresh.php index 180c686a50f..0abe5cfd439 100644 --- a/src/Console/Commands/StacheRefresh.php +++ b/src/Console/Commands/StacheRefresh.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Statamic\Console\Commands\Concerns\HasStacheExcludes; +use Statamic\Console\Processes\Exceptions\ProcessException; use Statamic\Console\RunsInPlease; use Statamic\Facades\Stache; use Statamic\Git\Git; @@ -60,7 +61,15 @@ protected function handleGitRefresh(): int } $includeDirty = (bool) $this->option('include-dirty'); - $actions = $git->stacheDiff($includeDirty); + + try { + $actions = $git->stacheDiff($includeDirty); + } catch (ProcessException $e) { + $this->components->error('Unable to diff git changes. Stache ref was not updated.'); + $this->components->error($e->getMessage()); + + return self::FAILURE; + } if ($actions->isEmpty()) { $this->components->info('No changes detected since last stache refresh.'); @@ -69,10 +78,9 @@ protected function handleGitRefresh(): int return self::SUCCESS; } - // Any unrecognized file triggers a full refresh fallback. if ($actions->contains(fn ($a) => $a['type'] === 'full-refresh')) { if ($this->getOutput()->isVerbose()) { - $this->components->warn('Unrecognized file(s) detected. Falling back to full refresh.'); + $this->components->warn('A change requires a full stache refresh.'); $this->output->listing( $actions->filter(fn ($a) => $a['type'] === 'full-refresh')->pluck('displayPath')->all() ); diff --git a/src/Console/Processes/Git.php b/src/Console/Processes/Git.php index 0e9e257aa7d..1e65fa76e10 100644 --- a/src/Console/Processes/Git.php +++ b/src/Console/Processes/Git.php @@ -2,6 +2,7 @@ namespace Statamic\Console\Processes; +use Statamic\Console\Processes\Exceptions\ProcessException; use Statamic\Support\Str; class Git extends Process @@ -58,7 +59,7 @@ public function push() */ public function diff(string $from, string $to = 'HEAD') { - return $this->runGitCommand('diff', '--name-status', $from, $to); + return $this->runRequiredGitCommand('diff', '--name-status', $from, $to); } /** @@ -68,7 +69,7 @@ public function diff(string $from, string $to = 'HEAD') */ public function diffDirty() { - return $this->runGitCommand('diff', '--name-status', 'HEAD'); + return $this->runRequiredGitCommand('diff', '--name-status', 'HEAD'); } /** @@ -78,7 +79,17 @@ public function diffDirty() */ public function diffStaged() { - return $this->runGitCommand('diff', '--name-status', '--cached', 'HEAD'); + return $this->runRequiredGitCommand('diff', '--name-status', '--cached', 'HEAD'); + } + + /** + * List untracked files, excluding ignored paths. + * + * @return string + */ + public function untrackedFiles() + { + return $this->runRequiredGitCommand('ls-files', '--others', '--exclude-standard'); } /** @@ -102,6 +113,42 @@ private function runGitCommand(...$parts) return $this->run($this->prepareProcessArguments($parts)); } + /** + * Run a git command that must succeed. Throw when git writes to stderr or exits non-zero. + * + * @param mixed $parts + * @return mixed + * + * @throws ProcessException + */ + private function runRequiredGitCommand(...$parts) + { + $this->throwOnFailure = true; + + try { + $output = $this->runGitCommand(...$parts); + } catch (ProcessException $e) { + throw new ProcessException($this->failedGitCommandMessage($e), 0, $e); + } finally { + $this->throwOnFailure = false; + } + + if ($this->hasErrorOutput()) { + throw new ProcessException($this->failedGitCommandMessage()); + } + + return $output; + } + + private function failedGitCommandMessage(?ProcessException $e = null): string + { + $detail = $this->hasErrorOutput() + ? collect($this->errorOutput)->implode("\n") + : ($e?->getMessage() ?: 'unknown error'); + + return 'Git command failed: '.$detail; + } + /** * Prepare process arguments. * diff --git a/src/Facades/Git.php b/src/Facades/Git.php index 5c797e27c2b..555a5952ebe 100644 --- a/src/Facades/Git.php +++ b/src/Facades/Git.php @@ -18,6 +18,7 @@ * @method static string|null getStacheRef() * @method static void setStacheRef(string $sha) * @method static \Illuminate\Support\Collection parseDiffOutput(?string $output) + * @method static \Illuminate\Support\Collection parseUntrackedOutput(?string $output) * @method static \Illuminate\Support\Collection|null stacheDiff(bool $includeDirty = false) * * @see \Statamic\Git\Git diff --git a/src/Git/Git.php b/src/Git/Git.php index 3e3836ff0e4..42acf4a0e04 100644 --- a/src/Git/Git.php +++ b/src/Git/Git.php @@ -363,6 +363,7 @@ public function isRepo(): bool * * Each item is an array with keys: 'status' (A|M|D) and 'path' (relative to git root). * Rename lines (R100\told\tnew) are normalized into a delete + add pair. + * Copy lines (C100\told\tnew) are normalized into an add of the new path. * * @return Collection */ @@ -378,6 +379,12 @@ public function parseDiffOutput(?string $output): Collection ]; } + if (preg_match('/^C\d*\t(.+)\t(.+)$/', $line, $m)) { + return [ + ['status' => 'A', 'path' => $m[2]], + ]; + } + $parts = explode("\t", $line, 2); if (count($parts) < 2) { @@ -410,7 +417,7 @@ public function stacheDiff(bool $includeDirty = false): ?Collection return null; } - $process = GitProcess::create(base_path()); + $process = $this->stacheGitProcess(); $changes = $this->parseDiffOutput($process->diff($fromSha, 'HEAD')); @@ -418,6 +425,7 @@ public function stacheDiff(bool $includeDirty = false): ?Collection $changes = $changes ->merge($this->parseDiffOutput($process->diffDirty())) ->merge($this->parseDiffOutput($process->diffStaged())) + ->merge($this->parseUntrackedOutput($process->untrackedFiles())) ->unique(fn ($c) => $c['status'].':'.$c['path']); } @@ -432,4 +440,22 @@ public function stacheDiff(bool $includeDirty = false): ?Collection return (new GitPathMapper)->map($changes, base_path(), $storeDirectories); } + + /** + * Parse git ls-files --others output into added changes. + * + * @return Collection + */ + public function parseUntrackedOutput(?string $output): Collection + { + return collect(explode("\n", trim((string) $output))) + ->filter() + ->map(fn ($path) => ['status' => 'A', 'path' => $path]) + ->values(); + } + + protected function stacheGitProcess(): GitProcess + { + return GitProcess::create(base_path()); + } } diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index 87804884544..b6d72969c73 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -3,6 +3,7 @@ namespace Statamic\Stache\Stores; use Facades\Statamic\Stache\Traverser; +use Illuminate\Support\Enumerable; use Statamic\Facades\File; use Statamic\Facades\Path; use Statamic\Facades\Stache; @@ -315,7 +316,9 @@ public function updateItemFromPath(string $path): void */ public function forgetItemByPath(string $path): void { - collect($this->getKeyFromPath($path))->each(function ($key) use ($path) { + $key = $this->getKeyFromPathVariants($path); + + collect($key)->each(function ($key) use ($path) { $this->forgetItem($key); $this->forgetPath($key); $this->resolveIndexes()->filter->isCached()->each->forgetItem($key); @@ -323,6 +326,41 @@ public function forgetItemByPath(string $path): void }); } + protected function getKeyFromPathVariants(string $path) + { + foreach ($this->pathLookupVariants($path) as $candidate) { + $key = $this->getKeyFromPath($candidate); + + if ($key instanceof Enumerable) { + if ($key->isNotEmpty()) { + return $key; + } + + continue; + } + + if ($key !== null && $key !== false && $key !== '') { + return $key; + } + } + + return null; + } + + protected function pathLookupVariants(string $path): array + { + $tidy = Path::tidy($path); + $resolved = Path::resolve($path); + + return array_values(array_unique(array_filter([ + $path, + $tidy, + rtrim($tidy, '/'), + $resolved, + rtrim($resolved, '/'), + ]))); + } + protected function handleModifiedItem($item) { // diff --git a/tests/Console/Commands/StacheRefreshTest.php b/tests/Console/Commands/StacheRefreshTest.php index 64be7b287de..998b1f7c122 100644 --- a/tests/Console/Commands/StacheRefreshTest.php +++ b/tests/Console/Commands/StacheRefreshTest.php @@ -5,6 +5,7 @@ use Mockery; use PHPUnit\Framework\Attributes\Test; use Statamic\Console\Commands\StacheRefresh; +use Statamic\Console\Processes\Exceptions\ProcessException; use Statamic\Facades\Stache; use Statamic\Git\Git; use Statamic\Stache\Stores\ChildStore; @@ -126,10 +127,10 @@ public function it_reports_no_changes_when_diff_is_empty() } #[Test] - public function it_falls_back_to_full_refresh_for_unrecognized_files() + public function it_falls_back_to_full_refresh_when_an_action_requires_it() { $actions = collect([ - ['type' => 'full-refresh', 'storeKey' => null, 'absolutePath' => null, 'displayPath' => 'resources/views/some-template.antlers.html'], + ['type' => 'full-refresh', 'storeKey' => null, 'absolutePath' => null, 'displayPath' => 'resources/fieldsets/common.yaml'], ]); $git = $this->mockGit([ @@ -189,4 +190,79 @@ public function it_passes_include_dirty_flag_to_stache_diff() ->expectsOutputToContain('No changes detected') ->assertExitCode(0); } + + #[Test] + public function it_fails_when_git_diff_throws_and_does_not_update_the_ref() + { + $git = $this->mockGit([ + 'isRepo' => true, + 'getStacheRef' => 'abc1234', + ]); + $git->shouldReceive('stacheDiff')->once()->andThrow(new ProcessException('Git command failed: fatal: bad object')); + $git->shouldReceive('setStacheRef')->never(); + $git->shouldReceive('currentSha')->never(); + + Stache::shouldReceive('clear')->never(); + Stache::shouldReceive('warm')->never(); + + $this->artisan('statamic:stache:refresh', ['--git' => true]) + ->expectsOutputToContain('Unable to diff git changes') + ->assertExitCode(1); + } + + #[Test] + public function it_updates_an_item_from_path_for_update_item_actions() + { + $path = '/var/www/site/content/collections/blog/post.md'; + $actions = collect([ + ['type' => 'update-item', 'storeKey' => 'entries::blog', 'absolutePath' => $path, 'displayPath' => 'content/collections/blog/post.md'], + ]); + + $git = $this->mockGit([ + 'isRepo' => true, + 'getStacheRef' => 'abc1234', + 'stacheDiff' => $actions, + 'currentSha' => 'def5678', + ]); + $git->shouldReceive('setStacheRef')->once()->with('def5678'); + + $storeMock = Mockery::mock(ChildStore::class); + $storeMock->shouldReceive('updateItemFromPath')->once()->with($path); + + Stache::shouldReceive('store')->with('entries::blog')->andReturn($storeMock); + Stache::shouldReceive('clear')->never(); + Stache::shouldReceive('warm')->never(); + + $this->artisan('statamic:stache:refresh', ['--git' => true]) + ->expectsOutputToContain('selectively groomed') + ->assertExitCode(0); + } + + #[Test] + public function it_forgets_an_item_by_path_for_forget_item_actions() + { + $path = '/var/www/site/content/collections/blog/post.md'; + $actions = collect([ + ['type' => 'forget-item', 'storeKey' => 'entries::blog', 'absolutePath' => $path, 'displayPath' => 'content/collections/blog/post.md'], + ]); + + $git = $this->mockGit([ + 'isRepo' => true, + 'getStacheRef' => 'abc1234', + 'stacheDiff' => $actions, + 'currentSha' => 'def5678', + ]); + $git->shouldReceive('setStacheRef')->once()->with('def5678'); + + $storeMock = Mockery::mock(ChildStore::class); + $storeMock->shouldReceive('forgetItemByPath')->once()->with($path); + + Stache::shouldReceive('store')->with('entries::blog')->andReturn($storeMock); + Stache::shouldReceive('clear')->never(); + Stache::shouldReceive('warm')->never(); + + $this->artisan('statamic:stache:refresh', ['--git' => true]) + ->expectsOutputToContain('selectively groomed') + ->assertExitCode(0); + } } diff --git a/tests/Git/GitProcessTest.php b/tests/Git/GitProcessTest.php index af38589ed90..519464c5269 100644 --- a/tests/Git/GitProcessTest.php +++ b/tests/Git/GitProcessTest.php @@ -128,6 +128,18 @@ public function it_can_get_git_status_of_specific_sub_paths() $this->assertEquals($expectedCombinedStatus, Git::create($this->basePath('temp/content'))->status(['collections', 'taxonomies'])); } + #[Group('integration')] + #[Test] + public function it_lists_untracked_files() + { + $this->files->put($this->basePath('temp/content/collections/new.yaml'), 'title: New Collection'); + + $output = Git::create($this->basePath('temp/content'))->untrackedFiles(); + + $this->assertStringContainsString('collections/new.yaml', $output); + $this->assertStringNotContainsString('pages.yaml', $output); + } + #[Test] public function it_logs_error_output() { diff --git a/tests/Git/GitTest.php b/tests/Git/GitTest.php index a9f4e55af45..fef998bedb3 100644 --- a/tests/Git/GitTest.php +++ b/tests/Git/GitTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Queue; use PHPUnit\Framework\Attributes\Test; +use Statamic\Console\Processes\Exceptions\ProcessException; use Statamic\Console\Processes\Git as GitProcess; use Statamic\Console\Processes\Process; use Statamic\Facades\Config; @@ -570,6 +571,29 @@ public function it_normalizes_rename_lines_into_delete_and_add() $this->assertEquals(['status' => 'A', 'path' => 'content/collections/blog/new-name.md'], $changes[1]); } + #[Test] + public function it_normalizes_copy_lines_into_add_of_the_new_path() + { + $output = "C100\tcontent/collections/blog/original.md\tcontent/collections/blog/copy.md"; + + $changes = Git::parseDiffOutput($output); + + $this->assertCount(1, $changes); + $this->assertEquals(['status' => 'A', 'path' => 'content/collections/blog/copy.md'], $changes[0]); + } + + #[Test] + public function it_parses_untracked_output_as_added_paths() + { + $output = "content/collections/blog/new.md\ncontent/collections/news/other.md"; + + $changes = Git::parseUntrackedOutput($output); + + $this->assertCount(2, $changes); + $this->assertEquals(['status' => 'A', 'path' => 'content/collections/blog/new.md'], $changes[0]); + $this->assertEquals(['status' => 'A', 'path' => 'content/collections/news/other.md'], $changes[1]); + } + #[Test] public function it_returns_empty_collection_for_empty_diff_output() { @@ -624,6 +648,48 @@ public function it_returns_empty_collection_when_no_changes_since_ref() unlink(storage_path('statamic/.stache-git-ref')); } + #[Test] + public function stache_diff_throws_when_git_diff_fails() + { + $refFile = storage_path('statamic/.stache-git-ref'); + + Git::setStacheRef('not-a-valid-sha'); + + try { + Git::stacheDiff(); + $this->fail('Expected a ProcessException when git diff fails.'); + } catch (ProcessException $e) { + $this->assertMatchesRegularExpression('/Git command failed/', $e->getMessage()); + } finally { + if (file_exists($refFile)) { + unlink($refFile); + } + } + } + + #[Test] + public function stache_diff_includes_untracked_files_when_include_dirty_is_true() + { + $process = \Mockery::mock(GitProcess::class); + $process->shouldReceive('diff')->once()->with('abc1234', 'HEAD')->andReturn(''); + $process->shouldReceive('diffDirty')->once()->andReturn(''); + $process->shouldReceive('diffStaged')->once()->andReturn(''); + $process->shouldReceive('untrackedFiles')->once()->andReturn('content/collections/blog/new-post.md'); + + $git = \Mockery::mock(\Statamic\Git\Git::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $git->shouldReceive('getStacheRef')->andReturn('abc1234'); + $git->shouldReceive('stacheGitProcess')->andReturn($process); + + config(['statamic.stache.stores.entries.directory' => base_path('content/collections')]); + + $actions = $git->stacheDiff(true); + + $this->assertCount(1, $actions); + $this->assertEquals('update-item', $actions[0]['type']); + $this->assertEquals('entries::blog', $actions[0]['storeKey']); + $this->assertEquals('content/collections/blog/new-post.md', $actions[0]['displayPath']); + } + private function showLastCommit($path) { return Process::create($path)->run('git show'); diff --git a/tests/Stache/Stores/EntriesStoreTest.php b/tests/Stache/Stores/EntriesStoreTest.php index 705a70f3c75..ada8f5ccc13 100644 --- a/tests/Stache/Stores/EntriesStoreTest.php +++ b/tests/Stache/Stores/EntriesStoreTest.php @@ -356,6 +356,45 @@ public function it_removes_the_suffix_if_it_previously_had_one_but_needs_a_new_p $this->assertFileDoesNotExist($newPath); } + #[Test] + public function it_updates_an_item_from_path() + { + $store = $this->parent->store('blog'); + $path = Path::tidy($this->directory).'/blog/2018-07-04.fourth-of-july.md'; + $original = file_get_contents($path); + + $store->paths(); + + $this->assertEquals('Fourth of July', $store->getItem('blog-fourth-of-july')->get('title')); + + try { + file_put_contents($path, "id: blog-fourth-of-july\ntitle: Independence Day\n"); + + $store->updateItemFromPath($path); + + $this->assertEquals('Independence Day', $store->getItem('blog-fourth-of-july')->get('title')); + } finally { + file_put_contents($path, $original); + } + } + + #[Test] + public function it_forgets_an_item_by_path() + { + $store = $this->parent->store('blog'); + $path = Path::tidy($this->directory).'/blog/2018-07-04.fourth-of-july.md'; + + $store->paths(); + + $this->assertNotNull($store->getItem('blog-fourth-of-july')); + + $store->forgetItemByPath($this->directory.'/blog//2018-07-04.fourth-of-july.md'); + + $this->assertNull($store->getItem('blog-fourth-of-july')); + $this->assertNull($store->paths()->get('blog-fourth-of-july')); + $this->assertFileExists($path); + } + #[Test] public function it_ignores_entries_in_a_site_subdirectory_where_the_collection_doesnt_have_that_site_enabled() { From 34d7269c77dc2c29b15d8fde0e9b955c9da55549 Mon Sep 17 00:00:00 2001 From: Daniel Weaver Date: Wed, 2 Sep 2026 14:52:52 -0400 Subject: [PATCH 4/6] fix(stache): persist git ref, warm related stores, and honor --exclude An empty ref file now bootstraps like a missing one, and a full clear+warm writes HEAD when the site is a git repo. Collection, taxonomy, and collection-tree yaml also warm the child store. Targeted --git skips excluded stores, and term yaml updates every locale. --- src/Console/Commands/StacheRefresh.php | 37 ++++++++++++- src/Git/Git.php | 8 ++- src/Stache/GitPathMapper.php | 33 +++++++++--- src/Stache/Stores/Store.php | 19 +++---- tests/Console/Commands/StacheRefreshTest.php | 56 ++++++++++++++++++++ tests/Git/GitTest.php | 17 ++++++ tests/Stache/GitPathMapperTest.php | 46 +++++++++++++--- 7 files changed, 192 insertions(+), 24 deletions(-) diff --git a/src/Console/Commands/StacheRefresh.php b/src/Console/Commands/StacheRefresh.php index 0abe5cfd439..51727154fc0 100644 --- a/src/Console/Commands/StacheRefresh.php +++ b/src/Console/Commands/StacheRefresh.php @@ -8,6 +8,7 @@ use Statamic\Console\RunsInPlease; use Statamic\Facades\Stache; use Statamic\Git\Git; +use Statamic\Support\Str; use function Laravel\Prompts\spin; @@ -33,6 +34,8 @@ public function handle() spin(callback: fn () => Stache::clear(), message: 'Clearing the Stache...'); spin(callback: fn () => Stache::warm(), message: 'Warming the Stache...'); + $this->writeStacheRefIfRepo(); + $this->components->info('You have trimmed and polished the Stache. It is handsome, warm, and ready.'); } @@ -53,7 +56,7 @@ protected function handleGitRefresh(): int spin(callback: fn () => Stache::clear(), message: 'Clearing the Stache...'); spin(callback: fn () => Stache::warm(), message: 'Warming the Stache...'); - $git->setStacheRef($git->currentSha()); + $this->writeStacheRefIfRepo(); $this->components->info('Stache bootstrapped from HEAD. Future --git runs will be targeted.'); @@ -89,7 +92,7 @@ protected function handleGitRefresh(): int spin(callback: fn () => Stache::clear(), message: 'Clearing the Stache...'); spin(callback: fn () => Stache::warm(), message: 'Warming the Stache...'); - $git->setStacheRef($git->currentSha()); + $this->writeStacheRefIfRepo(); $this->components->info('You have trimmed and polished the Stache. It is handsome, warm, and ready.'); return self::SUCCESS; @@ -112,6 +115,7 @@ protected function executeActions($actions): void { $actions ->filter(fn ($a) => in_array($a['type'], ['update-item', 'forget-item'])) + ->reject(fn ($a) => $this->isStoreExcluded($a['storeKey'])) ->each(function ($action) { $store = Stache::store($action['storeKey']); @@ -134,6 +138,7 @@ protected function executeActions($actions): void $actions ->filter(fn ($a) => $a['type'] === 'warm-store') + ->reject(fn ($a) => $this->isStoreExcluded($a['storeKey'])) ->pluck('storeKey') ->unique() ->each(function ($storeKey) { @@ -144,6 +149,34 @@ protected function executeActions($actions): void }); } + protected function writeStacheRefIfRepo(): void + { + $git = app(Git::class); + + if (! $git->isRepo()) { + return; + } + + $git->setStacheRef($git->currentSha()); + } + + protected function isStoreExcluded(?string $storeKey): bool + { + if (! $storeKey) { + return false; + } + + $excludes = collect(explode(',', (string) $this->option('exclude'))) + ->map(fn ($key) => trim($key)) + ->filter(); + + if ($excludes->contains($storeKey)) { + return true; + } + + return Str::contains($storeKey, '::') && $excludes->contains(Str::before($storeKey, '::')); + } + protected function outputVerboseTable($actions): void { $this->table( diff --git a/src/Git/Git.php b/src/Git/Git.php index 42acf4a0e04..c4a01efda0e 100644 --- a/src/Git/Git.php +++ b/src/Git/Git.php @@ -324,7 +324,13 @@ public function getStacheRef(): ?string { $path = $this->stacheRefFilePath(); - return file_exists($path) ? trim(file_get_contents($path)) : null; + if (! file_exists($path)) { + return null; + } + + $sha = trim((string) file_get_contents($path)); + + return $sha === '' ? null : $sha; } /** diff --git a/src/Stache/GitPathMapper.php b/src/Stache/GitPathMapper.php index 56291d4f982..b36cb8162f2 100644 --- a/src/Stache/GitPathMapper.php +++ b/src/Stache/GitPathMapper.php @@ -55,7 +55,12 @@ private function mapChange(string $status, string $relativePath, string $basePat $remainder = Str::after($absolutePath, $collectionsDir.'/'); // Only match files directly in the directory (not subdirectories = entry files) if (! Str::contains($remainder, '/') && Str::endsWith($remainder, '.yaml')) { - return [$this->makeAction($status, 'collections', $absolutePath, $relativePath)]; + $handle = pathinfo($remainder, PATHINFO_FILENAME); + + return [ + $this->makeAction($status, 'collections', $absolutePath, $relativePath), + $this->warmStoreAction('entries::'.$handle, $relativePath), + ]; } } @@ -77,7 +82,12 @@ private function mapChange(string $status, string $relativePath, string $basePat if (Str::startsWith($absolutePath, $taxonomiesDir.'/')) { $remainder = Str::after($absolutePath, $taxonomiesDir.'/'); if (! Str::contains($remainder, '/') && Str::endsWith($remainder, '.yaml')) { - return [$this->makeAction($status, 'taxonomies', $absolutePath, $relativePath)]; + $handle = pathinfo($remainder, PATHINFO_FILENAME); + + return [ + $this->makeAction($status, 'taxonomies', $absolutePath, $relativePath), + $this->warmStoreAction('terms::'.$handle, $relativePath), + ]; } } @@ -102,7 +112,13 @@ private function mapChange(string $status, string $relativePath, string $basePat // --- Collection trees: content/trees/collections/... $collectionTreesDir = rtrim($storeDirectories['collection-trees'] ?? $basePath.'/content/trees/collections', '/'); if (Str::startsWith($absolutePath, $collectionTreesDir.'/')) { - return [$this->makeAction($status, 'collection-trees', $absolutePath, $relativePath)]; + $remainder = Str::after($absolutePath, $collectionTreesDir.'/'); + $handle = pathinfo($remainder, PATHINFO_FILENAME); + + return [ + $this->makeAction($status, 'collection-trees', $absolutePath, $relativePath), + $this->warmStoreAction('entries::'.$handle, $relativePath), + ]; } // --- Nav trees: content/trees/navigation/... @@ -150,17 +166,17 @@ private function mapBlueprintChange(string $status, string $absolutePath, string // resources/blueprints/collections/{collection}/... → warm entries::{collection} store if (isset($parts[0]) && $parts[0] === 'collections' && isset($parts[1])) { - return ['type' => 'warm-store', 'storeKey' => 'entries::'.$parts[1], 'absolutePath' => null, 'displayPath' => $relativePath]; + return $this->warmStoreAction('entries::'.$parts[1], $relativePath); } // resources/blueprints/taxonomies/{taxonomy}/... → warm terms::{taxonomy} store if (isset($parts[0]) && $parts[0] === 'taxonomies' && isset($parts[1])) { - return ['type' => 'warm-store', 'storeKey' => 'terms::'.$parts[1], 'absolutePath' => null, 'displayPath' => $relativePath]; + return $this->warmStoreAction('terms::'.$parts[1], $relativePath); } // resources/blueprints/assets/... → warm asset-containers store if (isset($parts[0]) && $parts[0] === 'assets') { - return ['type' => 'warm-store', 'storeKey' => 'asset-containers', 'absolutePath' => null, 'displayPath' => $relativePath]; + return $this->warmStoreAction('asset-containers', $relativePath); } // Any other blueprint change (e.g. user.yaml, default.yaml) → full refresh @@ -172,6 +188,11 @@ private function fullRefreshAction(string $displayPath): array return ['type' => 'full-refresh', 'storeKey' => null, 'absolutePath' => null, 'displayPath' => $displayPath]; } + private function warmStoreAction(string $storeKey, string $displayPath): array + { + return ['type' => 'warm-store', 'storeKey' => $storeKey, 'absolutePath' => null, 'displayPath' => $displayPath]; + } + private function makeAction(string $gitStatus, string $storeKey, string $absolutePath, string $displayPath): array { $type = $gitStatus === 'D' ? 'forget-item' : 'update-item'; diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index b6d72969c73..eac11651349 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -297,17 +297,18 @@ public function handleFileChanges() */ public function updateItemFromPath(string $path): void { - $item = $this->makeItemFromFile($path, File::get($path)); - $key = $this->getItemKey($item); + foreach (Arr::wrap($this->getItemFromModifiedPath($path)) as $item) { + $key = $this->getItemKey($item); - $this->forgetItem($key); - $this->setPath($key, $item->path()); - $this->cacheItem($item); - $this->handleModifiedItem($item); + $this->forgetItem($key); + $this->setPath($key, $item->path()); + $this->cacheItem($item); + $this->handleModifiedItem($item); - $this->resolveIndexes()->filter->isCached()->each(function ($index) use ($item) { - $index->updateItem($item); - }); + $this->resolveIndexes()->filter->isCached()->each(function ($index) use ($item) { + $index->updateItem($item); + }); + } } /** diff --git a/tests/Console/Commands/StacheRefreshTest.php b/tests/Console/Commands/StacheRefreshTest.php index 998b1f7c122..517a0f1245e 100644 --- a/tests/Console/Commands/StacheRefreshTest.php +++ b/tests/Console/Commands/StacheRefreshTest.php @@ -265,4 +265,60 @@ public function it_forgets_an_item_by_path_for_forget_item_actions() ->expectsOutputToContain('selectively groomed') ->assertExitCode(0); } + + #[Test] + public function it_writes_the_stache_ref_after_a_full_refresh_when_in_a_git_repo() + { + $git = $this->mockGit([ + 'isRepo' => true, + 'currentSha' => 'abc1234', + ]); + $git->shouldReceive('setStacheRef')->once()->with('abc1234'); + + Stache::shouldReceive('exclude')->never() + ->shouldReceive('clear')->once() + ->shouldReceive('warm')->once(); + + $this->artisan(StacheRefresh::class); + } + + #[Test] + public function it_does_not_write_the_stache_ref_after_a_full_refresh_when_not_in_a_git_repo() + { + $git = $this->mockGit(['isRepo' => false]); + $git->shouldReceive('setStacheRef')->never(); + $git->shouldReceive('currentSha')->never(); + + Stache::shouldReceive('exclude')->never() + ->shouldReceive('clear')->once() + ->shouldReceive('warm')->once(); + + $this->artisan(StacheRefresh::class); + } + + #[Test] + public function it_skips_targeted_actions_for_excluded_stores() + { + $path = '/var/www/site/content/collections/blog/post.md'; + $actions = collect([ + ['type' => 'update-item', 'storeKey' => 'entries::blog', 'absolutePath' => $path, 'displayPath' => 'content/collections/blog/post.md'], + ]); + + $git = $this->mockGit([ + 'isRepo' => true, + 'getStacheRef' => 'abc1234', + 'stacheDiff' => $actions, + 'currentSha' => 'def5678', + ]); + $git->shouldReceive('setStacheRef')->once()->with('def5678'); + + Stache::shouldReceive('exclude')->once()->with('entries')->andReturn(); + Stache::shouldReceive('store')->never(); + Stache::shouldReceive('clear')->never(); + Stache::shouldReceive('warm')->never(); + + $this->artisan('statamic:stache:refresh', ['--git' => true, '--exclude' => 'entries']) + ->expectsOutputToContain('selectively groomed') + ->assertExitCode(0); + } } diff --git a/tests/Git/GitTest.php b/tests/Git/GitTest.php index fef998bedb3..2645577cbe5 100644 --- a/tests/Git/GitTest.php +++ b/tests/Git/GitTest.php @@ -620,6 +620,23 @@ public function it_reads_and_writes_the_stache_ref_file() unlink($refFile); } + #[Test] + public function it_treats_an_empty_stache_ref_file_as_null() + { + $refFile = storage_path('statamic/.stache-git-ref'); + $dir = dirname($refFile); + + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($refFile, " \n"); + + $this->assertNull(Git::getStacheRef()); + + unlink($refFile); + } + #[Test] public function it_returns_null_stache_diff_when_no_ref_file_exists() { diff --git a/tests/Stache/GitPathMapperTest.php b/tests/Stache/GitPathMapperTest.php index 32640e0ebfa..9a6a6b5414a 100644 --- a/tests/Stache/GitPathMapperTest.php +++ b/tests/Stache/GitPathMapperTest.php @@ -80,27 +80,59 @@ public function it_maps_a_deleted_entry_to_forget_item() } #[Test] - public function it_maps_a_collection_config_yaml_to_collections_store() + public function it_maps_a_collection_config_yaml_to_collections_store_and_warms_entries() { $actions = $this->map([ ['status' => 'M', 'path' => 'content/collections/blog.yaml'], ]); - $this->assertCount(1, $actions); + $this->assertCount(2, $actions); $this->assertEquals('update-item', $actions[0]['type']); $this->assertEquals('collections', $actions[0]['storeKey']); + $this->assertEquals('warm-store', $actions[1]['type']); + $this->assertEquals('entries::blog', $actions[1]['storeKey']); + } + + #[Test] + public function it_maps_a_deleted_collection_config_yaml_to_forget_item_and_warms_entries() + { + $actions = $this->map([ + ['status' => 'D', 'path' => 'content/collections/blog.yaml'], + ]); + + $this->assertCount(2, $actions); + $this->assertEquals('forget-item', $actions[0]['type']); + $this->assertEquals('collections', $actions[0]['storeKey']); + $this->assertEquals('warm-store', $actions[1]['type']); + $this->assertEquals('entries::blog', $actions[1]['storeKey']); } #[Test] - public function it_maps_a_taxonomy_config_yaml_to_taxonomies_store() + public function it_maps_a_taxonomy_config_yaml_to_taxonomies_store_and_warms_terms() { $actions = $this->map([ ['status' => 'M', 'path' => 'content/taxonomies/tags.yaml'], ]); - $this->assertCount(1, $actions); + $this->assertCount(2, $actions); $this->assertEquals('update-item', $actions[0]['type']); $this->assertEquals('taxonomies', $actions[0]['storeKey']); + $this->assertEquals('warm-store', $actions[1]['type']); + $this->assertEquals('terms::tags', $actions[1]['storeKey']); + } + + #[Test] + public function it_maps_a_deleted_taxonomy_config_yaml_to_forget_item_and_warms_terms() + { + $actions = $this->map([ + ['status' => 'D', 'path' => 'content/taxonomies/tags.yaml'], + ]); + + $this->assertCount(2, $actions); + $this->assertEquals('forget-item', $actions[0]['type']); + $this->assertEquals('taxonomies', $actions[0]['storeKey']); + $this->assertEquals('warm-store', $actions[1]['type']); + $this->assertEquals('terms::tags', $actions[1]['storeKey']); } #[Test] @@ -164,15 +196,17 @@ public function it_maps_navigation_to_navigation_store() } #[Test] - public function it_maps_collection_tree_to_collection_trees_store() + public function it_maps_collection_tree_to_collection_trees_store_and_warms_entries() { $actions = $this->map([ ['status' => 'M', 'path' => 'content/trees/collections/pages.yaml'], ]); - $this->assertCount(1, $actions); + $this->assertCount(2, $actions); $this->assertEquals('update-item', $actions[0]['type']); $this->assertEquals('collection-trees', $actions[0]['storeKey']); + $this->assertEquals('warm-store', $actions[1]['type']); + $this->assertEquals('entries::pages', $actions[1]['storeKey']); } #[Test] From 10b40b46221e7a8fc81b5fd799d5d2c0d4984e2e Mon Sep 17 00:00:00 2001 From: Daniel Weaver Date: Wed, 2 Sep 2026 15:34:47 -0400 Subject: [PATCH 5/6] fix(stache): raise Store phpstan baseline counts updateItemFromPath and forgetItemByPath call cacheItem, forgetItem, and getKeyFromPath more times than the baseline allowed. --- .phpstan/baseline.neon | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.phpstan/baseline.neon b/.phpstan/baseline.neon index 865f5ca2805..d17e3b04f13 100644 --- a/.phpstan/baseline.neon +++ b/.phpstan/baseline.neon @@ -195,19 +195,19 @@ parameters: - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:cacheItem\(\)\.$#' identifier: method.notFound - count: 2 + count: 3 path: ../src/Stache/Stores/Store.php - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:forgetItem\(\)\.$#' identifier: method.notFound - count: 2 + count: 4 path: ../src/Stache/Stores/Store.php - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:getKeyFromPath\(\)\.$#' identifier: method.notFound - count: 1 + count: 2 path: ../src/Stache/Stores/Store.php - From 8bcb63960dbb3de65d5dc9628ed5eeb7bc709b07 Mon Sep 17 00:00:00 2001 From: Daniel Weaver Date: Wed, 2 Sep 2026 15:43:17 -0400 Subject: [PATCH 6/6] fix(stache): run targeted item updates on BasicStore cacheItem, forgetItem, and getKeyFromPath already live there. The new methods belong next to them, not on Store. --- .phpstan/baseline.neon | 6 +-- src/Stache/Stores/BasicStore.php | 66 +++++++++++++++++++++++++++++ src/Stache/Stores/Store.php | 72 -------------------------------- 3 files changed, 69 insertions(+), 75 deletions(-) diff --git a/.phpstan/baseline.neon b/.phpstan/baseline.neon index d17e3b04f13..865f5ca2805 100644 --- a/.phpstan/baseline.neon +++ b/.phpstan/baseline.neon @@ -195,19 +195,19 @@ parameters: - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:cacheItem\(\)\.$#' identifier: method.notFound - count: 3 + count: 2 path: ../src/Stache/Stores/Store.php - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:forgetItem\(\)\.$#' identifier: method.notFound - count: 4 + count: 2 path: ../src/Stache/Stores/Store.php - message: '#^Call to an undefined method Statamic\\Stache\\Stores\\Store\:\:getKeyFromPath\(\)\.$#' identifier: method.notFound - count: 2 + count: 1 path: ../src/Stache/Stores/Store.php - diff --git a/src/Stache/Stores/BasicStore.php b/src/Stache/Stores/BasicStore.php index 560faf35e36..b78f484e7f1 100644 --- a/src/Stache/Stores/BasicStore.php +++ b/src/Stache/Stores/BasicStore.php @@ -2,8 +2,11 @@ namespace Statamic\Stache\Stores; +use Illuminate\Support\Enumerable; use Statamic\Facades\File; +use Statamic\Facades\Path; use Statamic\Facades\Stache; +use Statamic\Support\Arr; use Symfony\Component\Finder\SplFileInfo; abstract class BasicStore extends Store @@ -113,6 +116,69 @@ protected function getKeyFromPath($path) return $this->paths()->flip()->get($path); } + public function updateItemFromPath(string $path): void + { + foreach (Arr::wrap($this->getItemFromModifiedPath($path)) as $item) { + $key = $this->getItemKey($item); + + $this->forgetItem($key); + $this->setPath($key, $item->path()); + $this->cacheItem($item); + $this->handleModifiedItem($item); + + $this->resolveIndexes()->filter->isCached()->each(function ($index) use ($item) { + $index->updateItem($item); + }); + } + } + + public function forgetItemByPath(string $path): void + { + $key = $this->getKeyFromPathVariants($path); + + collect($key)->each(function ($key) use ($path) { + $this->forgetItem($key); + $this->forgetPath($key); + $this->resolveIndexes()->filter->isCached()->each->forgetItem($key); + $this->handleDeletedItem($path, $key); + }); + } + + protected function getKeyFromPathVariants(string $path) + { + foreach ($this->pathLookupVariants($path) as $candidate) { + $key = $this->getKeyFromPath($candidate); + + if ($key instanceof Enumerable) { + if ($key->isNotEmpty()) { + return $key; + } + + continue; + } + + if ($key !== null && $key !== false && $key !== '') { + return $key; + } + } + + return null; + } + + protected function pathLookupVariants(string $path): array + { + $tidy = Path::tidy($path); + $resolved = Path::resolve($path); + + return array_values(array_unique(array_filter([ + $path, + $tidy, + rtrim($tidy, '/'), + $resolved, + rtrim($resolved, '/'), + ]))); + } + public function save($item) { $this->writeItemToDisk($item); diff --git a/src/Stache/Stores/Store.php b/src/Stache/Stores/Store.php index eac11651349..778f6d66440 100644 --- a/src/Stache/Stores/Store.php +++ b/src/Stache/Stores/Store.php @@ -3,7 +3,6 @@ namespace Statamic\Stache\Stores; use Facades\Statamic\Stache\Traverser; -use Illuminate\Support\Enumerable; use Statamic\Facades\File; use Statamic\Facades\Path; use Statamic\Facades\Stache; @@ -291,77 +290,6 @@ public function handleFileChanges() $this->modified = $modified; } - /** - * Update the stache cache for a single file that was added or modified on disk. - * This is a read-only stache operation — it does not write to disk. - */ - public function updateItemFromPath(string $path): void - { - foreach (Arr::wrap($this->getItemFromModifiedPath($path)) as $item) { - $key = $this->getItemKey($item); - - $this->forgetItem($key); - $this->setPath($key, $item->path()); - $this->cacheItem($item); - $this->handleModifiedItem($item); - - $this->resolveIndexes()->filter->isCached()->each(function ($index) use ($item) { - $index->updateItem($item); - }); - } - } - - /** - * Remove a single item from the stache cache by its file path. - * Used when a file has been deleted from disk. - */ - public function forgetItemByPath(string $path): void - { - $key = $this->getKeyFromPathVariants($path); - - collect($key)->each(function ($key) use ($path) { - $this->forgetItem($key); - $this->forgetPath($key); - $this->resolveIndexes()->filter->isCached()->each->forgetItem($key); - $this->handleDeletedItem($path, $key); - }); - } - - protected function getKeyFromPathVariants(string $path) - { - foreach ($this->pathLookupVariants($path) as $candidate) { - $key = $this->getKeyFromPath($candidate); - - if ($key instanceof Enumerable) { - if ($key->isNotEmpty()) { - return $key; - } - - continue; - } - - if ($key !== null && $key !== false && $key !== '') { - return $key; - } - } - - return null; - } - - protected function pathLookupVariants(string $path): array - { - $tidy = Path::tidy($path); - $resolved = Path::resolve($path); - - return array_values(array_unique(array_filter([ - $path, - $tidy, - rtrim($tidy, '/'), - $resolved, - rtrim($resolved, '/'), - ]))); - } - protected function handleModifiedItem($item) { //