Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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