diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 1b3e025..b29833a 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -168,15 +168,15 @@ public function list_repos( array $args, array $assoc_args ): void { function ( $repo ) { $freshness = is_array($repo['primary_freshness'] ?? null) ? $repo['primary_freshness'] : null; return array( - 'name' => $repo['name'], - 'kind' => ! empty($repo['is_worktree']) ? 'worktree' : 'primary', - 'repo' => $repo['repo'] ?? $repo['name'], - 'branch' => $repo['branch'] ?? '-', - 'freshness' => is_array($freshness) ? (string) ( $freshness['status'] ?? '-' ) : '-', - 'behind' => is_array($freshness) && null !== ( $freshness['behind'] ?? null ) ? (string) $freshness['behind'] : '-', - 'remote' => $repo['remote'] ?? '-', - 'git' => $repo['git'] ? 'yes' : 'no', - 'path' => $repo['path'], + 'name' => $repo['name'], + 'kind' => ! empty($repo['is_worktree']) ? 'worktree' : 'primary', + 'repo' => $repo['repo'] ?? $repo['name'], + 'branch' => $repo['branch'] ?? '-', + 'freshness' => is_array($freshness) ? (string) ( $freshness['status'] ?? '-' ) : '-', + 'behind' => is_array($freshness) && null !== ( $freshness['behind'] ?? null ) ? (string) $freshness['behind'] : '-', + 'remote' => $repo['remote'] ?? '-', + 'git' => $repo['git'] ? 'yes' : 'no', + 'path' => $repo['path'], ); }, $result['repos'] @@ -230,7 +230,7 @@ public function clone_repo( array $args, array $assoc_args ): void { array( 'full' => isset($assoc_args['full']), 'allow_duplicate_remote' => isset($assoc_args['allow-duplicate-remote']), - 'progress_callback' => static function ( array $event ): void { + 'progress_callback' => static function ( array $event ): void { $elapsed = number_format( (float) ( $event['elapsed'] ?? 0 ), 1); WP_CLI::log(sprintf('[clone %ss] %s', $elapsed, (string) ( $event['message'] ?? '' ))); }, @@ -5106,7 +5106,8 @@ private function render_worktree_artifact_cleanup_result( array $result, array $ } if ( $dry_run ) { - WP_CLI::success(sprintf('%d artifact(s) would be removed. Prefer `workspace cleanup run --mode=artifacts`; --apply-plan remains a low-level escape hatch until DB-backed cleanup runs land.', (int) ( $summary['would_remove_artifacts'] ?? 0 ))); + $apply_command = (string) ( $result['apply_command'] ?? $summary['apply_command'] ?? 'studio wp datamachine-code workspace cleanup run --mode=artifacts --format=json' ); + WP_CLI::success(sprintf('%d artifact(s) would be removed. Apply this page with `%s`; --apply-plan remains a low-level escape hatch.', (int) ( $summary['would_remove_artifacts'] ?? 0 ), $apply_command)); return; } WP_CLI::success(sprintf('Removed %d artifact(s); %d worktree(s) skipped.', (int) ( $summary['removed_artifacts'] ?? 0 ), count($skipped))); diff --git a/inc/Workspace/WorkspaceArtifactCleanup.php b/inc/Workspace/WorkspaceArtifactCleanup.php index 334a77e..ad214b5 100644 --- a/inc/Workspace/WorkspaceArtifactCleanup.php +++ b/inc/Workspace/WorkspaceArtifactCleanup.php @@ -54,6 +54,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E if ( $exhaustive ) { $limit = 0; } + $apply_command = $this->build_artifact_cleanup_apply_command($limit, $offset, $exhaustive); // Apply paths default to safety probing (small subset). Dry-run defaults // to skipping the per-worktree git probes unless explicitly requested or // the caller asked for exhaustive mode. @@ -66,7 +67,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E } if ( ! $dry_run && null === $apply_plan ) { - return new \WP_Error('artifact_cleanup_plan_required', 'Artifact cleanup applies through reviewed JSON only on this low-level command. Prefer workspace cleanup run --mode=artifacts for daily cleanup; use --dry-run first and --apply-plan= only as an escape hatch.', array( 'status' => 400 )); + return new \WP_Error('artifact_cleanup_plan_required', sprintf('Artifact cleanup applies through the high-level cleanup runner for daily cleanup. Run `%s` to apply the same bounded page, or use --dry-run first and --apply-plan= only as a low-level escape hatch.', $apply_command), array( 'status' => 400 )); } $only_handles = null; @@ -116,12 +117,13 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E if ( $dry_run ) { $response = array( - 'success' => true, - 'dry_run' => true, - 'candidates' => $candidates, - 'removed' => array(), - 'skipped' => $skipped, - 'summary' => $summary, + 'success' => true, + 'dry_run' => true, + 'apply_command' => $apply_command, + 'candidates' => $candidates, + 'removed' => array(), + 'skipped' => $skipped, + 'summary' => array( 'apply_command' => $apply_command ) + $summary, ); if ( null !== $pagination ) { $response['pagination'] = $pagination; @@ -178,6 +180,30 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E return $response; } + /** + * Build the high-level command that applies the same artifact cleanup page. + * + * @param int $limit Effective bounded scan limit. + * @param int $offset Bounded inventory offset. + * @param bool $exhaustive Whether the dry-run used exhaustive mode. + * @return string + */ + private function build_artifact_cleanup_apply_command( int $limit, int $offset, bool $exhaustive ): string { + $parts = array( + 'studio wp datamachine-code workspace cleanup run', + '--mode=artifacts', + ); + if ( $exhaustive ) { + $parts[] = '--exhaustive'; + } else { + $parts[] = sprintf('--limit=%d', $limit); + $parts[] = sprintf('--offset=%d', $offset); + } + $parts[] = '--format=json'; + + return implode(' ', $parts); + } + /** * Build current artifact cleanup candidates and safety skips. * diff --git a/tests/smoke-worktree-cleanup-artifacts.php b/tests/smoke-worktree-cleanup-artifacts.php index f9be93f..c4b50f4 100644 --- a/tests/smoke-worktree-cleanup-artifacts.php +++ b/tests/smoke-worktree-cleanup-artifacts.php @@ -208,6 +208,8 @@ function apply_filters( string $hook_name, $value ) // phpcs:ignore Generic.Cod $assert('bounded_inventory', $plan['pagination']['mode'] ?? '', 'bounded dry-run advertises bounded_inventory mode'); $assert(false, (bool) ( $plan['pagination']['safety_probes'] ?? true ), 'bounded dry-run reports safety_probes=false'); $assert(true, (bool) ( $plan['pagination']['complete'] ?? false ), 'bounded dry-run completes when total <= limit'); + $assert('studio wp datamachine-code workspace cleanup run --mode=artifacts --limit=100 --offset=0 --format=json', $plan['apply_command'] ?? '', 'bounded dry-run exposes matching high-level apply command'); + $assert($plan['apply_command'] ?? '', $plan['summary']['apply_command'] ?? '', 'bounded dry-run summary repeats apply command'); $bounded_skip_reasons = array_column($plan['skipped'] ?? array(), 'reason_code', 'handle'); $assert('active_symlink_target', $bounded_skip_reasons['demo@active'] ?? '', 'active plugin symlink target is protected even in bounded mode'); @@ -230,6 +232,7 @@ function apply_filters( string $hook_name, $value ) // phpcs:ignore Generic.Cod $assert(1, count($exhaustive_plan['candidates'] ?? array()), 'exhaustive dry-run skips dirty/unpushed worktrees'); $assert('demo@clean', $exhaustive_plan['candidates'][0]['handle'] ?? '', 'exhaustive clean worktree is candidate'); $assert('target', $exhaustive_plan['candidates'][0]['artifacts'][0]['path'] ?? '', 'exhaustive candidate artifact path comes from profile'); + $assert('studio wp datamachine-code workspace cleanup run --mode=artifacts --exhaustive --format=json', $exhaustive_plan['apply_command'] ?? '', 'exhaustive dry-run exposes matching high-level apply command'); $skip_reasons = array_column($exhaustive_plan['skipped'] ?? array(), 'reason_code', 'handle'); $assert('dirty_worktree', $skip_reasons['demo@dirty'] ?? '', 'exhaustive dirty worktree is protected'); @@ -252,6 +255,7 @@ function apply_filters( string $hook_name, $value ) // phpcs:ignore Generic.Cod $page_one = $workspace->worktree_cleanup_artifacts(array( 'dry_run' => true, 'limit' => 1, 'offset' => 0 )); $assert(false, is_wp_error($page_one), 'page-1 dry-run succeeds'); $assert(1, (int) ( $page_one['pagination']['scanned'] ?? 0 ), 'page-1 scanned exactly one worktree'); + $assert('studio wp datamachine-code workspace cleanup run --mode=artifacts --limit=1 --offset=0 --format=json', $page_one['apply_command'] ?? '', 'page-1 dry-run apply command preserves page scope'); $assert(true, (bool) ( $page_one['pagination']['partial'] ?? false ), 'page-1 reports partial=true'); $assert(false, (bool) ( $page_one['pagination']['complete'] ?? true ), 'page-1 reports complete=false'); $assert(1, (int) ( $page_one['pagination']['next_offset'] ?? 0 ), 'page-1 next_offset advances by limit'); @@ -263,6 +267,7 @@ function apply_filters( string $hook_name, $value ) // phpcs:ignore Generic.Cod $direct_apply = $workspace->worktree_cleanup_artifacts(array()); $assert(true, is_wp_error($direct_apply), 'direct apply without plan is rejected'); $assert('artifact_cleanup_plan_required', $direct_apply->code ?? '', 'direct apply error is explicit'); + $assert(true, str_contains($direct_apply->get_error_message(), 'workspace cleanup run --mode=artifacts --limit=100 --offset=0 --format=json'), 'direct apply error points to matching high-level apply command'); // Build a stricter plan from the exhaustive scan for precise apply-shape // assertions. This keeps the source-file-mismatch test deterministic diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 145dd82..f416684 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -292,10 +292,19 @@ class FakeArtifactCleanupAbility public function execute( array $input ): array { $this->last_input = $input; + $apply_command = 'studio wp datamachine-code workspace cleanup run --mode=artifacts'; + if ( ! empty($input['exhaustive']) ) { + $apply_command .= ' --exhaustive'; + } else { + $apply_command .= ' --limit=' . (int) ( $input['limit'] ?? 100 ); + $apply_command .= ' --offset=' . (int) ( $input['offset'] ?? 0 ); + } + $apply_command .= ' --format=json'; return array( - 'success' => true, - 'dry_run' => ! empty($input['dry_run']), - 'candidates' => array( + 'success' => true, + 'dry_run' => ! empty($input['dry_run']), + 'apply_command' => $apply_command, + 'candidates' => array( array( 'handle' => 'repo@old', 'repo' => 'repo', @@ -316,6 +325,7 @@ public function execute( array $input ): array ), ), 'summary' => array( + 'apply_command' => $apply_command, 'would_remove_artifacts' => 1, 'removed_artifacts' => 0, 'skipped' => 1, @@ -1376,11 +1386,13 @@ public function execute( array $input ): array datamachine_code_cleanup_assert(array( 'dry_run' => true, 'force' => false ) === $artifact_ability->last_input, 'cleanup-artifacts dry-run flags forwarded to ability'); $artifact_json = json_decode(WP_CLI::$logs[0] ?? '', true); datamachine_code_cleanup_assert('target' === ( $artifact_json['candidates'][0]['artifacts'][0]['path'] ?? '' ), 'cleanup-artifacts JSON includes artifact paths'); + datamachine_code_cleanup_assert('studio wp datamachine-code workspace cleanup run --mode=artifacts --limit=100 --offset=0 --format=json' === ( $artifact_json['apply_command'] ?? '' ), 'cleanup-artifacts JSON includes matching high-level apply command'); + datamachine_code_cleanup_assert(( $artifact_json['apply_command'] ?? '' ) === ( $artifact_json['summary']['apply_command'] ?? null ), 'cleanup-artifacts summary repeats matching apply command'); WP_CLI::$logs = array(); WP_CLI::$successes = array(); $command->worktree(array( 'cleanup-artifacts' ), array( 'dry-run' => true )); - datamachine_code_cleanup_assert(str_contains(WP_CLI::$successes[0] ?? '', 'workspace cleanup run --mode=artifacts'), 'cleanup-artifacts dry-run points daily apply path to task-backed cleanup'); + datamachine_code_cleanup_assert(str_contains(WP_CLI::$successes[0] ?? '', 'workspace cleanup run --mode=artifacts --limit=100 --offset=0 --format=json'), 'cleanup-artifacts dry-run points daily apply path to same task-backed page'); datamachine_code_cleanup_assert(str_contains(WP_CLI::$successes[0] ?? '', 'low-level escape hatch'), 'cleanup-artifacts dry-run demotes apply-plan wording'); datamachine_code_cleanup_assert(! str_contains(WP_CLI::$successes[0] ?? '', 'Save JSON'), 'cleanup-artifacts dry-run does not normalize saving plan files');