diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 7e3eb53..750d020 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1220,6 +1220,46 @@ private function registerAbilities(): void { 'meta' => array( 'show_in_rest' => false ), ) ); + + wp_register_ability( + 'datamachine/workspace-worktree-cleanup-artifacts', + array( + 'label' => 'Cleanup Worktree Artifacts', + 'description' => 'Remove profile-derived, reconstructable artifact directories inside workspace worktrees. Requires a dry-run plan before deletion and revalidates exact paths before applying.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'dry_run' => array( + 'type' => 'boolean', + 'description' => 'If true, return the artifact cleanup plan without deleting anything.', + ), + 'force' => array( + 'type' => 'boolean', + 'description' => 'If true, allow artifact cleanup in dirty or unpushed worktrees. Active plugin/theme symlink targets remain protected.', + ), + 'apply_plan' => array( + 'type' => 'object', + 'description' => 'Decoded artifact cleanup dry-run report to apply after revalidating every worktree and artifact path.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'dry_run' => array( 'type' => 'boolean' ), + 'candidates' => array( 'type' => 'array' ), + 'removed' => array( 'type' => 'array' ), + 'skipped' => array( 'type' => 'array' ), + 'summary' => array( 'type' => 'object' ), + ), + ), + 'execute_callback' => array( self::class, 'worktreeCleanupArtifacts' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); }; if ( doing_action( 'wp_abilities_api_init' ) ) { @@ -1637,6 +1677,25 @@ public static function worktreeReconcileMetadata( array $input ): array|\WP_Erro return $workspace->worktree_reconcile_metadata( $opts ); } + /** + * Remove profile-derived artifacts inside workspace worktrees. + * + * @param array $input Input parameters (dry_run, force, apply_plan). + * @return array + */ + public static function worktreeCleanupArtifacts( array $input ): array|\WP_Error { + $workspace = new Workspace(); + $opts = array( + 'dry_run' => ! empty( $input['dry_run'] ), + 'force' => ! empty( $input['force'] ), + ); + if ( isset( $input['apply_plan'] ) && is_array( $input['apply_plan'] ) ) { + $opts['apply_plan'] = $input['apply_plan']; + } + + return $workspace->worktree_cleanup_artifacts( $opts ); + } + /** * Read git log entries for a workspace repository. * diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index e79dd76..1e707cb 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -1201,6 +1201,10 @@ private function renderGitOperationResult( string $operation, array $result, arr * wp datamachine workspace worktree cleanup --dry-run --format=json > cleanup-plan.json * wp datamachine workspace worktree cleanup --apply-plan=cleanup-plan.json * + * # Review and apply artifact-only cleanup without removing worktrees + * wp datamachine workspace worktree cleanup-artifacts --dry-run --format=json > artifact-plan.json + * wp datamachine workspace worktree cleanup-artifacts --apply-plan=artifact-plan.json + * * # Local-only detection (no GitHub API call) * wp datamachine workspace worktree cleanup --skip-github * @@ -1237,21 +1241,22 @@ public function worktree( array $args, array $assoc_args ): void { $operation = $args[0] ?? ''; if ( '' === $operation ) { - WP_CLI::error( 'Usage: wp datamachine workspace worktree [] [] [--flags]' ); + WP_CLI::error( 'Usage: wp datamachine workspace worktree [] [] [--flags]' ); return; } $ability_name = match ( $operation ) { - 'add' => 'datamachine/workspace-worktree-add', - 'list' => 'datamachine/workspace-worktree-list', - 'remove' => 'datamachine/workspace-worktree-remove', - 'prune' => 'datamachine/workspace-worktree-prune', - 'cleanup' => 'datamachine/workspace-worktree-cleanup', - 'reconcile-metadata' => 'datamachine/workspace-worktree-reconcile-metadata', - 'refresh-context' => 'datamachine/workspace-worktree-refresh-context', - 'finalize' => 'datamachine/workspace-worktree-finalize', + 'add' => 'datamachine/workspace-worktree-add', + 'list' => 'datamachine/workspace-worktree-list', + 'remove' => 'datamachine/workspace-worktree-remove', + 'prune' => 'datamachine/workspace-worktree-prune', + 'cleanup' => 'datamachine/workspace-worktree-cleanup', + 'cleanup-artifacts' => 'datamachine/workspace-worktree-cleanup-artifacts', + 'reconcile-metadata' => 'datamachine/workspace-worktree-reconcile-metadata', + 'refresh-context' => 'datamachine/workspace-worktree-refresh-context', + 'finalize' => 'datamachine/workspace-worktree-finalize', 'mark-cleanup-eligible' => 'datamachine/workspace-worktree-finalize', - default => '', + default => '', }; if ( '' === $ability_name ) { @@ -1366,13 +1371,20 @@ public function worktree( array $args, array $assoc_args ): void { $input['sort'] = trim( (string) $assoc_args['sort'] ); } break; - case 'reconcile-metadata': $input['dry_run'] = ! empty( $assoc_args['dry-run'] ); if ( ! empty( $assoc_args['apply-plan'] ) ) { $input['apply_plan'] = $this->read_worktree_json_plan( (string) $assoc_args['apply-plan'], 'metadata reconciliation' ); } break; + + case 'cleanup-artifacts': + $input['dry_run'] = ! empty( $assoc_args['dry-run'] ); + $input['force'] = ! empty( $assoc_args['force'] ); + if ( ! empty( $assoc_args['apply-plan'] ) ) { + $input['apply_plan'] = $this->read_worktree_cleanup_plan( (string) $assoc_args['apply-plan'] ); + } + break; } $result = $ability->execute( $input ); @@ -1451,11 +1463,14 @@ private function renderWorktreeResult( string $operation, array $result, array $ case 'cleanup': $this->render_worktree_cleanup_result( $result, $assoc_args ); return; - case 'reconcile-metadata': $this->render_worktree_metadata_reconciliation_result( $result, $assoc_args ); return; + case 'cleanup-artifacts': + $this->render_worktree_artifact_cleanup_result( $result, $assoc_args ); + return; + case 'add': WP_CLI::success( $result['message'] ?? 'Worktree created.' ); if ( isset( $result['disk_budget'] ) && is_array( $result['disk_budget'] ) ) { @@ -1944,6 +1959,121 @@ private function render_worktree_metadata_reconciliation_result( array $result, WP_CLI::success( sprintf( 'Wrote metadata for %d worktree(s); %d skipped.', count( $written ), count( $skipped ) ) ); } + /** + * Render artifact-only cleanup output. + * + * @param array $result Artifact cleanup ability result. + * @param array $assoc_args CLI assoc args. + * @return void + */ + private function render_worktree_artifact_cleanup_result( array $result, array $assoc_args ): void { + $format = isset( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'table'; + if ( 'json' === $format ) { + $json = wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); + WP_CLI::log( false === $json ? '{}' : $json ); + return; + } + + $candidates = (array) ( $result['candidates'] ?? array() ); + $removed = (array) ( $result['removed'] ?? array() ); + $skipped = (array) ( $result['skipped'] ?? array() ); + $summary = (array) ( $result['summary'] ?? array() ); + $dry_run = ! empty( $result['dry_run'] ); + + if ( empty( $candidates ) && empty( $removed ) && empty( $skipped ) ) { + WP_CLI::log( 'No worktree artifacts found.' ); + return; + } + + WP_CLI::log( 'Artifact cleanup summary:' ); + $this->format_items( + array( + array( + 'metric' => 'would_remove_artifacts', + 'count' => (int) ( $summary['would_remove_artifacts'] ?? 0 ), + ), + array( + 'metric' => 'removed_artifacts', + 'count' => (int) ( $summary['removed_artifacts'] ?? 0 ), + ), + array( + 'metric' => 'skipped_worktrees', + 'count' => (int) ( $summary['skipped'] ?? count( $skipped ) ), + ), + array( + 'metric' => 'artifact_size', + 'count' => $this->format_bytes( $summary['artifact_size_bytes'] ?? null ), + ), + ), + array( 'metric', 'count' ), + array( 'format' => 'table' ), + 'metric' + ); + + if ( ! empty( $candidates ) ) { + WP_CLI::log( '' ); + WP_CLI::log( $dry_run ? 'Would remove artifacts:' : 'Artifact candidates:' ); + $this->format_items( $this->flatten_artifact_cleanup_rows( $candidates ), array( 'handle', 'repo', 'branch', 'artifact', 'size', 'path' ), array( 'format' => 'table' ), 'handle' ); + } + + if ( ! empty( $removed ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Removed artifacts:' ); + $this->format_items( $this->flatten_artifact_cleanup_rows( $removed ), array( 'handle', 'repo', 'branch', 'artifact', 'size', 'path' ), array( 'format' => 'table' ), 'handle' ); + } + + if ( ! empty( $skipped ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Skipped worktrees:' ); + $rows = array_map( + fn( $row ) => array( + 'handle' => $row['handle'] ?? '', + 'repo' => $row['repo'] ?? '', + 'branch' => $row['branch'] ?? '', + 'artifacts' => count( (array) ( $row['artifacts'] ?? array() ) ), + 'reason_code' => $row['reason_code'] ?? '', + 'reason' => $row['reason'] ?? '', + ), + $skipped + ); + $this->format_items( $rows, array( 'handle', 'repo', 'branch', 'artifacts', 'reason_code', 'reason' ), array( 'format' => 'table' ), 'handle' ); + } + + WP_CLI::log( '' ); + if ( $dry_run ) { + WP_CLI::success( sprintf( '%d artifact(s) would be removed. Save JSON and re-run with --apply-plan= to apply.', (int) ( $summary['would_remove_artifacts'] ?? 0 ) ) ); + return; + } + WP_CLI::success( sprintf( 'Removed %d artifact(s); %d worktree(s) skipped.', (int) ( $summary['removed_artifacts'] ?? 0 ), count( $skipped ) ) ); + } + + /** + * Flatten artifact cleanup worktree rows into table rows. + * + * @param array $rows Worktree artifact rows. + * @return array> + */ + private function flatten_artifact_cleanup_rows( array $rows ): array { + $flat = array(); + foreach ( $rows as $row ) { + foreach ( (array) ( $row['artifacts'] ?? array() ) as $artifact ) { + if ( ! is_array( $artifact ) ) { + continue; + } + $flat[] = array( + 'handle' => $row['handle'] ?? '', + 'repo' => $row['repo'] ?? '', + 'branch' => $row['branch'] ?? '', + 'artifact' => $artifact['path'] ?? '', + 'size' => $this->format_bytes( $artifact['size_bytes'] ?? null ), + 'path' => rtrim( (string) ( $row['path'] ?? '' ), '/' ) . '/' . ltrim( (string) ( $artifact['path'] ?? '' ), '/' ), + ); + } + } + + return $flat; + } + /** * Read and decode a cleanup plan file for --apply-plan. * diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 630ffc6..5547f9c 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -2921,6 +2921,103 @@ private function build_worktree_metadata_reconciliation_summary( int $inspected, ); } + /** + * Cleanup reconstructable artifact directories inside workspace worktrees. + * + * Unlike whole-worktree cleanup, this intentionally does not require a merge + * signal: clean active worktrees can safely shed build outputs. Applying is + * plan-only so every destructive run revalidates the exact worktree and + * profile-derived artifact paths from a reviewed dry-run. + * + * @param array $opts Cleanup options (dry_run, force, apply_plan). + * @return array|\WP_Error + */ + public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_Error { + $dry_run = ! empty( $opts['dry_run'] ); + $force = ! empty( $opts['force'] ); + $apply_plan = isset( $opts['apply_plan'] ) && is_array( $opts['apply_plan'] ) ? $opts['apply_plan'] : null; + + if ( null !== $apply_plan ) { + $dry_run = false; + } + + if ( ! $dry_run && null === $apply_plan ) { + return new \WP_Error( 'artifact_cleanup_plan_required', 'Artifact cleanup requires --dry-run first and --apply-plan= to delete.', array( 'status' => 400 ) ); + } + + $plan = $this->build_worktree_artifact_cleanup_plan( $force ); + if ( $plan instanceof \WP_Error ) { + return $plan; + } + + $candidates = $plan['candidates']; + $skipped = $plan['skipped']; + + if ( null !== $apply_plan ) { + $planned = $this->extract_worktree_artifact_cleanup_plan_candidates( $apply_plan ); + if ( $planned instanceof \WP_Error ) { + return $planned; + } + + $scoped = $this->scope_worktree_artifact_cleanup_to_plan( $planned, $candidates, $skipped ); + $candidates = $scoped['candidates']; + $skipped = $scoped['skipped']; + } + + $summary = $this->build_worktree_artifact_cleanup_summary( $candidates, array(), $skipped ); + + if ( $dry_run ) { + return array( + 'success' => true, + 'dry_run' => true, + 'candidates' => $candidates, + 'removed' => array(), + 'skipped' => $skipped, + 'summary' => $summary, + ); + } + + $removed = array(); + foreach ( $candidates as $candidate ) { + $removed_artifacts = array(); + $failed = false; + + foreach ( (array) ( $candidate['artifacts'] ?? array() ) as $artifact ) { + $remove = $this->remove_worktree_artifact_path( (string) $candidate['path'], (string) ( $artifact['path'] ?? '' ) ); + if ( $remove instanceof \WP_Error ) { + $skipped[] = array( + 'handle' => $candidate['handle'] ?? '', + 'repo' => $candidate['repo'] ?? '', + 'branch' => $candidate['branch'] ?? '', + 'path' => $candidate['path'] ?? '', + 'reason_code' => 'artifact_remove_failed', + 'reason' => sprintf( 'failed to remove artifact %s: %s', (string) ( $artifact['path'] ?? '' ), $remove->get_error_message() ), + 'artifacts' => array( $artifact ), + ); + $failed = true; + break; + } + + $removed_artifacts[] = $artifact; + } + + if ( $failed ) { + continue; + } + + $removed[] = array_merge( $candidate, array( 'artifacts' => $removed_artifacts ) ); + } + + return array( + 'success' => true, + 'dry_run' => false, + 'candidates' => $candidates, + 'removed' => $removed, + 'skipped' => $skipped, + 'summary' => $this->build_worktree_artifact_cleanup_summary( $candidates, $removed, $skipped ), + ); + } + /** * Build a bounded cleanup review from top-level inventory only. * @@ -3062,6 +3159,371 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so ); } + /** + * Build current artifact cleanup candidates and safety skips. + * + * @param bool $force Whether to allow dirty/unpushed worktrees. + * @return array{candidates: array, skipped: array}|\WP_Error + */ + private function build_worktree_artifact_cleanup_plan( bool $force ): array|\WP_Error { + $listing = $this->worktree_list(); + if ( $listing instanceof \WP_Error ) { + return $listing; + } + + $candidates = array(); + $skipped = array(); + + foreach ( (array) ( $listing['worktrees'] ?? array() ) as $wt ) { + if ( ! empty( $wt['is_primary'] ) ) { + continue; + } + + $handle = (string) ( $wt['handle'] ?? '?' ); + $repo = (string) ( $wt['repo'] ?? '' ); + $branch = (string) ( $wt['branch'] ?? '' ); + $wt_path = (string) ( $wt['path'] ?? '' ); + $artifacts = array_values( array_filter( (array) ( $wt['artifacts'] ?? array() ), fn( $artifact ) => is_array( $artifact ) ) ); + $base_row = array( + 'handle' => $handle, + 'repo' => $repo, + 'branch' => $branch, + 'path' => $wt_path, + 'created_at' => $wt['created_at'] ?? null, + ); + + if ( empty( $artifacts ) ) { + continue; + } + + if ( ! empty( $wt['external'] ) ) { + $skipped[] = array_merge( $base_row, array( + 'reason_code' => 'external_worktree', + 'reason' => 'external worktree (outside workspace) - artifact cleanup only operates inside the DMC workspace', + 'artifacts' => $artifacts, + ) ); + continue; + } + + if ( '' === $repo || '' === $branch || '' === $wt_path ) { + $skipped[] = array_merge( $base_row, array( + 'reason_code' => 'missing_metadata', + 'reason' => 'missing repo/branch/path', + 'artifacts' => $artifacts, + ) ); + continue; + } + + if ( $this->is_active_studio_symlink_target( $wt_path ) ) { + $skipped[] = array_merge( $base_row, array( + 'reason_code' => 'active_symlink_target', + 'reason' => 'worktree is the target of a wp-content plugin/theme symlink - leaving artifacts in place', + 'artifacts' => $artifacts, + ) ); + continue; + } + + $dirty_count = (int) ( $wt['dirty'] ?? 0 ); + if ( $dirty_count > 0 && ! $force ) { + $skipped[] = array_merge( $base_row, array( + 'reason_code' => 'dirty_worktree', + 'reason' => sprintf( 'working tree dirty (%d files) - pass force=true to override artifact cleanup only', $dirty_count ), + 'dirty' => $dirty_count, + 'artifacts' => $artifacts, + ) ); + continue; + } + + $unpushed = $this->count_unpushed_commits( $wt_path ); + if ( $unpushed > 0 && ! $force ) { + $skipped[] = array_merge( $base_row, array( + 'reason_code' => 'unpushed_commits', + 'reason' => sprintf( '%d unpushed commit(s) - pass force=true to override artifact cleanup only', $unpushed ), + 'unpushed' => $unpushed, + 'artifacts' => $artifacts, + ) ); + continue; + } + + $candidates[] = array_merge( $base_row, array( + 'artifacts' => $artifacts, + 'artifact_count' => count( $artifacts ), + 'artifact_size_bytes' => array_sum( array_map( fn( $artifact ) => (int) ( $artifact['size_bytes'] ?? 0 ), $artifacts ) ), + 'reason_code' => 'profile_artifacts', + 'reason' => 'profile-derived reconstructable artifacts can be removed', + ) ); + } + + return array( + 'candidates' => $candidates, + 'skipped' => $skipped, + ); + } + + /** + * Build stable artifact cleanup counts. + * + * @param array $candidates Candidate rows. + * @param array $removed Removed rows. + * @param array $skipped Skipped rows. + * @return array + */ + private function build_worktree_artifact_cleanup_summary( array $candidates, array $removed, array $skipped ): array { + $skipped_by_reason = array(); + $artifact_by_repo = array(); + $would_bytes = 0; + $removed_bytes = 0; + $would_count = 0; + $removed_count = 0; + + foreach ( $skipped as $row ) { + $code = (string) ( $row['reason_code'] ?? 'unknown' ); + $skipped_by_reason[ $code ] = ( $skipped_by_reason[ $code ] ?? 0 ) + 1; + } + + foreach ( $candidates as $row ) { + $repo = (string) ( $row['repo'] ?? 'unknown' ); + foreach ( (array) ( $row['artifacts'] ?? array() ) as $artifact ) { + $bytes = (int) ( is_array( $artifact ) ? ( $artifact['size_bytes'] ?? 0 ) : 0 ); + $would_bytes += max( 0, $bytes ); + ++$would_count; + $artifact_by_repo[ $repo ] = ( $artifact_by_repo[ $repo ] ?? 0 ) + max( 0, $bytes ); + } + } + + foreach ( $removed as $row ) { + foreach ( (array) ( $row['artifacts'] ?? array() ) as $artifact ) { + $removed_bytes += max( 0, (int) ( is_array( $artifact ) ? ( $artifact['size_bytes'] ?? 0 ) : 0 ) ); + ++$removed_count; + } + } + + ksort( $skipped_by_reason ); + arsort( $artifact_by_repo ); + + return array( + 'would_remove_worktrees' => count( $candidates ), + 'would_remove_artifacts' => $would_count, + 'removed_worktrees' => count( $removed ), + 'removed_artifacts' => $removed_count, + 'skipped' => count( $skipped ), + 'skipped_by_reason' => $skipped_by_reason, + 'artifact_count' => 0 === $removed_count ? $would_count : $removed_count, + 'artifact_size_bytes' => 0 === $removed_count ? $would_bytes : $removed_bytes, + 'removed_size_bytes' => $removed_bytes, + 'artifact_size_by_repo' => $artifact_by_repo, + ); + } + + /** + * Extract artifact cleanup candidates from a dry-run JSON report. + * + * @param array $plan Decoded artifact cleanup report. + * @return array|\WP_Error + */ + private function extract_worktree_artifact_cleanup_plan_candidates( array $plan ): array|\WP_Error { + $candidates = $plan['candidates'] ?? null; + if ( ! is_array( $candidates ) ) { + return new \WP_Error( 'invalid_artifact_cleanup_plan', 'Artifact cleanup plan must contain a candidates array.', array( 'status' => 400 ) ); + } + + foreach ( $candidates as $index => $row ) { + if ( ! is_array( $row ) ) { + return new \WP_Error( 'invalid_artifact_cleanup_plan', sprintf( 'Artifact cleanup candidate #%d is not an object.', (int) $index ), array( 'status' => 400 ) ); + } + + foreach ( array( 'handle', 'repo', 'branch', 'path', 'artifacts' ) as $field ) { + $value = $row[ $field ] ?? null; + if ( 'artifacts' === $field ? ! is_array( $value ) || array() === $value : '' === trim( (string) $value ) ) { + return new \WP_Error( 'invalid_artifact_cleanup_plan', sprintf( 'Artifact cleanup candidate #%d is missing %s.', (int) $index, $field ), array( 'status' => 400 ) ); + } + } + + foreach ( $row['artifacts'] as $artifact_index => $artifact ) { + if ( ! is_array( $artifact ) || '' === trim( (string) ( $artifact['path'] ?? '' ) ) ) { + return new \WP_Error( 'invalid_artifact_cleanup_plan', sprintf( 'Artifact cleanup candidate #%d artifact #%d is missing path.', (int) $index, (int) $artifact_index ), array( 'status' => 400 ) ); + } + } + } + + return array_values( $candidates ); + } + + /** + * Restrict current artifact cleanup candidates to a reviewed plan. + * + * @param array $planned_candidates Planned rows. + * @param array $current_candidates Fresh candidates. + * @param array $current_skipped Fresh skips. + * @return array{candidates: array, skipped: array} + */ + private function scope_worktree_artifact_cleanup_to_plan( array $planned_candidates, array $current_candidates, array $current_skipped ): array { + $current_by_handle = array(); + foreach ( $current_candidates as $row ) { + $current_by_handle[ (string) ( $row['handle'] ?? '' ) ] = $row; + } + + $skipped_by_handle = array(); + foreach ( $current_skipped as $row ) { + $handle = (string) ( $row['handle'] ?? '' ); + if ( '' !== $handle && ! isset( $skipped_by_handle[ $handle ] ) ) { + $skipped_by_handle[ $handle ] = $row; + } + } + + $scoped_candidates = array(); + $scoped_skipped = array(); + + foreach ( $planned_candidates as $plan_row ) { + $handle = (string) ( $plan_row['handle'] ?? '' ); + $current = $current_by_handle[ $handle ] ?? null; + if ( null === $current ) { + $skip = $skipped_by_handle[ $handle ] ?? array( + 'handle' => $handle, + 'repo' => (string) ( $plan_row['repo'] ?? '' ), + 'branch' => (string) ( $plan_row['branch'] ?? '' ), + 'path' => (string) ( $plan_row['path'] ?? '' ), + 'reason_code' => 'artifact_plan_not_current', + 'reason' => 'planned artifact cleanup row is no longer a current safe candidate', + ); + $skip['planned_artifacts'] = $plan_row['artifacts'] ?? array(); + $scoped_skipped[] = $skip; + continue; + } + + $mismatches = array(); + foreach ( array( 'repo', 'branch', 'path' ) as $field ) { + if ( (string) ( $plan_row[ $field ] ?? '' ) !== (string) ( $current[ $field ] ?? '' ) ) { + $mismatches[] = $field; + } + } + + $current_artifacts = array(); + foreach ( (array) ( $current['artifacts'] ?? array() ) as $artifact ) { + if ( is_array( $artifact ) ) { + $current_artifacts[ (string) ( $artifact['path'] ?? '' ) ] = $artifact; + } + } + + $artifacts = array(); + foreach ( (array) ( $plan_row['artifacts'] ?? array() ) as $planned_artifact ) { + $relative = (string) ( is_array( $planned_artifact ) ? ( $planned_artifact['path'] ?? '' ) : '' ); + if ( '' === $relative || ! isset( $current_artifacts[ $relative ] ) ) { + $mismatches[] = 'artifact:' . $relative; + continue; + } + $artifacts[] = $current_artifacts[ $relative ]; + } + + if ( array() !== $mismatches ) { + $scoped_skipped[] = array( + 'handle' => $handle, + 'repo' => (string) ( $current['repo'] ?? $plan_row['repo'] ?? '' ), + 'branch' => (string) ( $current['branch'] ?? $plan_row['branch'] ?? '' ), + 'path' => (string) ( $current['path'] ?? $plan_row['path'] ?? '' ), + 'reason_code' => 'artifact_plan_mismatch', + 'reason' => 'planned artifact cleanup row no longer matches current state: ' . implode( ', ', $mismatches ), + 'planned_artifacts' => $plan_row['artifacts'] ?? array(), + 'artifacts' => $current['artifacts'] ?? array(), + ); + continue; + } + + $scoped_candidates[] = array_merge( $current, array( 'artifacts' => $artifacts ) ); + } + + return array( + 'candidates' => $scoped_candidates, + 'skipped' => $scoped_skipped, + ); + } + + /** + * Remove one artifact directory after exact profile/path revalidation. + * + * @param string $worktree_path Worktree root path. + * @param string $relative Profile-relative artifact path. + * @return true|\WP_Error + */ + private function remove_worktree_artifact_path( string $worktree_path, string $relative ): true|\WP_Error { + $relative = trim( $relative, '/' ); + if ( '' === $relative || str_contains( $relative, '..' ) ) { + return new \WP_Error( 'invalid_artifact_path', sprintf( 'Invalid artifact path: %s', $relative ), array( 'status' => 400 ) ); + } + + if ( '' === $worktree_path || ! is_dir( $worktree_path ) ) { + return new \WP_Error( 'worktree_path_missing', sprintf( 'Worktree path does not exist: %s', $worktree_path ), array( 'status' => 404 ) ); + } + + $worktree_validation = $this->validate_containment( $worktree_path, $this->workspace_path ); + if ( ! $worktree_validation['valid'] ) { + return new \WP_Error( 'path_outside_workspace', sprintf( 'Refusing artifact cleanup outside workspace: %s', $worktree_validation['message'] ?? '' ), array( 'status' => 403 ) ); + } + $worktree_real = (string) ( $worktree_validation['real_path'] ?? '' ); + if ( '' === $worktree_real ) { + return new \WP_Error( 'path_resolution_failed', sprintf( 'Unable to resolve worktree path: %s', $worktree_path ), array( 'status' => 403 ) ); + } + + $artifact_path = rtrim( $worktree_real, '/' ) . '/' . $relative; + if ( ! is_dir( $artifact_path ) ) { + return new \WP_Error( 'artifact_path_missing', sprintf( 'Artifact path does not exist: %s', $relative ), array( 'status' => 404 ) ); + } + + $artifact_validation = $this->validate_containment( $artifact_path, $worktree_real ); + $artifact_real = (string) ( $artifact_validation['real_path'] ?? '' ); + if ( ! $artifact_validation['valid'] || '' === $artifact_real || $artifact_real === $worktree_real ) { + return new \WP_Error( 'artifact_path_outside_worktree', sprintf( 'Refusing artifact cleanup for %s: %s', $relative, $artifact_validation['message'] ?? '' ), array( 'status' => 403 ) ); + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( sprintf( 'rm -rf %s 2>&1', escapeshellarg( $artifact_real ) ) ); + + return true; + } + + /** + * Check whether a worktree is currently targeted by a Studio plugin/theme symlink. + * + * @param string $worktree_path Worktree path. + * @return bool True when a wp-content plugin/theme symlink points at the path. + */ + private function is_active_studio_symlink_target( string $worktree_path ): bool { + $worktree_real = realpath( $worktree_path ); + if ( false === $worktree_real || ! defined( 'ABSPATH' ) ) { + return false; + } + + foreach ( array( 'wp-content/plugins', 'wp-content/themes' ) as $relative_dir ) { + $dir = rtrim( ABSPATH, '/' ) . '/' . $relative_dir; + if ( ! is_dir( $dir ) ) { + continue; + } + + $entries = scandir( $dir ); + if ( false === $entries ) { + continue; + } + + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry ) { + continue; + } + + $path = $dir . '/' . $entry; + if ( ! is_link( $path ) ) { + continue; + } + + $target_real = realpath( $path ); + if ( false !== $target_real && rtrim( $target_real, '/' ) === rtrim( $worktree_real, '/' ) ) { + return true; + } + } + } + + return false; + } + /** * Build stable cleanup counts for CLI and automation consumers. * diff --git a/tests/smoke-worktree-cleanup-artifacts.php b/tests/smoke-worktree-cleanup-artifacts.php new file mode 100644 index 0000000..2a388d1 --- /dev/null +++ b/tests/smoke-worktree-cleanup-artifacts.php @@ -0,0 +1,230 @@ + array() ); + } + } + } +} + +namespace { + $tmp = sys_get_temp_dir() . '/dmc-artifact-cleanup-smoke-' . bin2hex( random_bytes( 4 ) ); + $site_tmp = $tmp . '-site'; + mkdir( $tmp, 0755, true ); + mkdir( $site_tmp . '/wp-content/plugins', 0755, true ); + mkdir( $site_tmp . '/wp-content/themes', 0755, true ); + + register_shutdown_function( function () use ( $tmp, $site_tmp ) { + foreach ( array( $tmp, $site_tmp ) as $path ) { + if ( is_dir( $path ) ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( 'rm -rf ' . escapeshellarg( $path ) ); + } + } + } ); + + if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', $site_tmp . '/' ); + } + if ( ! defined( 'DATAMACHINE_WORKSPACE_PATH' ) ) { + define( 'DATAMACHINE_WORKSPACE_PATH', realpath( $tmp ) ?: $tmp ); + } + + if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + public string $code; + public string $message; + public array $data; + public function __construct( $code = '', $message = '', $data = array() ) { + $this->code = (string) $code; + $this->message = (string) $message; + $this->data = (array) $data; + } + public function get_error_message(): string { + return $this->message; + } + } + } + + if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $thing ): bool { + return $thing instanceof \WP_Error; + } + } + + if ( ! function_exists( 'wp_mkdir_p' ) ) { + function wp_mkdir_p( string $path ): bool { + return is_dir( $path ) || mkdir( $path, 0755, true ); + } + } + + if ( ! function_exists( 'get_option' ) ) { + function get_option( string $name, $default_value = false ) { + global $datamachine_code_test_options; + return $datamachine_code_test_options[ $name ] ?? $default_value; + } + } + + if ( ! function_exists( 'update_option' ) ) { + function update_option( string $name, $value, $autoload = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + global $datamachine_code_test_options; + $datamachine_code_test_options[ $name ] = $value; + return true; + } + } + + if ( ! function_exists( 'apply_filters' ) ) { + function apply_filters( string $hook_name, $value ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + return $value; + } + } + + require __DIR__ . '/../inc/Support/GitHubRemote.php'; + require __DIR__ . '/../inc/Support/GitRunner.php'; + require __DIR__ . '/../inc/Support/PathSecurity.php'; + require __DIR__ . '/../inc/Workspace/WorkspaceMutationLock.php'; + require __DIR__ . '/../inc/Workspace/WorktreeDiskBudget.php'; + require __DIR__ . '/../inc/Workspace/WorktreeContextInjector.php'; + require __DIR__ . '/../inc/Workspace/Workspace.php'; + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( 'git --version 2>&1', $_gv, $gv_exit ); + if ( 0 !== $gv_exit ) { + echo "SKIP: git not available\n"; + exit( 0 ); + } + + $failures = 0; + $total = 0; + $datamachine_code_test_options = array(); + + $assert = function ( $expected, $actual, string $message ) use ( &$failures, &$total ): void { + ++$total; + if ( $expected === $actual ) { + echo " [PASS] {$message}\n"; + return; + } + ++$failures; + echo " [FAIL] {$message}\n"; + echo ' expected: ' . var_export( $expected, true ) . "\n"; + echo ' actual: ' . var_export( $actual, true ) . "\n"; + }; + + $run = function ( string $cmd, string $cwd = '' ): void { + $full = '' === $cwd ? $cmd : sprintf( 'cd %s && %s', escapeshellarg( $cwd ), $cmd ); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( $full . ' 2>&1', $out, $rc ); + if ( 0 !== $rc ) { + throw new RuntimeException( "Command failed: {$full}\n" . implode( "\n", $out ) ); + } + }; + + $remote = $tmp . '/remote.git'; + $run( sprintf( 'git init --bare %s', escapeshellarg( $remote ) ) ); + + $primary = $tmp . '/demo'; + $run( sprintf( 'git clone %s %s', escapeshellarg( $remote ), escapeshellarg( $primary ) ) ); + $run( 'git config user.email test@example.com', $primary ); + $run( 'git config user.name test', $primary ); + file_put_contents( $primary . '/README.md', "demo\n" ); + file_put_contents( $primary . '/Cargo.toml', "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n" ); + file_put_contents( $primary . '/.gitignore', "target/\n" ); + $run( 'git add README.md Cargo.toml .gitignore && git commit -m init', $primary ); + $run( 'git branch -M main', $primary ); + $run( 'git push -u origin main', $primary ); + + $make_branch = function ( string $branch ) use ( $primary, $run ): void { + $run( sprintf( 'git checkout -b %s', escapeshellarg( $branch ) ), $primary ); + file_put_contents( $primary . '/' . $branch . '.txt', $branch ); + $run( sprintf( 'git add . && git commit -m %s', escapeshellarg( 'work ' . $branch ) ), $primary ); + $run( sprintf( 'git push -u origin %s', escapeshellarg( $branch ) ), $primary ); + $run( 'git checkout main', $primary ); + }; + + foreach ( array( 'clean', 'dirty', 'unpushed', 'active' ) as $branch ) { + $make_branch( $branch ); + $run( sprintf( 'git worktree add %s %s', escapeshellarg( $tmp . '/demo@' . $branch ), escapeshellarg( $branch ) ), $primary ); + mkdir( $tmp . '/demo@' . $branch . '/target', 0755, true ); + file_put_contents( $tmp . '/demo@' . $branch . '/target/artifact.bin', str_repeat( $branch, 128 ) ); + } + + file_put_contents( $tmp . '/demo@dirty/scratch.txt', 'dirty' ); + file_put_contents( $tmp . '/demo@unpushed/local.txt', 'local' ); + $run( 'git add local.txt && git commit -m local', $tmp . '/demo@unpushed' ); + symlink( $tmp . '/demo@active', $site_tmp . '/wp-content/plugins/demo-active' ); + + $workspace = new \DataMachineCode\Workspace\Workspace(); + + echo "=== smoke-worktree-cleanup-artifacts ===\n"; + + $plan = $workspace->worktree_cleanup_artifacts( array( 'dry_run' => true ) ); + $assert( false, is_wp_error( $plan ), 'dry-run returns a plan' ); + $assert( true, (bool) ( $plan['dry_run'] ?? false ), 'dry-run flag is true' ); + $assert( 1, count( $plan['candidates'] ?? array() ), 'only clean non-active worktree is candidate by default' ); + $assert( 'demo@clean', $plan['candidates'][0]['handle'] ?? '', 'clean worktree is candidate' ); + $assert( 'target', $plan['candidates'][0]['artifacts'][0]['path'] ?? '', 'candidate artifact path comes from profile' ); + $assert( true, is_dir( $tmp . '/demo@clean/target' ), 'dry-run does not delete target directory' ); + + $skip_reasons = array_column( $plan['skipped'] ?? array(), 'reason_code', 'handle' ); + $assert( 'dirty_worktree', $skip_reasons['demo@dirty'] ?? '', 'dirty worktree is protected' ); + $assert( 'unpushed_commits', $skip_reasons['demo@unpushed'] ?? '', 'unpushed worktree is protected' ); + $assert( 'active_symlink_target', $skip_reasons['demo@active'] ?? '', 'active plugin symlink target is protected' ); + + $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' ); + + $source_plan = $plan; + $source_plan['candidates'][0]['artifacts'] = array( array( 'path' => 'README.md', 'size_bytes' => 4 ) ); + $source_apply = $workspace->worktree_cleanup_artifacts( array( 'apply_plan' => $source_plan ) ); + $assert( false, is_wp_error( $source_apply ), 'source-file-shaped artifact plan returns a report, not deletion' ); + $assert( 'artifact_plan_mismatch', $source_apply['skipped'][0]['reason_code'] ?? '', 'source-file path is rejected by profile revalidation' ); + $assert( true, is_file( $tmp . '/demo@clean/README.md' ), 'source file remains after mismatched plan' ); + $assert( true, is_dir( $tmp . '/demo@clean/target' ), 'real artifact remains after mismatched plan' ); + + $apply = $workspace->worktree_cleanup_artifacts( array( 'apply_plan' => $plan ) ); + $assert( false, is_wp_error( $apply ), 'apply-plan returns report' ); + $assert( false, (bool) ( $apply['dry_run'] ?? true ), 'apply-plan is destructive mode' ); + $assert( 1, (int) ( $apply['summary']['removed_artifacts'] ?? 0 ), 'apply-plan reports removed artifact count' ); + $assert( false, is_dir( $tmp . '/demo@clean/target' ), 'apply-plan removes artifact directory' ); + $assert( true, is_dir( $tmp . '/demo@clean' ), 'apply-plan leaves worktree directory in place' ); + + $force_plan = $workspace->worktree_cleanup_artifacts( array( 'dry_run' => true, 'force' => true ) ); + $force_handles = array_column( $force_plan['candidates'] ?? array(), 'handle' ); + $assert( true, in_array( 'demo@dirty', $force_handles, true ), 'force permits dirty artifact candidate' ); + $assert( true, in_array( 'demo@unpushed', $force_handles, true ), 'force permits unpushed artifact candidate' ); + $force_skip_reasons = array_column( $force_plan['skipped'] ?? array(), 'reason_code', 'handle' ); + $assert( 'active_symlink_target', $force_skip_reasons['demo@active'] ?? '', 'force still protects active symlink target' ); + + if ( $failures > 0 ) { + echo "\nFAILURES: {$failures}/{$total}\n"; + exit( 1 ); + } + + echo "\nAll {$total} artifact cleanup smoke assertions passed.\n"; +} diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index b57d072..b69c745 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -156,6 +156,44 @@ public function execute( array $input ): array { } } + class FakeArtifactCleanupAbility { + public array $last_input = array(); + + public function execute( array $input ): array { + $this->last_input = $input; + return array( + 'success' => true, + 'dry_run' => ! empty( $input['dry_run'] ), + 'candidates' => array( + array( + 'handle' => 'repo@old', + 'repo' => 'repo', + 'branch' => 'old', + 'path' => '/workspace/repo@old', + 'artifacts' => array( array( 'path' => 'target', 'size_bytes' => 1024 ) ), + ), + ), + 'removed' => array(), + 'skipped' => array( + array( + 'handle' => 'repo@active', + 'repo' => 'repo', + 'branch' => 'active', + 'artifacts' => array( array( 'path' => 'target', 'size_bytes' => 2048 ) ), + 'reason_code' => 'active_symlink_target', + 'reason' => 'worktree is an active symlink target', + ), + ), + 'summary' => array( + 'would_remove_artifacts' => 1, + 'removed_artifacts' => 0, + 'skipped' => 1, + 'artifact_size_bytes' => 1024, + ), + ); + } + } + class FakeListAbility { public function execute( array $input ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found return array( @@ -207,10 +245,12 @@ public function execute( array $input ): array { // phpcs:ignore Generic.CodeAna echo "=== smoke-worktree-cleanup-cli ===\n"; $ability = new FakeCleanupAbility(); + $artifact_ability = new FakeArtifactCleanupAbility(); $list_ability = new FakeListAbility(); $GLOBALS['__abilities'] = array( - 'datamachine/workspace-worktree-cleanup' => $ability, - 'datamachine/workspace-worktree-list' => $list_ability, + 'datamachine/workspace-worktree-cleanup' => $ability, + 'datamachine/workspace-worktree-cleanup-artifacts' => $artifact_ability, + 'datamachine/workspace-worktree-list' => $list_ability, ); $command = new \DataMachineCode\Cli\Commands\WorkspaceCommand(); $doc_comment = ( new ReflectionMethod( $command, 'worktree' ) )->getDocComment() ?: ''; @@ -322,5 +362,24 @@ public function execute( array $input ): array { // phpcs:ignore Generic.CodeAna $command->worktree( array( 'cleanup' ), array( 'dry-run' => true, 'inventory-only' => true, 'skip-github' => true, 'format' => 'json' ) ); datamachine_code_cleanup_assert( true === ( $ability->last_input['inventory_only'] ?? null ), '--inventory-only forwards to cleanup ability' ); + echo "\n[9] cleanup-artifacts forwards plan-first flags and renders separately\n"; + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree( array( 'cleanup-artifacts' ), array( 'dry-run' => true, 'format' => 'json' ) ); + 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' ); + + $artifact_plan_file = sys_get_temp_dir() . '/dmc-artifact-cleanup-plan-' . bin2hex( random_bytes( 3 ) ) . '.json'; + file_put_contents( $artifact_plan_file, wp_json_encode( $artifact_json ) ); + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree( array( 'cleanup-artifacts' ), array( 'apply-plan' => $artifact_plan_file, 'force' => true ) ); + datamachine_code_cleanup_assert( false === ( $artifact_ability->last_input['dry_run'] ?? null ), 'cleanup-artifacts apply-plan enters apply mode' ); + datamachine_code_cleanup_assert( true === ( $artifact_ability->last_input['force'] ?? null ), 'cleanup-artifacts forwards explicit force for dirty/unpushed artifacts' ); + datamachine_code_cleanup_assert( 'repo@old' === ( $artifact_ability->last_input['apply_plan']['candidates'][0]['handle'] ?? '' ), 'cleanup-artifacts forwards decoded apply plan' ); + datamachine_code_cleanup_assert( 'Artifact cleanup summary:' === ( WP_CLI::$logs[0] ?? '' ), 'cleanup-artifacts human output uses artifact-specific summary' ); + unlink( $artifact_plan_file ); + echo "\nAll worktree cleanup CLI smoke tests passed.\n"; }