From bd789ff69455cbde77076b79650cbfb8f37f8c87 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 29 Apr 2026 19:14:46 -0400 Subject: [PATCH] feat(workspace): add hygiene report --- data-machine-code.php | 20 +- inc/Abilities/WorkspaceAbilities.php | 68 +++++ inc/Cli/Commands/WorkspaceCommand.php | 188 ++++++++++++ inc/Tasks/WorkspaceHygieneReportTask.php | 118 +++++++ inc/Workspace/Workspace.php | 371 +++++++++++++++++++++++ tests/smoke-workspace-hygiene-cli.php | 147 +++++++++ tests/smoke-workspace-hygiene-task.php | 111 +++++++ tests/smoke-worktree-finalizer-cli.php | 2 +- 8 files changed, 1021 insertions(+), 4 deletions(-) create mode 100644 inc/Tasks/WorkspaceHygieneReportTask.php create mode 100644 tests/smoke-workspace-hygiene-cli.php create mode 100644 tests/smoke-workspace-hygiene-task.php diff --git a/data-machine-code.php b/data-machine-code.php index ab2b8d7..704c267 100644 --- a/data-machine-code.php +++ b/data-machine-code.php @@ -206,8 +206,9 @@ function datamachine_code_load_chat_tools() { * Register system tasks. */ add_filter( 'datamachine_tasks', function ( array $tasks ): array { - $tasks['github_create_issue'] = \DataMachineCode\Tasks\GitHubIssueTask::class; - $tasks['worktree_cleanup'] = \DataMachineCode\Tasks\WorktreeCleanupTask::class; + $tasks['github_create_issue'] = \DataMachineCode\Tasks\GitHubIssueTask::class; + $tasks['worktree_cleanup'] = \DataMachineCode\Tasks\WorktreeCleanupTask::class; + $tasks['workspace_hygiene_report'] = \DataMachineCode\Tasks\WorkspaceHygieneReportTask::class; return $tasks; } ); @@ -224,7 +225,7 @@ function datamachine_code_load_chat_tools() { * @see https://github.com/Extra-Chill/data-machine/pull/1117 */ add_filter( 'datamachine_recurring_schedules', function ( array $schedules ): array { - $schedules['worktree_cleanup'] = array( + $schedules['worktree_cleanup'] = array( 'task_type' => 'worktree_cleanup', 'interval' => 'daily', 'enabled_setting' => \DataMachineCode\Tasks\WorktreeCleanupTask::SETTING_KEY, @@ -232,6 +233,19 @@ function datamachine_code_load_chat_tools() { 'label' => 'Daily — cleans up merged worktrees', 'task_params' => array( 'source' => 'recurring_schedule' ), ); + $schedules['workspace_hygiene_report'] = array( + 'task_type' => 'workspace_hygiene_report', + 'interval' => 'weekly', + 'enabled_setting' => \DataMachineCode\Tasks\WorkspaceHygieneReportTask::SETTING_KEY, + 'default_enabled' => false, + 'label' => 'Weekly — reports workspace disk hygiene', + 'task_params' => array( + 'source' => 'recurring_schedule', + 'include_cleanup' => true, + 'include_sizes' => true, + 'size_limit' => 200, + ), + ); return $schedules; } ); diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 9aa350f..399eb35 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -953,6 +953,52 @@ private function registerAbilities(): void { ) ); + wp_register_ability( + 'datamachine/workspace-hygiene-report', + array( + 'label' => 'Workspace Hygiene Report', + 'description' => 'Build a non-destructive workspace hygiene report with disk, size, worktree, and local cleanup dry-run summaries.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'include_cleanup' => array( + 'type' => 'boolean', + 'description' => 'Include a local-only worktree cleanup dry-run summary. Default true.', + ), + 'include_sizes' => array( + 'type' => 'boolean', + 'description' => 'Include best-effort top-level workspace size data. Default true.', + ), + 'size_limit' => array( + 'type' => 'integer', + 'description' => 'Maximum top-level workspace entries to size. Default 200.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'generated_at' => array( 'type' => 'string' ), + 'workspace_path' => array( 'type' => 'string' ), + 'destructive' => array( 'type' => 'boolean' ), + 'size' => array( 'type' => 'object' ), + 'disk' => array( 'type' => 'object' ), + 'worktrees' => array( 'type' => 'object' ), + 'top_repos_by_worktrees' => array( 'type' => 'array' ), + 'top_repos_by_size' => array( 'type' => 'array' ), + 'cleanup' => array( 'type' => 'object' ), + 'suggested_cleanup_command' => array( 'type' => 'string' ), + 'notes' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( self::class, 'workspaceHygieneReport' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + wp_register_ability( 'datamachine/workspace-worktree-list', array( @@ -1449,6 +1495,28 @@ public static function worktreeList( array $input ): array|\WP_Error { return $workspace->worktree_list( $repo, $state ); } + /** + * Build a non-destructive workspace hygiene report. + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function workspaceHygieneReport( array $input ): array|\WP_Error { + $workspace = new Workspace(); + $opts = array(); + if ( array_key_exists( 'include_cleanup', $input ) ) { + $opts['include_cleanup'] = (bool) $input['include_cleanup']; + } + if ( array_key_exists( 'include_sizes', $input ) ) { + $opts['include_sizes'] = (bool) $input['include_sizes']; + } + if ( isset( $input['size_limit'] ) ) { + $opts['size_limit'] = (int) $input['size_limit']; + } + + return $workspace->workspace_hygiene_report( $opts ); + } + /** * Remove a worktree. * diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 154d852..9ca0752 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -244,6 +244,63 @@ public function remove_repo( array $args, array $assoc_args ): void { WP_CLI::success( $result['message'] ); } + /** + * Show a non-destructive workspace hygiene report. + * + * ## OPTIONS + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * --- + * + * [--skip-cleanup] + * : Skip the local cleanup dry-run summary. + * + * [--skip-sizes] + * : Skip best-effort workspace size collection. + * + * [--size-limit=] + * : Maximum top-level workspace entries to size. + * --- + * default: 200 + * --- + * + * ## EXAMPLES + * + * wp datamachine-code workspace hygiene + * wp datamachine-code workspace hygiene --format=json + * + * @subcommand hygiene + */ + public function hygiene( array $args, array $assoc_args ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $ability = wp_get_ability( 'datamachine/workspace-hygiene-report' ); + if ( ! $ability ) { + WP_CLI::error( 'Workspace hygiene ability not available.' ); + return; + } + + $input = array( + 'include_cleanup' => empty( $assoc_args['skip-cleanup'] ), + 'include_sizes' => empty( $assoc_args['skip-sizes'] ), + ); + if ( isset( $assoc_args['size-limit'] ) ) { + $input['size_limit'] = (int) $assoc_args['size-limit']; + } + + $result = $ability->execute( $input ); + if ( is_wp_error( $result ) ) { + WP_CLI::error( $result->get_error_message() ); + return; + } + + $this->render_workspace_hygiene_report( $result, $assoc_args ); + } + /** * Show detailed info about a workspace repo. * @@ -1449,6 +1506,137 @@ private function renderWorktreeResult( string $operation, array $result, array $ } } + /** + * Render workspace hygiene report output. + * + * @param array $report Hygiene report. + * @param array $assoc_args CLI args. + * @return void + */ + private function render_workspace_hygiene_report( array $report, array $assoc_args ): void { + $format = isset( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'table'; + if ( 'json' === $format ) { + $json = wp_json_encode( $report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); + WP_CLI::log( false === $json ? '{}' : $json ); + return; + } + + $size = (array) ( $report['size'] ?? array() ); + $disk = (array) ( $report['disk'] ?? array() ); + $worktrees = (array) ( $report['worktrees'] ?? array() ); + $cleanup = (array) ( $report['cleanup'] ?? array() ); + $cleanup_summary = (array) ( $cleanup['summary'] ?? array() ); + + WP_CLI::log( 'Workspace hygiene:' ); + $this->format_items( + array( + array( + 'metric' => 'workspace_path', + 'value' => (string) ( $report['workspace_path'] ?? '' ), + ), + array( + 'metric' => 'workspace_size', + 'value' => (string) ( $size['total_human'] ?? '-' ), + ), + array( + 'metric' => 'size_mode', + 'value' => (string) ( $size['mode'] ?? '-' ), + ), + array( + 'metric' => 'size_scan_complete', + 'value' => ! empty( $size['scan_complete'] ) ? 'yes' : 'no', + ), + array( + 'metric' => 'disk_free', + 'value' => (string) ( $disk['free_human'] ?? '-' ), + ), + array( + 'metric' => 'worktree_count', + 'value' => (string) ( $worktrees['worktrees'] ?? 0 ), + ), + array( + 'metric' => 'dirty_protected', + 'value' => (string) ( $worktrees['protected_dirty'] ?? 0 ), + ), + array( + 'metric' => 'unpushed_protected', + 'value' => (string) ( $worktrees['protected_unpushed'] ?? 0 ), + ), + array( + 'metric' => 'missing_metadata', + 'value' => (string) ( $worktrees['missing_metadata'] ?? 0 ), + ), + array( + 'metric' => 'external_worktrees', + 'value' => (string) ( $worktrees['external'] ?? 0 ), + ), + array( + 'metric' => 'cleanup_candidates', + 'value' => (string) ( $cleanup_summary['would_remove'] ?? 0 ), + ), + ), + array( 'metric', 'value' ), + array( 'format' => 'table' ), + 'metric' + ); + + $top_size = array_slice( (array) ( $report['top_repos_by_size'] ?? array() ), 0, 10 ); + if ( array() !== $top_size ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Top repos by size:' ); + $this->format_items( + array_map( + fn( $row ) => array( + 'repo' => $row['repo'] ?? '', + 'size' => $row['human'] ?? '', + 'bytes' => $row['bytes'] ?? 0, + ), + $top_size + ), + array( 'repo', 'size', 'bytes' ), + array( 'format' => 'table' ), + 'bytes' + ); + } + + $top_counts = array_slice( (array) ( $report['top_repos_by_worktrees'] ?? array() ), 0, 10 ); + if ( array() !== $top_counts ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Top repos by worktree count:' ); + $this->format_items( $top_counts, array( 'repo', 'worktree_count' ), array( 'format' => 'table' ), 'worktree_count' ); + } + + $candidates = array_slice( (array) ( $cleanup['biggest_candidates'] ?? array() ), 0, 10 ); + if ( array() !== $candidates ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Cleanup candidates:' ); + $this->format_items( + array_map( + fn( $row ) => array( + 'handle' => $row['handle'] ?? '', + 'branch' => $row['branch'] ?? '', + 'signal' => $row['signal'] ?? '', + 'size' => $row['size_human'] ?? '', + ), + $candidates + ), + array( 'handle', 'branch', 'signal', 'size' ), + array( 'format' => 'table' ), + 'handle' + ); + } + + if ( ! empty( $report['suggested_cleanup_command'] ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Suggested cleanup review:' ); + WP_CLI::log( (string) $report['suggested_cleanup_command'] ); + } + + foreach ( (array) ( $report['notes'] ?? array() ) as $note ) { + WP_CLI::log( 'Note: ' . $note ); + } + } + /** * Render cleanup output with a machine-safe JSON contract and concise tables. * diff --git a/inc/Tasks/WorkspaceHygieneReportTask.php b/inc/Tasks/WorkspaceHygieneReportTask.php new file mode 100644 index 0000000..71bb4f0 --- /dev/null +++ b/inc/Tasks/WorkspaceHygieneReportTask.php @@ -0,0 +1,118 @@ + + */ + public static function getTaskMeta(): array { + return array( + 'label' => 'Workspace Hygiene Report', + 'description' => 'Non-destructive report for workspace disk usage, free space, worktree counts, and local cleanup candidates. Runs weekly when enabled.', + 'setting_key' => self::SETTING_KEY, + 'default_enabled' => false, + 'supports_run' => true, + ); + } + + /** + * Execute the hygiene report. + * + * Params are optional and mirror `Workspace::workspace_hygiene_report()`. + * + * @param int $jobId Job ID. + * @param array $params Task params. + * @return void + */ + public function executeTask( int $jobId, array $params ): void { + $enabled = (bool) PluginSettings::get( self::SETTING_KEY, false ); + if ( ! $enabled ) { + $this->completeJob( + $jobId, + array( + 'skipped' => true, + 'reason' => sprintf( 'Workspace hygiene report disabled (PluginSettings: %s=false).', self::SETTING_KEY ), + ) + ); + return; + } + + $workspace = new Workspace(); + $result = $workspace->workspace_hygiene_report( + array( + 'include_cleanup' => array_key_exists( 'include_cleanup', $params ) ? (bool) $params['include_cleanup'] : true, + 'include_sizes' => array_key_exists( 'include_sizes', $params ) ? (bool) $params['include_sizes'] : true, + 'size_limit' => isset( $params['size_limit'] ) ? (int) $params['size_limit'] : 200, + ) + ); + + if ( $result instanceof \WP_Error ) { + do_action( + 'datamachine_log', + 'error', + 'Workspace hygiene report failed', + array( + 'task' => $this->getTaskType(), + 'jobId' => $jobId, + 'error' => $result->get_error_message(), + 'code' => $result->get_error_code(), + ) + ); + $this->failJob( $jobId, $result->get_error_message() ); + return; + } + $worktrees = (array) ( $result['worktrees'] ?? array() ); + $cleanup = (array) ( $result['cleanup']['summary'] ?? array() ); + do_action( + 'datamachine_log', + 'info', + sprintf( + 'Workspace hygiene report: %s used, %s free, %d worktree(s), %d cleanup candidate(s).', + $result['size']['total_human'] ?? 'unknown size', + $result['disk']['free_human'] ?? 'unknown disk', + (int) ( $worktrees['worktrees'] ?? 0 ), + (int) ( $cleanup['would_remove'] ?? 0 ) + ), + array( + 'task' => $this->getTaskType(), + 'jobId' => $jobId, + 'report' => $result, + ) + ); + + $this->completeJob( $jobId, $result ); + } +} diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index a9a4fa2..9cbc99e 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -41,6 +41,11 @@ class Workspace { */ private const CLEANUP_SUMMARY_TOP_LIMIT = 10; + /** + * Default number of workspace entries to size in hygiene reports. + */ + private const HYGIENE_DEFAULT_SIZE_LIMIT = 200; + /** * @var string Resolved workspace path. */ @@ -1566,6 +1571,372 @@ public function worktree_list( ?string $repo = null, ?string $state = null ): ar ); } + /** + * Build a non-destructive workspace hygiene report. + * + * The report intentionally defaults to local-only cleanup detection so an + * on-demand or scheduled run never depends on GitHub API availability. Size + * collection is best-effort and bounded by a top-level entry limit. + * + * @param array $opts { + * @type bool $include_cleanup Whether to include a cleanup dry-run. Default true. + * @type bool $include_sizes Whether to include best-effort `du` sizes. Default true. + * @type int $size_limit Maximum top-level workspace entries to size. Default 200. + * } + * @return array|\WP_Error + */ + public function workspace_hygiene_report( array $opts = array() ): array|\WP_Error { + $include_cleanup = array_key_exists( 'include_cleanup', $opts ) ? (bool) $opts['include_cleanup'] : true; + $include_sizes = array_key_exists( 'include_sizes', $opts ) ? (bool) $opts['include_sizes'] : true; + $size_limit = isset( $opts['size_limit'] ) ? max( 0, (int) $opts['size_limit'] ) : self::HYGIENE_DEFAULT_SIZE_LIMIT; + + $listing = $this->worktree_list(); + if ( is_wp_error( $listing ) ) { + return $listing; + } + + $worktrees = (array) ( $listing['worktrees'] ?? array() ); + $size_report = $include_sizes ? $this->build_workspace_size_report( $size_limit ) : $this->empty_workspace_size_report( $size_limit, false ); + $cleanup = null; + $cleanup_error = null; + + if ( $include_cleanup ) { + $cleanup = $this->worktree_cleanup_merged( + array( + 'dry_run' => true, + 'force' => false, + 'skip_github' => true, + ) + ); + if ( $cleanup instanceof \WP_Error ) { + $cleanup_error = array( + 'code' => $cleanup->get_error_code(), + 'message' => $cleanup->get_error_message(), + ); + $cleanup = null; + } + } + return array( + 'success' => true, + 'generated_at' => gmdate( 'c' ), + 'workspace_path' => $this->workspace_path, + 'destructive' => false, + 'size' => $size_report, + 'disk' => $this->build_workspace_disk_report(), + 'worktrees' => $this->summarize_workspace_worktrees( $worktrees, $cleanup ), + 'top_repos_by_worktrees' => $this->top_repos_by_worktree_count( $worktrees, 10 ), + 'top_repos_by_size' => $this->top_repos_by_size( (array) ( $size_report['entries'] ?? array() ), 10 ), + 'cleanup' => $this->summarize_workspace_cleanup( $cleanup, $cleanup_error, (array) ( $size_report['entries'] ?? array() ) ), + 'suggested_cleanup_command' => 'wp datamachine-code workspace worktree cleanup --dry-run --skip-github --format=json', + 'notes' => array_values( array_filter( array( + $include_sizes ? (string) ( $size_report['mode_note'] ?? '' ) : 'Size scan disabled by request.', + $include_cleanup ? 'Cleanup summary uses local-only dry-run detection (--skip-github); no GitHub API lookups are required.' : 'Cleanup dry-run disabled by request.', + ) ) ), + ); + } + + /** + * Build best-effort workspace size data from top-level entries. + * + * @param int $limit Maximum entries to size. + * @return array + */ + private function build_workspace_size_report( int $limit ): array { + if ( '' === $this->workspace_path || ! is_dir( $this->workspace_path ) ) { + return $this->empty_workspace_size_report( $limit, true ); + } + + $entries = scandir( $this->workspace_path ); + if ( false === $entries ) { + return $this->empty_workspace_size_report( $limit, true ); + } + + $dirs = array_values( array_filter( + $entries, + fn( $entry ) => '.' !== $entry && '..' !== $entry && is_dir( $this->workspace_path . '/' . $entry ) + ) ); + sort( $dirs, SORT_NATURAL ); + + $total_dirs = count( $dirs ); + $sample = array_slice( $dirs, 0, $limit ); + $rows = array(); + $total = 0; + + foreach ( $sample as $entry ) { + $path = $this->workspace_path . '/' . $entry; + $size = $this->directory_size_bytes_best_effort( $path ); + if ( null === $size ) { + continue; + } + + $parsed = $this->parse_handle( $entry ); + $total += $size; + $rows[] = array( + 'handle' => $entry, + 'repo' => $parsed['repo'], + 'is_worktree' => ! empty( $parsed['is_worktree'] ), + 'path' => $path, + 'bytes' => $size, + 'human' => $this->format_bytes( $size ), + ); + } + + usort( $rows, fn( $a, $b ) => (int) $b['bytes'] <=> (int) $a['bytes'] ); + $scanned_count = count( $sample ); + + return array( + 'mode' => 'best_effort_top_level_du', + 'mode_note' => 'Workspace size is best-effort: top-level entries are sized with du and capped by size_limit.', + 'size_limit' => $limit, + 'total_entries' => $total_dirs, + 'scanned_entries' => $scanned_count, + 'scan_complete' => $scanned_count >= $total_dirs, + 'total_bytes' => $total, + 'total_human' => $this->format_bytes( $total ), + 'entries' => $rows, + 'top_entries' => array_slice( $rows, 0, 10 ), + ); + } + + /** + * Empty size-report envelope. + * + * @param int $limit Configured size limit. + * @param bool $enabled Whether size scanning was requested. + * @return array + */ + private function empty_workspace_size_report( int $limit, bool $enabled ): array { + return array( + 'mode' => $enabled ? 'best_effort_top_level_du' : 'disabled', + 'mode_note' => $enabled ? 'Workspace path is unavailable or unreadable; no size data collected.' : 'Size scan disabled by request.', + 'size_limit' => $limit, + 'total_entries' => 0, + 'scanned_entries' => 0, + 'scan_complete' => true, + 'total_bytes' => 0, + 'total_human' => $this->format_bytes( 0 ), + 'entries' => array(), + 'top_entries' => array(), + ); + } + + /** + * Best-effort directory size via `du -sk`. + * + * @param string $path Directory path. + * @return int|null Size in bytes, or null when unavailable. + */ + private function directory_size_bytes_best_effort( string $path ): ?int { + if ( ! is_dir( $path ) ) { + return null; + } + + $output = array(); + $exit = 0; + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Local workspace hygiene needs best-effort disk usage; input path is shell-escaped. + exec( sprintf( 'du -sk %s 2>/dev/null', escapeshellarg( $path ) ), $output, $exit ); + if ( 0 !== $exit || empty( $output[0] ) ) { + return null; + } + + $parts = preg_split( '/\s+/', trim( (string) $output[0] ) ); + $kb = isset( $parts[0] ) ? (int) $parts[0] : 0; + return max( 0, $kb ) * 1024; + } + + /** + * Build workspace disk/free-space report. + * + * @return array + */ + private function build_workspace_disk_report(): array { + $path = '' !== $this->workspace_path && is_dir( $this->workspace_path ) ? $this->workspace_path : dirname( $this->workspace_path ); + $free = '' !== $path ? disk_free_space( $path ) : false; + $total = '' !== $path ? disk_total_space( $path ) : false; + + $free_bytes = false === $free ? null : (int) $free; + $total_bytes = false === $total ? null : (int) $total; + + return array( + 'path' => $path, + 'free_bytes' => $free_bytes, + 'free_human' => null === $free_bytes ? null : $this->format_bytes( $free_bytes ), + 'total_bytes' => $total_bytes, + 'total_human' => null === $total_bytes ? null : $this->format_bytes( $total_bytes ), + ); + } + + /** + * Summarize worktree listing and cleanup-protected counts. + * + * @param array $worktrees Worktree rows. + * @param array|null $cleanup Cleanup dry-run report. + * @return array + */ + private function summarize_workspace_worktrees( array $worktrees, ?array $cleanup ): array { + $summary = array( + 'total' => count( $worktrees ), + 'primaries' => 0, + 'worktrees' => 0, + 'external' => 0, + 'dirty' => 0, + 'protected_dirty' => 0, + 'protected_unpushed' => 0, + 'missing_metadata' => 0, + ); + + foreach ( $worktrees as $row ) { + if ( ! empty( $row['is_primary'] ) ) { + ++$summary['primaries']; + } else { + ++$summary['worktrees']; + } + + if ( ! empty( $row['external'] ) ) { + ++$summary['external']; + } + + if ( (int) ( $row['dirty'] ?? 0 ) > 0 ) { + ++$summary['dirty']; + } + } + + if ( null !== $cleanup ) { + $by_reason = (array) ( $cleanup['summary']['skipped_by_reason'] ?? array() ); + $summary['protected_dirty'] = (int) ( $by_reason['dirty_worktree'] ?? 0 ); + $summary['protected_unpushed'] = (int) ( $by_reason['unpushed_commits'] ?? 0 ); + $summary['missing_metadata'] = (int) ( $by_reason['missing_metadata'] ?? 0 ); + $summary['external'] = max( $summary['external'], (int) ( $by_reason['external_worktree'] ?? 0 ) ); + } + + return $summary; + } + + /** + * Summarize cleanup dry-run output for hygiene reports. + * + * @param array|null $cleanup Cleanup report. + * @param array|null $error Cleanup error envelope. + * @return array + */ + private function summarize_workspace_cleanup( ?array $cleanup, ?array $error, array $size_entries = array() ): array { + if ( null === $cleanup ) { + return array( + 'included' => false, + 'error' => $error, + ); + } + + $candidates = (array) ( $cleanup['candidates'] ?? array() ); + $size_by_handle = array(); + foreach ( $size_entries as $entry ) { + $handle = (string) ( $entry['handle'] ?? '' ); + if ( '' !== $handle ) { + $size_by_handle[ $handle ] = array( + 'bytes' => (int) ( $entry['bytes'] ?? 0 ), + 'human' => (string) ( $entry['human'] ?? '' ), + ); + } + } + foreach ( $candidates as &$candidate ) { + $handle = (string) ( $candidate['handle'] ?? '' ); + if ( isset( $size_by_handle[ $handle ] ) ) { + $candidate['size_bytes'] = $size_by_handle[ $handle ]['bytes']; + $candidate['size_human'] = $size_by_handle[ $handle ]['human']; + } + } + unset( $candidate ); + usort( $candidates, fn( $a, $b ) => (int) ( $b['size_bytes'] ?? 0 ) <=> (int) ( $a['size_bytes'] ?? 0 ) ); + return array( + 'included' => true, + 'dry_run' => true, + 'skip_github' => true, + 'summary' => $cleanup['summary'] ?? array(), + 'biggest_candidates' => array_slice( $candidates, 0, 10 ), + 'skipped_by_reason' => $cleanup['summary']['skipped_by_reason'] ?? array(), + 'candidates_by_signal' => $cleanup['summary']['candidates_by_signal'] ?? array(), + ); + } + + /** + * Count worktrees by repo. + * + * @param array $worktrees Worktree rows. + * @param int $limit Max rows. + * @return array> + */ + private function top_repos_by_worktree_count( array $worktrees, int $limit ): array { + $counts = array(); + foreach ( $worktrees as $row ) { + if ( ! empty( $row['is_primary'] ) ) { + continue; + } + $repo = (string) ( $row['repo'] ?? '' ); + if ( '' === $repo ) { + continue; + } + $counts[ $repo ] = ( $counts[ $repo ] ?? 0 ) + 1; + } + + arsort( $counts ); + $rows = array(); + foreach ( array_slice( $counts, 0, $limit, true ) as $repo => $count ) { + $rows[] = array( + 'repo' => $repo, + 'worktree_count' => (int) $count, + ); + } + return $rows; + } + + /** + * Sum best-effort size rows by repo. + * + * @param array $entries Size rows. + * @param int $limit Max rows. + * @return array> + */ + private function top_repos_by_size( array $entries, int $limit ): array { + $sizes = array(); + foreach ( $entries as $entry ) { + $repo = (string) ( $entry['repo'] ?? '' ); + if ( '' === $repo ) { + continue; + } + $sizes[ $repo ] = ( $sizes[ $repo ] ?? 0 ) + (int) ( $entry['bytes'] ?? 0 ); + } + + arsort( $sizes ); + $rows = array(); + foreach ( array_slice( $sizes, 0, $limit, true ) as $repo => $bytes ) { + $rows[] = array( + 'repo' => $repo, + 'bytes' => (int) $bytes, + 'human' => $this->format_bytes( (int) $bytes ), + ); + } + return $rows; + } + + /** + * Format bytes for reports. + * + * @param int $bytes Byte count. + * @return string + */ + private function format_bytes( int $bytes ): string { + $units = array( 'B', 'KiB', 'MiB', 'GiB', 'TiB' ); + $unit_count = count( $units ); + $value = (float) max( 0, $bytes ); + $index = 0; + while ( $value >= 1024 && $index < $unit_count - 1 ) { + $value /= 1024; + ++$index; + } + + return sprintf( $index > 0 ? '%.1f %s' : '%.0f %s', $value, $units[ $index ] ); + } + /** * Remove a worktree. * diff --git a/tests/smoke-workspace-hygiene-cli.php b/tests/smoke-workspace-hygiene-cli.php new file mode 100644 index 0000000..95f4d14 --- /dev/null +++ b/tests/smoke-workspace-hygiene-cli.php @@ -0,0 +1,147 @@ + true, + 'generated_at' => '2026-04-29T00:00:00+00:00', + 'workspace_path' => '/workspace', + 'destructive' => false, + 'size' => array( + 'mode' => 'best_effort_top_level_du', + 'mode_note' => 'Workspace size is best-effort.', + 'total_human' => '712.6 GiB', + 'scan_complete' => true, + 'top_entries' => array(), + ), + 'disk' => array( + 'free_human' => '1.1 GiB', + ), + 'worktrees' => array( + 'worktrees' => 42, + 'protected_dirty' => 3, + 'protected_unpushed' => 2, + 'missing_metadata' => 4, + 'external' => 7, + ), + 'top_repos_by_worktrees' => array( + array( 'repo' => 'data-machine', 'worktree_count' => 17 ), + ), + 'top_repos_by_size' => array( + array( 'repo' => 'data-machine', 'bytes' => 4096, 'human' => '4.0 KiB' ), + ), + 'cleanup' => array( + 'included' => true, + 'dry_run' => true, + 'skip_github' => true, + 'summary' => array( 'would_remove' => 9 ), + 'biggest_candidates' => array( + array( 'handle' => 'data-machine@merged', 'branch' => 'merged', 'signal' => 'upstream-gone' ), + ), + ), + 'suggested_cleanup_command' => 'wp datamachine-code workspace worktree cleanup --dry-run --skip-github --format=json', + 'notes' => array( 'Cleanup summary uses local-only dry-run detection (--skip-github); no GitHub API lookups are required.' ), + ); + } + + class FakeHygieneAbility { + public array $last_input = array(); + + public function execute( array $input ): array { + $this->last_input = $input; + return datamachine_code_hygiene_report(); + } + } + + echo "=== smoke-workspace-hygiene-cli ===\n"; + + $ability = new FakeHygieneAbility(); + $GLOBALS['__abilities'] = array( + 'datamachine/workspace-hygiene-report' => $ability, + ); + $command = new \DataMachineCode\Cli\Commands\WorkspaceCommand(); + + echo "\n[1] JSON output is parseable and forwards bounded flags\n"; + $GLOBALS['__cli_logs'] = array(); + $command->hygiene( array(), array( 'format' => 'json', 'skip-cleanup' => true, 'skip-sizes' => true, 'size-limit' => '25' ) ); + datamachine_code_hygiene_assert( array( 'include_cleanup' => false, 'include_sizes' => false, 'size_limit' => 25 ) === $ability->last_input, 'CLI forwards skip flags and size limit' ); + datamachine_code_hygiene_assert( 1 === count( $GLOBALS['__cli_logs'] ), 'JSON path writes one stdout document' ); + $decoded = json_decode( (string) $GLOBALS['__cli_logs'][0], true ); + datamachine_code_hygiene_assert( JSON_ERROR_NONE === json_last_error(), 'JSON output parses cleanly' ); + datamachine_code_hygiene_assert( false === ( $decoded['destructive'] ?? true ), 'JSON report is explicitly non-destructive' ); + datamachine_code_hygiene_assert( '1.1 GiB' === ( $decoded['disk']['free_human'] ?? '' ), 'JSON report includes free disk space' ); + + echo "\n[2] Human output is summary-first and includes actionable sections\n"; + $GLOBALS['__cli_logs'] = array(); + $command->hygiene( array(), array() ); + datamachine_code_hygiene_assert( 'Workspace hygiene:' === ( $GLOBALS['__cli_logs'][0] ?? '' ), 'human output starts with report heading' ); + datamachine_code_hygiene_assert( in_array( 'table:11:metric,value', $GLOBALS['__cli_logs'], true ), 'human output renders summary table' ); + datamachine_code_hygiene_assert( in_array( 'Top repos by size:', $GLOBALS['__cli_logs'], true ), 'human output renders size leaders' ); + datamachine_code_hygiene_assert( in_array( 'Top repos by worktree count:', $GLOBALS['__cli_logs'], true ), 'human output renders worktree-count leaders' ); + datamachine_code_hygiene_assert( in_array( 'Cleanup candidates:', $GLOBALS['__cli_logs'], true ), 'human output renders cleanup candidates' ); + datamachine_code_hygiene_assert( in_array( 'Suggested cleanup review:', $GLOBALS['__cli_logs'], true ), 'human output renders cleanup command' ); + + echo "\nAll workspace hygiene CLI smoke tests passed.\n"; +} diff --git a/tests/smoke-workspace-hygiene-task.php b/tests/smoke-workspace-hygiene-task.php new file mode 100644 index 0000000..4cfe198 --- /dev/null +++ b/tests/smoke-workspace-hygiene-task.php @@ -0,0 +1,111 @@ +completed[] = array( $jobId, $data ); + } + + protected function failJob( int $jobId, string $message ): void { + $this->failed[] = array( $jobId, $message ); + } + } +} + +namespace DataMachineCode\Workspace { + class Workspace { + public function workspace_hygiene_report( array $opts = array() ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + return array( + 'success' => true, + 'size' => array( 'total_human' => '712.6 GiB' ), + 'disk' => array( 'free_human' => '1.1 GiB' ), + 'worktrees' => array( 'worktrees' => 42 ), + 'cleanup' => array( 'summary' => array( 'would_remove' => 9 ) ), + 'destructive' => false, + ); + } + } +} + +namespace { + require_once dirname( __DIR__ ) . '/inc/Tasks/WorkspaceHygieneReportTask.php'; + + function datamachine_code_hygiene_task_assert( bool $condition, string $message ): void { + if ( $condition ) { + echo " [PASS] {$message}\n"; + return; + } + echo " [FAIL] {$message}\n"; + exit( 1 ); + } + + echo "=== smoke-workspace-hygiene-task ===\n"; + + echo "\n[1] Disabled task completes as skipped\n"; + $settings_class = '\\DataMachine\\Core\\PluginSettings'; + $enabled_prop = 'enabled'; + $settings_class::$$enabled_prop = false; + $task = new \DataMachineCode\Tasks\WorkspaceHygieneReportTask(); + $task->executeTask( 101, array() ); + $completed_prop = 'completed'; + $failed_prop = 'failed'; + $completed = $task->{$completed_prop}; + $failed = $task->{$failed_prop}; + datamachine_code_hygiene_task_assert( 'workspace_hygiene_report' === $task->getTaskType(), 'task type is stable' ); + datamachine_code_hygiene_task_assert( true === ( $completed[0][1]['skipped'] ?? false ), 'disabled task reports skipped completion' ); + datamachine_code_hygiene_task_assert( array() === $failed, 'disabled task does not fail the job' ); + + echo "\n[2] Enabled task stores report and logs concise summary\n"; + $GLOBALS['__task_logs'] = array(); + $settings_class::$$enabled_prop = true; + $task = new \DataMachineCode\Tasks\WorkspaceHygieneReportTask(); + $task->executeTask( 102, array( 'size_limit' => 25 ) ); + $completed = $task->{$completed_prop}; + datamachine_code_hygiene_task_assert( false === ( $completed[0][1]['destructive'] ?? true ), 'enabled task completes with non-destructive report' ); + datamachine_code_hygiene_task_assert( '1.1 GiB' === ( $completed[0][1]['disk']['free_human'] ?? '' ), 'enabled task stores free disk data' ); + datamachine_code_hygiene_task_assert( 1 === count( $GLOBALS['__task_logs'] ), 'enabled task emits one datamachine_log event' ); + $first_log = $GLOBALS['__task_logs'][0] ?? array(); + datamachine_code_hygiene_task_assert( 'datamachine_log' === ( $first_log[0] ?? '' ), 'log hook is datamachine_log' ); + + echo "\nAll workspace hygiene task smoke tests passed.\n"; +} diff --git a/tests/smoke-worktree-finalizer-cli.php b/tests/smoke-worktree-finalizer-cli.php index b4eab92..dd240f7 100644 --- a/tests/smoke-worktree-finalizer-cli.php +++ b/tests/smoke-worktree-finalizer-cli.php @@ -135,7 +135,7 @@ public function execute( array $input ): array { WP_CLI::$successes = array(); $command->worktree( array( 'list' ), array( 'state' => 'cleanup_eligible' ) ); datamachine_code_finalizer_assert( array( 'state' => 'cleanup_eligible' ) === $list->last_input, 'list forwards lifecycle state filter' ); - datamachine_code_finalizer_assert( in_array( 'table:1:handle,repo,kind,branch,head,dirty,state,created_at,pr,path', WP_CLI::$logs, true ), 'list human table exposes state and PR columns' ); + datamachine_code_finalizer_assert( in_array( 'table:1:handle,repo,kind,branch,head,dirty,state,created_at,pr,age_days,size,artifacts,stale,path', WP_CLI::$logs, true ), 'list human table exposes state and PR columns' ); echo "\nAll worktree finalizer CLI smoke tests passed.\n"; }