diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 79a47bb..7c40c86 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -5886,7 +5886,7 @@ private function render_worktree_artifact_cleanup_result( array $result, array $ if ( $dry_run ) { $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)); + WP_CLI::success(sprintf('%d artifact(s) would be removed. Apply reviewed artifact cleanup 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 0c69b41..f334d4a 100644 --- a/inc/Workspace/WorkspaceArtifactCleanup.php +++ b/inc/Workspace/WorkspaceArtifactCleanup.php @@ -54,7 +54,8 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E if ( $exhaustive || $full_workspace ) { $limit = 0; } - $apply_command = $this->build_artifact_cleanup_apply_command(); + $apply_command = $this->build_artifact_cleanup_apply_command(); + $preview_command = $this->build_artifact_cleanup_preview_command($opts); // 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. @@ -67,7 +68,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', 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 )); + 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 reviewed artifact cleanup, or use --dry-run first and --apply-plan= only as a low-level escape hatch.', $apply_command), array( 'status' => 400 )); } $only_handles = null; @@ -141,13 +142,19 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E if ( $dry_run ) { $response = array( - 'success' => true, - 'dry_run' => true, - 'apply_command' => $apply_command, - 'candidates' => $candidates, - 'removed' => array(), - 'skipped' => $skipped, - 'summary' => array( 'apply_command' => $apply_command ) + $summary, + 'success' => true, + 'dry_run' => true, + 'apply_command' => $apply_command, + 'preview_command' => $preview_command, + 'rerun_preview_command' => $preview_command, + 'candidates' => $candidates, + 'removed' => array(), + 'skipped' => $skipped, + 'summary' => array( + 'apply_command' => $apply_command, + 'preview_command' => $preview_command, + 'rerun_preview_command' => $preview_command, + ) + $summary, ); if ( null !== $pagination ) { $response['pagination'] = $pagination; @@ -209,7 +216,37 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E * @return string */ private function build_artifact_cleanup_apply_command(): string { - return 'studio wp datamachine-code workspace cleanup run --mode=artifacts --dry-run --format=json'; + return 'studio wp datamachine-code workspace cleanup run --mode=artifacts'; + } + + /** + * Build the preview command for the current artifact cleanup dry-run. + * + * @param array $opts Dry-run options. + * @return string + */ + private function build_artifact_cleanup_preview_command( array $opts ): string { + $parts = array( 'studio wp datamachine-code workspace worktree cleanup-artifacts --dry-run' ); + if ( ! empty($opts['force']) ) { + $parts[] = '--force'; + } + if ( isset($opts['limit']) ) { + $parts[] = '--limit=' . (int) $opts['limit']; + } + if ( isset($opts['offset']) && (int) $opts['offset'] > 0 ) { + $parts[] = '--offset=' . (int) $opts['offset']; + } + if ( ! empty($opts['exhaustive']) ) { + $parts[] = '--exhaustive'; + } + if ( ! empty($opts['safety_probes']) ) { + $parts[] = '--safety-probes'; + } + if ( isset($opts['sort']) && '' !== trim( (string) $opts['sort']) ) { + $parts[] = '--sort=' . preg_replace('/[^a-z0-9_\-]/i', '', (string) $opts['sort']); + } + $parts[] = '--format=json'; + return implode(' ', $parts); } /** diff --git a/inc/Workspace/WorkspaceCleanupPlan.php b/inc/Workspace/WorkspaceCleanupPlan.php index 381fcad..ebf74e5 100644 --- a/inc/Workspace/WorkspaceCleanupPlan.php +++ b/inc/Workspace/WorkspaceCleanupPlan.php @@ -69,6 +69,7 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error if ( $artifact_plan instanceof \WP_Error ) { return $artifact_plan; } + $artifact_plan = $this->normalize_cleanup_plan_child_preview_commands($artifact_plan); } $worktree_plan = array( @@ -376,6 +377,7 @@ private function build_cleanup_plan_summary( array $rows, array $blocked = array $category_total = array_sum(array_map('intval', $category_totals)); return array( + 'apply_command' => 'studio wp datamachine-code workspace cleanup apply ', 'total_rows' => $total_rows, 'rows_by_type' => $counts, 'byte_totals' => $byte_totals, @@ -390,6 +392,32 @@ private function build_cleanup_plan_summary( array $rows, array $blocked = array ); } + /** + * Keep embedded child dry-runs from advertising preview commands as applies. + * + * @param array $plan Child artifact cleanup plan. + * @return array + */ + private function normalize_cleanup_plan_child_preview_commands( array $plan ): array { + $preview_command = (string) ( $plan['preview_command'] ?? $plan['rerun_preview_command'] ?? $plan['apply_command'] ?? '' ); + unset($plan['apply_command']); + if ( '' !== $preview_command ) { + $plan['preview_command'] = $preview_command; + $plan['rerun_preview_command'] = $preview_command; + } + + if ( isset($plan['summary']) && is_array($plan['summary']) ) { + $summary_preview_command = (string) ( $plan['summary']['preview_command'] ?? $plan['summary']['rerun_preview_command'] ?? $plan['summary']['apply_command'] ?? $preview_command ); + unset($plan['summary']['apply_command']); + if ( '' !== $summary_preview_command ) { + $plan['summary']['preview_command'] = $summary_preview_command; + $plan['summary']['rerun_preview_command'] = $summary_preview_command; + } + } + + return $plan; + } + /** * Build operator continuation evidence from bounded child cleanup plans. * diff --git a/tests/artifact-cleanup-plan-output-contract.php b/tests/artifact-cleanup-plan-output-contract.php new file mode 100644 index 0000000..0a4bbbe --- /dev/null +++ b/tests/artifact-cleanup-plan-output-contract.php @@ -0,0 +1,105 @@ + true, + 'dry_run' => true, + 'apply_command' => $preview_command, + 'candidates' => array( + array( + 'handle' => 'repo@example', + 'repo' => 'repo', + 'branch' => 'example', + 'path' => '/tmp/dmc-artifact-cleanup-contract/repo@example', + 'artifact_size_bytes' => 123, + 'artifacts' => array( + array( + 'path' => 'vendor', + 'size_bytes' => 123, + ), + ), + ), + ), + 'skipped' => array(), + 'summary' => array( + 'apply_command' => $preview_command, + 'would_remove_artifacts' => 1, + 'artifact_size_bytes' => 123, + ), + ); + } + + public function worktree_cleanup_merged( array $opts = array() ): array { + return array( + 'success' => true, + 'dry_run' => true, + 'candidates' => array(), + 'skipped' => array(), + 'summary' => array(), + ); + } + + private function stable_cleanup_hash( array $data, string $prefix ): string { + return $prefix . '-' . substr(hash('sha256', wp_json_encode($data)), 0, 12); + } +} + +if ( ! function_exists('wp_json_encode') ) { + function wp_json_encode( $data, $options = 0, $depth = 512 ) { + return json_encode($data, $options, $depth); + } +} + +$workspace = new ArtifactCleanupPlanContractWorkspace(); +$plan = $workspace->workspace_cleanup_plan(array( 'include_worktrees' => false )); + +artifact_cleanup_plan_contract_assert(is_array($plan), 'cleanup plan should return an array'); +artifact_cleanup_plan_contract_assert( + 'studio wp datamachine-code workspace cleanup apply ' === ( $plan['summary']['apply_command'] ?? '' ), + 'cleanup plan summary should expose the authoritative apply command' +); + +$artifact_plan = $plan['plans']['artifact_cleanup'] ?? array(); +artifact_cleanup_plan_contract_assert(! array_key_exists('apply_command', $artifact_plan), 'nested artifact plan should not expose apply_command'); +artifact_cleanup_plan_contract_assert(! array_key_exists('apply_command', $artifact_plan['summary'] ?? array()), 'nested artifact summary should not expose apply_command'); +artifact_cleanup_plan_contract_assert( + 'studio wp datamachine-code workspace cleanup run --mode=artifacts --dry-run --format=json' === ( $artifact_plan['preview_command'] ?? '' ), + 'nested artifact plan should expose preview_command' +); +artifact_cleanup_plan_contract_assert( + ( $artifact_plan['preview_command'] ?? null ) === ( $artifact_plan['rerun_preview_command'] ?? null ), + 'nested artifact plan should expose matching rerun_preview_command' +); +artifact_cleanup_plan_contract_assert( + ( $artifact_plan['summary']['preview_command'] ?? null ) === ( $artifact_plan['summary']['rerun_preview_command'] ?? null ), + 'nested artifact summary should expose matching preview commands' +); + +fwrite(STDOUT, "artifact-cleanup-plan-output-contract ok\n");