From b48202abee631f62e6d916e824ef3e376ae99b21 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 26 Apr 2026 11:36:06 +0200 Subject: [PATCH 1/6] Add --all flag to migrations status command Iterates the app and every loaded plugin that ships a config/Migrations/ directory, prints each section with a header, and returns a non-zero exit code (CODE_STATUS_DOWN / CODE_STATUS_MISSING) when anything is pending so the command is usable in CI/deploy gates. The flag is incompatible with --plugin and --cleanup; both combinations are rejected with a clear error. JSON output is grouped by section ({app: [...], PluginName: [...]}). --- src/Command/StatusCommand.php | 115 +++++++++++++++++++ tests/TestCase/Command/CompletionTest.php | 2 +- tests/TestCase/Command/StatusCommandTest.php | 60 ++++++++++ 3 files changed, 176 insertions(+), 1 deletion(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 7fb7defcd..d4b9a9148 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -17,6 +17,7 @@ use Cake\Console\Arguments; use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; +use Cake\Core\Plugin; use Migrations\Config\ConfigInterface; use Migrations\Db\Adapter\UnifiedMigrationsTableStorage; use Migrations\Migration\ManagerFactory; @@ -64,11 +65,18 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption '', 'migrations status -c secondary', 'migrations status -c secondary -f json', + 'migrations status --all', + 'Print status for the app and every loaded plugin that has migrations.', 'migrations status --cleanup', 'Remove *MISSING* migrations from the migration tracking table', ])->addOption('plugin', [ 'short' => 'p', 'help' => 'The plugin to run migrations for', + ])->addOption('all', [ + 'help' => 'Print status for the app and every loaded plugin that has migrations. ' + . 'Cannot be combined with --plugin or --cleanup.', + 'boolean' => true, + 'default' => false, ])->addOption('connection', [ 'short' => 'c', 'help' => 'The datasource connection to use', @@ -103,6 +111,22 @@ public function execute(Arguments $args, ConsoleIo $io): ?int /** @var string|null $format */ $format = $args->getOption('format'); $clean = $args->getOption('cleanup'); + $all = (bool)$args->getOption('all'); + + if ($all) { + if ($args->getOption('plugin')) { + $io->err('The --all option cannot be combined with --plugin.'); + + return Command::CODE_ERROR; + } + if ($clean) { + $io->err('The --all option cannot be combined with --cleanup.'); + + return Command::CODE_ERROR; + } + + return $this->executeAll($args, $io, $format); + } $factory = new ManagerFactory([ 'plugin' => $args->getOption('plugin'), @@ -143,6 +167,97 @@ public function execute(Arguments $args, ConsoleIo $io): ?int return Command::CODE_SUCCESS; } + /** + * Execute the status command for the app and every loaded plugin + * that ships migrations. + * + * @param \Cake\Console\Arguments $args The command arguments. + * @param \Cake\Console\ConsoleIo $io The console io. + * @param string|null $format Output format. + * @return int The exit code: CODE_STATUS_MISSING (2) when there are missing entries, + * CODE_STATUS_DOWN (3) when there are pending down migrations, CODE_SUCCESS otherwise. + */ + protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): int + { + $sections = ['app' => null]; + foreach (Plugin::loaded() as $pluginName) { + $migrationsPath = Plugin::path($pluginName) . 'config' . DS + . (string)$args->getOption('source') . DS; + if (!is_dir($migrationsPath)) { + continue; + } + $sections[$pluginName] = $pluginName; + } + + $jsonResults = []; + $exitCode = Command::CODE_SUCCESS; + + foreach ($sections as $label => $plugin) { + $factory = new ManagerFactory([ + 'plugin' => $plugin, + 'source' => $args->getOption('source'), + 'connection' => $args->getOption('connection'), + 'dry-run' => $args->getOption('dry-run'), + ]); + $manager = $factory->createManager($io); + $migrations = $manager->printStatus($format); + + $sectionExit = $this->statusExitCode($migrations); + // Precedence: MISSING > DOWN > SUCCESS — once we see MISSING anywhere, keep it. + if ($sectionExit === self::CODE_STATUS_MISSING) { + $exitCode = self::CODE_STATUS_MISSING; + } elseif ($sectionExit === self::CODE_STATUS_DOWN && $exitCode === Command::CODE_SUCCESS) { + $exitCode = self::CODE_STATUS_DOWN; + } + + if ($format === 'json') { + $jsonResults[$label] = $migrations; + continue; + } + + $heading = $label === 'app' + ? 'App' + : sprintf('Plugin: %s', $label); + $io->out(''); + $io->out('=================================================='); + $io->out($heading); + $io->out('=================================================='); + $this->display($migrations, $io, $manager->getSchemaTableName()); + } + + if ($format === 'json') { + $flags = 0; + if ($args->getOption('verbose')) { + $flags = JSON_PRETTY_PRINT; + } + $io->out((string)json_encode($jsonResults, $flags)); + } + + return $exitCode; + } + + /** + * Compute the appropriate status exit code for a single section's migrations array. + * + * @param array $migrations The result of {@see Manager::printStatus()}. + * @return int CODE_STATUS_MISSING when missing entries exist, CODE_STATUS_DOWN when + * any migration is pending (down), otherwise CODE_SUCCESS. + */ + protected function statusExitCode(array $migrations): int + { + $hasDown = false; + foreach ($migrations as $migration) { + if (!empty($migration['missing'])) { + return self::CODE_STATUS_MISSING; + } + if (($migration['status'] ?? null) === 'down') { + $hasDown = true; + } + } + + return $hasDown ? self::CODE_STATUS_DOWN : Command::CODE_SUCCESS; + } + /** * Print migration status to stdout. * diff --git a/tests/TestCase/Command/CompletionTest.php b/tests/TestCase/Command/CompletionTest.php index a1917c42f..d61686dcb 100644 --- a/tests/TestCase/Command/CompletionTest.php +++ b/tests/TestCase/Command/CompletionTest.php @@ -134,7 +134,7 @@ public function testMigrationsOptionsStatus(): void $this->exec('completion options migrations.migrations status'); $this->assertCount(1, $this->_out->messages()); $output = $this->_out->messages()[0]; - $expected = '--cleanup --connection -c --format -f --help -h --plugin -p --quiet -q --source -s --verbose -v'; + $expected = '--all --cleanup --connection -c --format -f --help -h --plugin -p --quiet -q --source -s --verbose -v'; $outputExplode = explode(' ', trim($output)); sort($outputExplode); $expectedExplode = explode(' ', $expected); diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php index 6b3b2f7e1..bad51f450 100644 --- a/tests/TestCase/Command/StatusCommandTest.php +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -5,6 +5,7 @@ use Cake\Console\TestSuite\StubConsoleOutput; use Cake\Core\Exception\MissingPluginException; +use Migrations\Command\StatusCommand; use Migrations\Test\TestCase\TestCase; use RuntimeException; @@ -105,4 +106,63 @@ public function testCleanHelp(): void $this->assertOutputContains('--cleanup'); $this->assertOutputContains('Remove MISSING migrations from the'); } + + public function testAllHelp(): void + { + $this->exec('migrations status --help'); + $this->assertExitSuccess(); + $this->assertOutputContains('--all'); + $this->assertOutputContains('every loaded plugin'); + } + + public function testAllAppOnlyExitCodeDownWhenPending(): void + { + $this->exec('migrations status -c test --all'); + // App has unmigrated migrations, so the exit code signals pending. + $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); + $this->assertOutputContains('App'); + $this->assertOutputContains('Status'); + $this->assertOutputContains('Migration ID'); + } + + public function testAllIncludesLoadedPluginWithMigrations(): void + { + $this->loadPlugins(['Migrator']); + $this->exec('migrations status -c test --all'); + $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); + $this->assertOutputContains('App'); + $this->assertOutputContains('Plugin: Migrator'); + } + + public function testAllJsonOutput(): void + { + $this->loadPlugins(['Migrator']); + $this->exec('migrations status -c test --all --format json'); + $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); + + assert($this->_out instanceof StubConsoleOutput); + $messages = $this->_out->messages(); + $jsonLine = end($messages); + $parsed = json_decode((string)$jsonLine, true); + $this->assertIsArray($parsed); + $this->assertArrayHasKey('app', $parsed); + $this->assertArrayHasKey('Migrator', $parsed); + $this->assertIsArray($parsed['app']); + $this->assertIsArray($parsed['Migrator']); + } + + public function testAllRejectsPluginOption(): void + { + $this->loadPlugins(['Migrator']); + $this->exec('migrations status -c test --all -p Migrator'); + $this->assertExitError(); + $this->assertErrorContains('cannot be combined with --plugin'); + } + + public function testAllRejectsCleanupOption(): void + { + $this->exec('migrations status -c test --all --cleanup'); + $this->assertExitError(); + $this->assertErrorContains('cannot be combined with --cleanup'); + } } From 9ca2b2ea2c1464a0735c7a29081222859c3c10ab Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 26 Apr 2026 11:39:38 +0200 Subject: [PATCH 2/6] Drop redundant string cast in --all path (Rector) --- src/Command/StatusCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index d4b9a9148..7bfb62eec 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -182,7 +182,7 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): $sections = ['app' => null]; foreach (Plugin::loaded() as $pluginName) { $migrationsPath = Plugin::path($pluginName) . 'config' . DS - . (string)$args->getOption('source') . DS; + . $args->getOption('source') . DS; if (!is_dir($migrationsPath)) { continue; } From 71b420385e229404ba15ff68a3644f76215b95b8 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 26 Apr 2026 12:02:53 +0200 Subject: [PATCH 3/6] Make --all summary-only by default, add tables under -v The default --all output is now a compact summary listing only sections that need action with their pending/missing counts (or a single 'all up to date' line). Adding -v restores the full per-section migration tables alongside the summary, matching how -v already brings additional detail in other migrations commands. This keeps the common CI/deploy-gate use case scannable while still letting users drill into per-plugin migration lists on demand. JSON output is unchanged in both modes. --- src/Command/StatusCommand.php | 85 +++++++++++++++++++- tests/TestCase/Command/StatusCommandTest.php | 22 ++++- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 7bfb62eec..ed97736d6 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -66,14 +66,16 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption 'migrations status -c secondary', 'migrations status -c secondary -f json', 'migrations status --all', - 'Print status for the app and every loaded plugin that has migrations.', + 'Print a summary for the app and every loaded plugin that has migrations.', + 'Add -v to also print the per-section migration tables.', 'migrations status --cleanup', 'Remove *MISSING* migrations from the migration tracking table', ])->addOption('plugin', [ 'short' => 'p', 'help' => 'The plugin to run migrations for', ])->addOption('all', [ - 'help' => 'Print status for the app and every loaded plugin that has migrations. ' + 'help' => 'Print a status summary for the app and every loaded plugin that has migrations. ' + . 'Use -v to also print the per-section migration tables. ' . 'Cannot be combined with --plugin or --cleanup.', 'boolean' => true, 'default' => false, @@ -189,7 +191,9 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): $sections[$pluginName] = $pluginName; } + $verbose = (bool)$args->getOption('verbose'); $jsonResults = []; + $summary = []; $exitCode = Command::CODE_SUCCESS; foreach ($sections as $label => $plugin) { @@ -210,11 +214,17 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): $exitCode = self::CODE_STATUS_DOWN; } + $summary[$label] = $this->countActions($migrations); + if ($format === 'json') { $jsonResults[$label] = $migrations; continue; } + if (!$verbose) { + continue; + } + $heading = $label === 'app' ? 'App' : sprintf('Plugin: %s', $label); @@ -227,15 +237,84 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): if ($format === 'json') { $flags = 0; - if ($args->getOption('verbose')) { + if ($verbose) { $flags = JSON_PRETTY_PRINT; } $io->out((string)json_encode($jsonResults, $flags)); + + return $exitCode; } + $this->displaySummary($io, $summary); + return $exitCode; } + /** + * Count actionable items (down + missing) in a section's migrations array. + * + * @param array $migrations The result of {@see Manager::printStatus()}. + * @return array{down: int, missing: int} + */ + protected function countActions(array $migrations): array + { + $down = 0; + $missing = 0; + foreach ($migrations as $migration) { + if (!empty($migration['missing'])) { + $missing++; + continue; + } + if (($migration['status'] ?? null) === 'down') { + $down++; + } + } + + return ['down' => $down, 'missing' => $missing]; + } + + /** + * Render the trailing summary block listing sections that need action. + * + * @param \Cake\Console\ConsoleIo $io The console io. + * @param array $summary + * @return void + */ + protected function displaySummary(ConsoleIo $io, array $summary): void + { + $needsAction = array_filter( + $summary, + fn(array $counts): bool => $counts['down'] > 0 || $counts['missing'] > 0, + ); + + $io->out(''); + if (!$needsAction) { + $io->out(sprintf( + 'Summary: all %d sections are up to date.', + count($summary), + )); + + return; + } + + $io->out(sprintf( + 'Summary: %d of %d sections require action:', + count($needsAction), + count($summary), + )); + foreach ($needsAction as $label => $counts) { + $heading = $label === 'app' ? 'App' : sprintf('Plugin: %s', $label); + $parts = []; + if ($counts['down'] > 0) { + $parts[] = sprintf('%d pending', $counts['down']); + } + if ($counts['missing'] > 0) { + $parts[] = sprintf('%d missing', $counts['missing']); + } + $io->out(sprintf(' - %s: %s', $heading, implode(', ', $parts))); + } + } + /** * Compute the appropriate status exit code for a single section's migrations array. * diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php index bad51f450..42488f12d 100644 --- a/tests/TestCase/Command/StatusCommandTest.php +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -120,9 +120,11 @@ public function testAllAppOnlyExitCodeDownWhenPending(): void $this->exec('migrations status -c test --all'); // App has unmigrated migrations, so the exit code signals pending. $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); - $this->assertOutputContains('App'); - $this->assertOutputContains('Status'); - $this->assertOutputContains('Migration ID'); + // Default output is summary only — no per-section table. + $this->assertOutputContains('Summary:'); + $this->assertOutputContains('App:'); + $this->assertOutputContains('pending'); + $this->assertOutputNotContains('Migration ID'); } public function testAllIncludesLoadedPluginWithMigrations(): void @@ -130,8 +132,20 @@ public function testAllIncludesLoadedPluginWithMigrations(): void $this->loadPlugins(['Migrator']); $this->exec('migrations status -c test --all'); $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); - $this->assertOutputContains('App'); + $this->assertOutputContains('Summary:'); + $this->assertOutputContains('App:'); + $this->assertOutputContains('Plugin: Migrator:'); + } + + public function testAllVerboseShowsPerSectionTables(): void + { + $this->loadPlugins(['Migrator']); + $this->exec('migrations status -c test --all -v'); + $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); + $this->assertOutputContains('Migration ID'); $this->assertOutputContains('Plugin: Migrator'); + // Summary still rendered after the tables. + $this->assertOutputContains('Summary:'); } public function testAllJsonOutput(): void From d5fc43dfcaffea3094120093dc9a9dd9166b3ce5 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 26 Apr 2026 12:07:19 +0200 Subject: [PATCH 4/6] Drop 'Plugin:' prefix; render app label as APP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins now read at the same level as the app in both the summary footer and the verbose section headers — APP and the plugin name sit side by side, e.g. 'APP: 2 pending' / 'Migrator: 1 pending'. --- src/Command/StatusCommand.php | 8 +++----- tests/TestCase/Command/StatusCommandTest.php | 11 +++++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index ed97736d6..39e093125 100644 --- a/src/Command/StatusCommand.php +++ b/src/Command/StatusCommand.php @@ -225,12 +225,10 @@ protected function executeAll(Arguments $args, ConsoleIo $io, ?string $format): continue; } - $heading = $label === 'app' - ? 'App' - : sprintf('Plugin: %s', $label); + $heading = $label === 'app' ? 'APP' : $label; $io->out(''); $io->out('=================================================='); - $io->out($heading); + $io->out(sprintf('%s', $heading)); $io->out('=================================================='); $this->display($migrations, $io, $manager->getSchemaTableName()); } @@ -303,7 +301,7 @@ protected function displaySummary(ConsoleIo $io, array $summary): void count($summary), )); foreach ($needsAction as $label => $counts) { - $heading = $label === 'app' ? 'App' : sprintf('Plugin: %s', $label); + $heading = $label === 'app' ? 'APP' : $label; $parts = []; if ($counts['down'] > 0) { $parts[] = sprintf('%d pending', $counts['down']); diff --git a/tests/TestCase/Command/StatusCommandTest.php b/tests/TestCase/Command/StatusCommandTest.php index 42488f12d..929b07071 100644 --- a/tests/TestCase/Command/StatusCommandTest.php +++ b/tests/TestCase/Command/StatusCommandTest.php @@ -122,7 +122,7 @@ public function testAllAppOnlyExitCodeDownWhenPending(): void $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); // Default output is summary only — no per-section table. $this->assertOutputContains('Summary:'); - $this->assertOutputContains('App:'); + $this->assertOutputContains('APP:'); $this->assertOutputContains('pending'); $this->assertOutputNotContains('Migration ID'); } @@ -133,8 +133,9 @@ public function testAllIncludesLoadedPluginWithMigrations(): void $this->exec('migrations status -c test --all'); $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); $this->assertOutputContains('Summary:'); - $this->assertOutputContains('App:'); - $this->assertOutputContains('Plugin: Migrator:'); + $this->assertOutputContains('- APP:'); + $this->assertOutputContains('- Migrator:'); + $this->assertOutputNotContains('Plugin:'); } public function testAllVerboseShowsPerSectionTables(): void @@ -143,7 +144,9 @@ public function testAllVerboseShowsPerSectionTables(): void $this->exec('migrations status -c test --all -v'); $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); $this->assertOutputContains('Migration ID'); - $this->assertOutputContains('Plugin: Migrator'); + // Plugin section header reads as just the plugin name now. + $this->assertOutputContains('Migrator'); + $this->assertOutputNotContains('Plugin: Migrator'); // Summary still rendered after the tables. $this->assertOutputContains('Summary:'); } From e99e27bad82530a055e1cbede160027779a91ded Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 26 Apr 2026 12:15:20 +0200 Subject: [PATCH 5/6] Document --all flag for migrations status Adds 'Checking All Plugins at Once' subsection in running-and-managing-migrations covering the summary/-v output, JSON shape, and exit codes for CI gates, plus a follow-up note in advanced/integration-and-deployment recommending status --all over manual per-plugin loops on deploy. --- .../en/advanced/integration-and-deployment.md | 13 ++++++++ .../running-and-managing-migrations.md | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/docs/en/advanced/integration-and-deployment.md b/docs/en/advanced/integration-and-deployment.md index 7b1f57b90..6fc1036f7 100644 --- a/docs/en/advanced/integration-and-deployment.md +++ b/docs/en/advanced/integration-and-deployment.md @@ -62,6 +62,19 @@ bin/cake migrations status -p PluginName bin/cake migrations migrate -p PluginName ``` +To check the app and every loaded plugin in a single call — a typical need on +deploy gates — use `migrations status --all` instead of looping over plugins +manually: + +```bash +bin/cake migrations status --all +``` + +The command prints a compact per-section summary and exits non-zero when +anything is pending, so it slots straight into a CI step. See +[Checking All Plugins at Once](../getting-started/running-and-managing-migrations#checking-all-plugins-at-once) +for the full description of the output and exit codes. + ## Running Migrations in a Non-shell Environment While typical usage of migrations is from the command line, you can also run diff --git a/docs/en/getting-started/running-and-managing-migrations.md b/docs/en/getting-started/running-and-managing-migrations.md index f3c98fda8..1e6f9f545 100644 --- a/docs/en/getting-started/running-and-managing-migrations.md +++ b/docs/en/getting-started/running-and-managing-migrations.md @@ -58,6 +58,38 @@ bin/cake migrations status --format json You can also use the `--source`, `--connection`, and `--plugin` options just like for the `migrate` command. +### Checking All Plugins at Once + +The `--all` flag prints the status for the app and every loaded plugin that +ships migrations in a single call: + +```bash +bin/cake migrations status --all +``` + +The default output is a compact summary listing only sections that need +action: + +``` +Summary: 2 of 3 sections require action: + - APP: 2 pending + - Migrator: 1 pending +``` + +When everything is migrated, it collapses to a single +`Summary: all N sections are up to date.` line. Add `-v` to also print the +full per-section migration tables before the summary. + +The exit code reflects the worst state across all sections — `0` when clean, +`3` (`CODE_STATUS_DOWN`) when migrations are pending, `2` +(`CODE_STATUS_MISSING`) when entries in the tracking table no longer have +matching files. This makes `status --all` directly usable as a deploy gate +in CI. + +`--format json` returns one combined object keyed by section name, +e.g. `{"app": [...], "PluginName": [...]}`. `--all` cannot be combined with +`--plugin` or `--cleanup`. + ### Cleaning Up Missing Migrations Sometimes migration files may be deleted from the filesystem but still exist in From 150b3d098b23d163050203966a7aacad34515fe3 Mon Sep 17 00:00:00 2001 From: mscherer Date: Sun, 26 Apr 2026 12:17:17 +0200 Subject: [PATCH 6/6] Add language tag to summary code block (md lint) --- docs/en/getting-started/running-and-managing-migrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/en/getting-started/running-and-managing-migrations.md b/docs/en/getting-started/running-and-managing-migrations.md index 1e6f9f545..7c51a4f9a 100644 --- a/docs/en/getting-started/running-and-managing-migrations.md +++ b/docs/en/getting-started/running-and-managing-migrations.md @@ -70,7 +70,7 @@ bin/cake migrations status --all The default output is a compact summary listing only sections that need action: -``` +```text Summary: 2 of 3 sections require action: - APP: 2 pending - Migrator: 1 pending