Skip to content
160 changes: 159 additions & 1 deletion src/Console/Commands/StacheRefresh.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,184 @@

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;
use Statamic\Support\Str;

use function Laravel\Prompts\spin;

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';

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->writeStacheRefIfRepo();

$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...');

$this->writeStacheRefIfRepo();

$this->components->info('Stache bootstrapped from HEAD. Future --git runs will be targeted.');

return self::SUCCESS;
}

$includeDirty = (bool) $this->option('include-dirty');

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.');
$git->setStacheRef($git->currentSha());

return self::SUCCESS;
}

if ($actions->contains(fn ($a) => $a['type'] === 'full-refresh')) {
if ($this->getOutput()->isVerbose()) {
$this->components->warn('A change requires a full stache 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...');

$this->writeStacheRefIfRepo();
$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']))
->reject(fn ($a) => $this->isStoreExcluded($a['storeKey']))
->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')
->reject(fn ($a) => $this->isStoreExcluded($a['storeKey']))
->pluck('storeKey')
->unique()
->each(function ($storeKey) {
spin(
callback: fn () => Stache::store($storeKey)?->warm(),
message: 'Warming '.$storeKey.'...'
);
});
}

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(
['Path', 'Store', 'Action'],
$actions->map(fn ($a) => [$a['displayPath'], $a['storeKey'] ?? '-', $a['type']])->all()
);
}
}
89 changes: 89 additions & 0 deletions src/Console/Processes/Git.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Statamic\Console\Processes;

use Statamic\Console\Processes\Exceptions\ProcessException;
use Statamic\Support\Str;

class Git extends Process
Expand Down Expand Up @@ -49,6 +50,58 @@ 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->runRequiredGitCommand('diff', '--name-status', $from, $to);
}

/**
* Get git diff --name-status for dirty (unstaged) files.
*
* @return string
*/
public function diffDirty()
{
return $this->runRequiredGitCommand('diff', '--name-status', 'HEAD');
}

/**
* Get git diff --name-status for staged (cached) files.
*
* @return string
*/
public function diffStaged()
{
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');
}

/**
* Get the current HEAD commit SHA.
*
* @return string
*/
public function currentSha()
{
return $this->runGitCommand('rev-parse', 'HEAD');
}

/**
* Run git command.
*
Expand All @@ -60,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.
*
Expand Down
7 changes: 7 additions & 0 deletions src/Facades/Git.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
* @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 parseUntrackedOutput(?string $output)
* @method static \Illuminate\Support\Collection|null stacheDiff(bool $includeDirty = false)
*
* @see \Statamic\Git\Git
*/
Expand Down
Loading
Loading