-
Notifications
You must be signed in to change notification settings - Fork 1
CLI command to resolve duplicate Stache IDs (please stache:resolve-duplicates) #1445
Description
Statamic has a CP page for resolving duplicate IDs (/cp/duplicates), but there's no CLI equivalent. This is a problem in two scenarios:
- The CP page itself crashes: Duplicates::all() calls $this->stache->store($store)->getItem($id)->path() without a null check. If an entry has been deleted or its collection is misconfigured, getItem() returns null and the entire page throws Call to a member function path() on null. At that point, the only way to fix it is manually editing YAML files.
- Headless / CI environments: There's no way to detect and resolve duplicates without the Control Panel.
stache:doctor already detects duplicates, but it can't fix them. The resolution logic lives entirely in DuplicatesController@regenerate, which is only accessible via HTTP.
Proposal
A new command: php please stache:resolve-duplicates
What it does
- Scans for duplicates using Stache::duplicates()->clear()->find() (same as stache:doctor)
- Reports a summary: count and affected stores
- Regenerates IDs for duplicate entries using the same approach as DuplicatesController@regenerate
- Handles errors gracefully — entries that can't be resolved (e.g. missing blueprints) are reported with a grouped count rather than crashing the whole operation
- Clears the Stache and dispatches DuplicateIdRegenerated
- Supports --force for non-interactive use
Key difference from the CP implementation
The command uses getItems() instead of all() to avoid the null crash in Duplicates::all(). This makes it resilient to the same edge case that breaks the CP page.
Example output
If the .md files can't find the correct blueprint it won't work. Once the blueprint reference is fixed (with a quick search and replace) the command works.
Reference implementation
<?php
namespace Statamic\Console\Commands;
use Illuminate\Console\Command;
use Statamic\Console\RunsInPlease;
use Statamic\Events\DuplicateIdRegenerated;
use Statamic\Facades\File;
use Statamic\Facades\Stache;
use Statamic\Support\Str;
use function Laravel\Prompts\confirm;
use function Laravel\Prompts\spin;
class ResolveDuplicates extends Command
{
use RunsInPlease;
protected $signature = 'statamic:stache:resolve-duplicates
{--force : Resolve all duplicates without confirmation}';
protected $description = 'Resolve duplicate IDs in the Stache by regenerating them';
public function handle()
{
$duplicates = spin(
callback: fn () => Stache::duplicates()->clear()->find(),
message: 'Scanning for duplicate IDs...',
);
$items = collect($duplicates->getItems());
if ($items->isEmpty()) {
$this->components->info('No duplicate IDs found.');
return;
}
$count = $items->flatMap(fn ($ids) => $ids)->map(fn ($paths) => count($paths))->sum();
$storeNames = $items->keys()->map(fn ($key) => Str::after($key, '::'))->implode(', ');
$this->components->error("{$count} duplicate ID(s) found across: {$storeNames}");
if (! $this->option('force') && ! confirm("{$count} duplicate(s) found. Regenerate IDs?")) {
$this->components->warn('Aborted.');
return;
}
$resolved = 0;
$errors = [];
$items->each(function ($ids, $storeName) use (&$resolved, &$errors) {
$store = Stache::store($storeName);
collect($ids)->each(function ($paths, $id) use ($store, &$resolved, &$errors) {
collect($paths)->each(function ($path) use ($store, &$resolved, &$errors) {
try {
$item = $store->makeItemFromFile($path, File::get($path));
$item->id(Stache::generateId());
$item->writeFile();
$resolved++;
} catch (\Exception $e) {
$errors[$e->getMessage()][] = Str::after($path, base_path().'/');
}
});
});
});
spin(
callback: fn () => Stache::clear(),
message: 'Clearing the Stache...',
);
DuplicateIdRegenerated::dispatch();
$this->components->info("Regenerated {$resolved} duplicate ID(s). The Stache has been cleared.");
foreach ($errors as $message => $paths) {
$this->components->warn(count($paths)." item(s) skipped: {$message}");
}
}
}
Side note: Duplicates::all() null safety
While building this, I discovered that Duplicates::all() (line 24) doesn't guard against getItem() returning null. This also crashes the CP duplicates page under the same conditions. A null check there would fix both the CP page and simplify this command:
// Current (crashes)
$this->stache->store($store)->getItem($id)->path()
// Suggested
$this->stache->store($store)->getItem($id)?->path()