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..7c51a4f9a 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: + +```text +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 diff --git a/src/Command/StatusCommand.php b/src/Command/StatusCommand.php index 7fb7defcd..39e093125 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,20 @@ protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOption '', 'migrations status -c secondary', 'migrations status -c secondary -f json', + 'migrations status --all', + '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 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, ])->addOption('connection', [ 'short' => 'c', 'help' => 'The datasource connection to use', @@ -103,6 +113,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 +169,172 @@ 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 + . $args->getOption('source') . DS; + if (!is_dir($migrationsPath)) { + continue; + } + $sections[$pluginName] = $pluginName; + } + + $verbose = (bool)$args->getOption('verbose'); + $jsonResults = []; + $summary = []; + $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; + } + + $summary[$label] = $this->countActions($migrations); + + if ($format === 'json') { + $jsonResults[$label] = $migrations; + continue; + } + + if (!$verbose) { + continue; + } + + $heading = $label === 'app' ? 'APP' : $label; + $io->out(''); + $io->out('=================================================='); + $io->out(sprintf('%s', $heading)); + $io->out('=================================================='); + $this->display($migrations, $io, $manager->getSchemaTableName()); + } + + if ($format === 'json') { + $flags = 0; + 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' : $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. + * + * @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..929b07071 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,80 @@ 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); + // 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 + { + $this->loadPlugins(['Migrator']); + $this->exec('migrations status -c test --all'); + $this->assertExitCode(StatusCommand::CODE_STATUS_DOWN); + $this->assertOutputContains('Summary:'); + $this->assertOutputContains('- APP:'); + $this->assertOutputContains('- Migrator:'); + $this->assertOutputNotContains('Plugin:'); + } + + 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'); + // 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:'); + } + + 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'); + } }