diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index a8c1b94..9aa350f 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -980,22 +980,28 @@ private function registerAbilities(): void { 'type' => 'array', 'items' => array( 'type' => 'object', - 'properties' => array( - 'handle' => array( 'type' => 'string' ), - 'repo' => array( 'type' => 'string' ), - 'is_worktree' => array( 'type' => 'boolean' ), - 'is_primary' => array( 'type' => 'boolean' ), - 'external' => array( 'type' => 'boolean' ), - 'branch_slug' => array( 'type' => array( 'string', 'null' ) ), - 'branch' => array( 'type' => array( 'string', 'null' ) ), - 'head' => array( 'type' => 'string' ), - 'path' => array( 'type' => 'string' ), - 'dirty' => array( 'type' => 'integer' ), - 'created_at' => array( 'type' => array( 'string', 'null' ) ), + 'properties' => array( + 'handle' => array( 'type' => 'string' ), + 'repo' => array( 'type' => 'string' ), + 'is_worktree' => array( 'type' => 'boolean' ), + 'is_primary' => array( 'type' => 'boolean' ), + 'external' => array( 'type' => 'boolean' ), + 'branch_slug' => array( 'type' => array( 'string', 'null' ) ), + 'branch' => array( 'type' => array( 'string', 'null' ) ), + 'head' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + 'dirty' => array( 'type' => 'integer' ), + 'created_at' => array( 'type' => array( 'string', 'null' ) ), 'lifecycle_state' => array( 'type' => array( 'string', 'null' ) ), - 'pr_url' => array( 'type' => array( 'string', 'null' ) ), - 'pr_number' => array( 'type' => array( 'integer', 'null' ) ), - 'metadata' => array( 'type' => array( 'object', 'null' ) ), + 'pr_url' => array( 'type' => array( 'string', 'null' ) ), + 'pr_number' => array( 'type' => array( 'integer', 'null' ) ), + 'last_touched_at' => array( 'type' => array( 'string', 'null' ) ), + 'age_days' => array( 'type' => array( 'integer', 'null' ) ), + 'size_bytes' => array( 'type' => array( 'integer', 'null' ) ), + 'artifact_size_bytes' => array( 'type' => 'integer' ), + 'artifacts' => array( 'type' => 'array' ), + 'stale_reason' => array( 'type' => array( 'string', 'null' ) ), + 'metadata' => array( 'type' => array( 'object', 'null' ) ), ), ), ), @@ -1100,6 +1106,10 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Optional candidate age filter such as 7d, 24h, 30m, or 60s. Uses lifecycle created_at metadata only.', ), + 'sort' => array( + 'type' => 'string', + 'description' => 'Optional cleanup candidate sort: size or age.', + ), ), ), 'output_schema' => array( @@ -1468,7 +1478,7 @@ public static function worktreePrune( array $input ): array|\WP_Error { // phpcs /** * Remove merged worktrees across all primary checkouts. * - * @param array $input Input parameters (dry_run, force, skip_github, apply_plan, older_than). + * @param array $input Input parameters (dry_run, force, skip_github, apply_plan, older_than, sort). * @return array */ public static function worktreeCleanup( array $input ): array|\WP_Error { @@ -1484,6 +1494,9 @@ public static function worktreeCleanup( array $input ): array|\WP_Error { if ( isset( $input['older_than'] ) && '' !== trim( (string) $input['older_than'] ) ) { $opts['older_than'] = trim( (string) $input['older_than'] ); } + if ( isset( $input['sort'] ) && '' !== trim( (string) $input['sort'] ) ) { + $opts['sort'] = trim( (string) $input['sort'] ); + } return $workspace->worktree_cleanup_merged( $opts ); } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index b39e457..154d852 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -1064,21 +1064,32 @@ private function renderGitOperationResult( string $operation, array $result, arr * [--dry-run] * : Preview cleanup candidates without removing anything (cleanup only). * - * [--apply-plan=] - * : Apply a previously reviewed cleanup JSON report. The destructive pass - * revalidates every planned row and removes only exact current matches. - * - * [--skip-github] - * : Skip the GitHub API lookup and rely only on the local `upstream-gone` - * signal (cleanup only). Faster, but misses merged branches where the - * remote branch wasn't auto-deleted. - * - * [--older-than=] - * : Limit cleanup candidates to worktrees with lifecycle `created_at` - * metadata older than the compact duration (cleanup only, e.g. 7d, 24h). - * Candidate worktrees without valid `created_at` metadata are skipped. - * - * [--verbose] + * [--apply-plan=] + * : Apply a previously reviewed cleanup JSON report. The destructive pass + * revalidates every planned row and removes only exact current matches. + * + * [--skip-github] + * : Skip the GitHub API lookup and rely only on the local `upstream-gone` + * signal (cleanup only). Faster, but misses merged branches where the + * remote branch wasn't auto-deleted. + * + * [--older-than=] + * : Limit cleanup candidates to worktrees with lifecycle `created_at` + * metadata older than the compact duration (cleanup only, e.g. 7d, 24h). + * Candidate worktrees without valid `created_at` metadata are skipped. + * + * [--sort=] + * : Sort cleanup candidates by reporting field (cleanup only). + * --- + * options: + * - size + * - age + * --- + * + * [--stale] + * : For list, show only worktrees with a stale_reason (old, dirty, or missing metadata). + * + * [--verbose] * : Show every cleanup row instead of concise samples (cleanup only). * * [--only=
] @@ -1277,6 +1288,9 @@ public function worktree( array $args, array $assoc_args ): void { if ( isset( $assoc_args['older-than'] ) && '' !== trim( (string) $assoc_args['older-than'] ) ) { $input['older_than'] = trim( (string) $assoc_args['older-than'] ); } + if ( isset( $assoc_args['sort'] ) && '' !== trim( (string) $assoc_args['sort'] ) ) { + $input['sort'] = trim( (string) $assoc_args['sort'] ); + } break; } @@ -1307,29 +1321,39 @@ private function renderWorktreeResult( string $operation, array $result, array $ switch ( $operation ) { case 'list': $worktrees = $result['worktrees'] ?? array(); + if ( ! empty( $assoc_args['stale'] ) ) { + $worktrees = array_values( array_filter( $worktrees, fn( $wt ) => ! empty( $wt['stale_reason'] ) ) ); + } if ( empty( $worktrees ) ) { WP_CLI::log( 'No worktrees found.' ); return; } $items = array_map( fn( $wt ) => array( - 'handle' => $wt['handle'] ?? '', - 'repo' => $wt['repo'] ?? '', - 'kind' => ! empty( $wt['is_primary'] ) ? 'primary' : 'worktree', - 'branch' => $wt['branch'] ?? '-', - 'head' => isset( $wt['head'] ) ? substr( (string) $wt['head'], 0, 7 ) : '-', - 'dirty' => (int) ( $wt['dirty'] ?? 0 ), - 'created_at' => $wt['created_at'] ?? null, - 'state' => $wt['lifecycle_state'] ?? null, - 'pr' => $wt['pr_url'] ?? null, - 'metadata' => $wt['metadata'] ?? null, - 'path' => $wt['path'] ?? '', + 'handle' => $wt['handle'] ?? '', + 'repo' => $wt['repo'] ?? '', + 'kind' => ! empty( $wt['is_primary'] ) ? 'primary' : 'worktree', + 'branch' => $wt['branch'] ?? '-', + 'head' => isset( $wt['head'] ) ? substr( (string) $wt['head'], 0, 7 ) : '-', + 'dirty' => (int) ( $wt['dirty'] ?? 0 ), + 'created_at' => $wt['created_at'] ?? null, + 'state' => $wt['lifecycle_state'] ?? null, + 'pr' => $wt['pr_url'] ?? null, + 'age_days' => $wt['age_days'] ?? null, + 'size_bytes' => $wt['size_bytes'] ?? null, + 'size' => $this->format_bytes( $wt['size_bytes'] ?? null ), + 'artifact_size_bytes' => $wt['artifact_size_bytes'] ?? 0, + 'artifacts' => $this->format_bytes( $wt['artifact_size_bytes'] ?? 0 ), + 'artifact_paths' => $wt['artifacts'] ?? array(), + 'stale' => $wt['stale_reason'] ?? '', + 'metadata' => $wt['metadata'] ?? null, + 'path' => $wt['path'] ?? '', ), $worktrees ); - $fields = array( 'handle', 'repo', 'kind', 'branch', 'head', 'dirty', 'state', 'created_at', 'pr', 'path' ); + $fields = array( 'handle', 'repo', 'kind', 'branch', 'head', 'dirty', 'state', 'created_at', 'pr', 'age_days', 'size', 'artifacts', 'stale', 'path' ); if ( in_array( (string) ( $assoc_args['format'] ?? '' ), array( 'json', 'yaml' ), true ) ) { - $fields[] = 'metadata'; + $fields = array( 'handle', 'repo', 'kind', 'branch', 'head', 'dirty', 'state', 'created_at', 'pr', 'age_days', 'size_bytes', 'artifact_size_bytes', 'artifact_paths', 'stale', 'metadata', 'path' ); } $this->format_items( $items, $fields, $assoc_args, 'handle' ); return; @@ -1487,8 +1511,29 @@ private function render_worktree_cleanup_result( array $result, array $assoc_arg 'count' => (int) ( $summary['age_filter']['unknown_age'] ?? 0 ), ); } + $summary_rows[] = array( + 'metric' => 'total_size', + 'count' => $this->format_bytes( $summary['total_size_bytes'] ?? null ), + ); + $summary_rows[] = array( + 'metric' => 'artifact_size', + 'count' => $this->format_bytes( $summary['artifact_size_bytes'] ?? null ), + ); $this->format_items( $summary_rows, array( 'metric', 'count' ), array( 'format' => 'table' ), 'metric' ); + if ( ! empty( $summary['size_by_repo'] ) && is_array( $summary['size_by_repo'] ) ) { + WP_CLI::log( '' ); + WP_CLI::log( 'Top repos by worktree size:' ); + $repo_rows = array(); + foreach ( array_slice( $summary['size_by_repo'], 0, 5, true ) as $repo => $bytes ) { + $repo_rows[] = array( + 'repo' => (string) $repo, + 'size' => $this->format_bytes( $bytes ), + ); + } + $this->format_items( $repo_rows, array( 'repo', 'size' ), array( 'format' => 'table' ), 'size' ); + } + if ( '' !== $only ) { WP_CLI::log( sprintf( 'Filter: %s', $only ) ); } @@ -1500,13 +1545,16 @@ private function render_worktree_cleanup_result( array $result, array $assoc_arg fn( $c ) => array( 'handle' => $c['handle'] ?? '', 'branch' => $c['branch'] ?? '', + 'age_days' => $c['age_days'] ?? '', + 'size' => $this->format_bytes( $c['size_bytes'] ?? null ), + 'artifacts' => $this->format_bytes( $c['artifact_size_bytes'] ?? 0 ), 'signal' => $c['signal'] ?? '', 'reason_code' => $c['reason_code'] ?? ( $c['signal'] ?? '' ), 'reason' => $c['reason'] ?? '', ), array_slice( $candidates, 0, $limit ) ); - $fields = $verbose ? array( 'handle', 'branch', 'signal', 'reason' ) : array( 'handle', 'branch', 'signal', 'reason_code' ); + $fields = $verbose ? array( 'handle', 'branch', 'age_days', 'size', 'artifacts', 'signal', 'reason' ) : array( 'handle', 'branch', 'age_days', 'size', 'artifacts', 'signal', 'reason_code' ); $this->format_items( $candidate_rows, $fields, array( 'format' => 'table' ), 'handle' ); $this->render_cleanup_truncation_hint( count( $candidates ), $limit, 'candidate rows' ); } @@ -1518,13 +1566,16 @@ private function render_worktree_cleanup_result( array $result, array $assoc_arg fn( $c ) => array( 'handle' => $c['handle'] ?? '', 'branch' => $c['branch'] ?? '', + 'age_days' => $c['age_days'] ?? '', + 'size' => $this->format_bytes( $c['size_bytes'] ?? null ), + 'artifacts' => $this->format_bytes( $c['artifact_size_bytes'] ?? 0 ), 'signal' => $c['signal'] ?? '', 'reason_code' => $c['reason_code'] ?? ( $c['signal'] ?? '' ), 'reason' => $c['reason'] ?? '', ), array_slice( $removed, 0, $limit ) ); - $fields = $verbose ? array( 'handle', 'branch', 'signal', 'reason' ) : array( 'handle', 'branch', 'signal', 'reason_code' ); + $fields = $verbose ? array( 'handle', 'branch', 'age_days', 'size', 'artifacts', 'signal', 'reason' ) : array( 'handle', 'branch', 'age_days', 'size', 'artifacts', 'signal', 'reason_code' ); $this->format_items( $removed_rows, $fields, array( 'format' => 'table' ), 'handle' ); $this->render_cleanup_truncation_hint( count( $removed ), $limit, 'removed rows' ); } @@ -1537,6 +1588,9 @@ private function render_worktree_cleanup_result( array $result, array $assoc_arg 'handle' => $s['handle'] ?? '', 'reason_code' => $s['reason_code'] ?? '', 'reason' => $verbose ? ( $s['reason'] ?? '' ) : $this->shorten_cleanup_reason( (string) ( $s['reason'] ?? '' ) ), + 'age_days' => $s['age_days'] ?? '', + 'size' => $this->format_bytes( $s['size_bytes'] ?? null ), + 'artifacts' => $this->format_bytes( $s['artifact_size_bytes'] ?? 0 ), 'repo' => $s['repo'] ?? '', 'branch' => $s['branch'] ?? '', 'path' => $s['path'] ?? '', @@ -1546,7 +1600,7 @@ private function render_worktree_cleanup_result( array $result, array $assoc_arg ), array_slice( $skipped, 0, $limit ) ); - $fields = $verbose ? array( 'handle', 'reason_code', 'reason', 'repo', 'branch', 'path', 'primary_path', 'missing', 'hint' ) : array( 'handle', 'reason_code', 'reason' ); + $fields = $verbose ? array( 'handle', 'reason_code', 'reason', 'age_days', 'size', 'artifacts', 'repo', 'branch', 'path', 'primary_path', 'missing', 'hint' ) : array( 'handle', 'reason_code', 'age_days', 'size', 'artifacts', 'reason' ); $this->format_items( $skipped_rows, $fields, array( 'format' => 'table' ), 'handle' ); $this->render_cleanup_truncation_hint( count( $skipped ), $limit, 'skipped rows' ); } @@ -1672,6 +1726,30 @@ private function render_cleanup_truncation_hint( int $total, int $limit, string WP_CLI::log( sprintf( 'Showing %d of %d %s. Re-run with --verbose for all rows or --only= to filter.', $limit, $total, $label ) ); } + /** + * Format a byte count without depending on WordPress helpers in smoke tests. + * + * @param mixed $bytes Raw byte count. + * @return string Human-readable size. + */ + private function format_bytes( mixed $bytes ): string { + if ( null === $bytes || '' === $bytes ) { + return '-'; + } + + $bytes = max( 0, (float) $bytes ); + $units = array( 'B', 'KiB', 'MiB', 'GiB', 'TiB' ); + $unit = 0; + $unit_count = count( $units ); + while ( $bytes >= 1024 && $unit < $unit_count - 1 ) { + $bytes /= 1024; + ++$unit; + } + + $precision = 0 === $unit ? 0 : 1; + return number_format( $bytes, $precision ) . ' ' . $units[ $unit ]; + } + /** * Render the freshness block for `worktree add` results. * diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 7821b6a..a9a4fa2 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -36,6 +36,11 @@ class Workspace { */ private const CLEANUP_GITHUB_MAX_PAGES = 3; + /** + * Number of largest/oldest rows to expose in cleanup summaries. + */ + private const CLEANUP_SUMMARY_TOP_LIMIT = 10; + /** * @var string Resolved workspace path. */ @@ -1523,13 +1528,19 @@ public function worktree_list( ?string $repo = null, ?string $state = null ): ar ? 0 : count( array_filter( array_map( 'trim', explode( "\n", $dirty_result['output'] ?? '' ) ) ) ); $metadata = ( ! $is_primary && $inside_ws ) ? WorktreeContextInjector::get_metadata( $relative ) : null; + $created_at = is_array( $metadata ) ? ( $metadata['created_at'] ?? null ) : null; + $disk = $this->build_worktree_disk_report( $primary, $wt['path'], ! $is_primary, $created_at, $metadata ); + $stale_reason = $this->detect_worktree_stale_reason( ! $is_primary, $dirty_files, $disk['age_days'] ?? null, $created_at ); + if ( null !== $stale_reason ) { + $disk['stale_reason'] = $stale_reason; + } $lifecycle_state = is_array( $metadata ) ? ( $metadata['lifecycle_state'] ?? null ) : null; if ( null !== $state && $lifecycle_state !== $state ) { continue; } - $worktrees[] = array( + $worktrees[] = array_merge( array( 'handle' => $handle, 'repo' => $primary, 'is_worktree' => ! $is_primary, @@ -1540,12 +1551,12 @@ public function worktree_list( ?string $repo = null, ?string $state = null ): ar 'head' => $wt['head'], 'path' => $wt['path'], 'dirty' => $dirty_files, - 'created_at' => is_array( $metadata ) ? ( $metadata['created_at'] ?? null ) : null, + 'created_at' => $created_at, 'lifecycle_state' => $lifecycle_state, 'pr_url' => is_array( $metadata ) ? ( $metadata['pr_url'] ?? null ) : null, 'pr_number' => is_array( $metadata ) ? ( $metadata['pr_number'] ?? null ) : null, 'metadata' => $metadata, - ); + ), $disk ); } } @@ -1699,6 +1710,11 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $skip_github = ! empty( $opts['skip_github'] ); $apply_plan = isset( $opts['apply_plan'] ) && is_array( $opts['apply_plan'] ) ? $opts['apply_plan'] : null; $older_than = isset( $opts['older_than'] ) ? trim( (string) $opts['older_than'] ) : ''; + $sort = isset( $opts['sort'] ) ? trim( (string) $opts['sort'] ) : ''; + + if ( '' !== $sort && ! in_array( $sort, array( 'size', 'age' ), true ) ) { + return new \WP_Error( 'invalid_cleanup_sort', 'Invalid cleanup sort. Use size or age.', array( 'status' => 400 ) ); + } $planned_candidates = null; if ( null !== $apply_plan ) { @@ -1755,10 +1771,11 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $wt_path = $wt['path'] ?? ''; $metadata = $wt['metadata'] ?? null; $created_at = $wt['created_at'] ?? null; + $disk_fields = $this->extract_worktree_disk_fields( $wt ); $primary_path = '' !== $repo ? $this->get_primary_path( $repo ) : ''; if ( ! empty( $wt['external'] ) ) { - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'reason_code' => 'external_worktree', 'reason' => 'external worktree (outside workspace)', @@ -1770,7 +1787,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'primary_path' => $primary_path, 'created_at' => $created_at, 'metadata' => $metadata, - ); + ), $disk_fields ); continue; } @@ -1789,7 +1806,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $missing_fields[] = $field; } } - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'repo' => $repo, 'branch' => $branch, @@ -1800,12 +1817,12 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'hint' => 'Run workspace worktree prune if this is a stale registry entry; inspect manually if the path still exists.', 'created_at' => $created_at, 'metadata' => $metadata, - ); + ), $disk_fields ); continue; } if ( in_array( $branch, $protected_branches, true ) ) { - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'repo' => $repo, 'branch' => $branch, @@ -1814,12 +1831,12 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'reason' => sprintf( 'protected branch (%s)', $branch ), 'created_at' => $created_at, 'metadata' => $metadata, - ); + ), $disk_fields ); continue; } if ( $dirty_count > 0 && ! $force ) { - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'repo' => $repo, 'branch' => $branch, @@ -1829,7 +1846,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'dirty' => $dirty_count, 'created_at' => $created_at, 'metadata' => $metadata, - ); + ), $disk_fields ); continue; } @@ -1840,7 +1857,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro // harder problem than dirty-file loss, and this guard is cheap. $unpushed = $this->count_unpushed_commits( $wt_path ); if ( $unpushed > 0 ) { - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'repo' => $repo, 'branch' => $branch, @@ -1850,13 +1867,13 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'unpushed' => $unpushed, 'created_at' => $created_at, 'metadata' => $metadata, - ); + ), $disk_fields ); continue; } $primary_path = $this->get_primary_path( $repo ); if ( ! is_dir( $primary_path . '/.git' ) ) { - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'repo' => $repo, 'branch' => $branch, @@ -1866,7 +1883,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'hint' => 'Run workspace worktree prune if this is a stale registry entry; inspect manually if the path still exists.', 'created_at' => $created_at, 'metadata' => $metadata, - ); + ), $disk_fields ); continue; } @@ -1888,7 +1905,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $signal = $this->detect_merge_signal( $primary_path, $repo, $branch, $skip_github, $github_cache ); } if ( null === $signal ) { - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'repo' => $repo, 'branch' => $branch, @@ -1897,12 +1914,12 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'reason' => 'no merge signal — leaving in place', 'created_at' => $created_at, 'metadata' => $metadata, - ); + ), $disk_fields ); continue; } if ( 'github-unknown' === ( $signal['signal'] ?? '' ) ) { - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'repo' => $repo, 'branch' => $branch, @@ -1911,7 +1928,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'reason' => $signal['reason'], 'created_at' => $created_at, 'metadata' => $metadata, - ); + ), $disk_fields ); continue; } @@ -1920,7 +1937,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $created_ts = is_string( $created_at ) && '' !== $created_at ? strtotime( $created_at ) : false; if ( false === $created_ts ) { ++$age_filter['unknown_age']; - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'repo' => $repo, 'branch' => $branch, @@ -1935,7 +1952,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'threshold' => $age_filter['threshold'], 'decision' => 'unknown_age', ), - ); + ), $disk_fields ); continue; } @@ -1950,7 +1967,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro if ( $created_ts > $age_filter['threshold_unix'] ) { ++$age_filter['excluded']; - $skipped[] = array( + $skipped[] = array_merge( array( 'handle' => $handle, 'repo' => $repo, 'branch' => $branch, @@ -1960,14 +1977,14 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'created_at' => $created_at, 'metadata' => $metadata, 'age_filter' => array_merge( $age_decision, array( 'decision' => 'excluded' ) ), - ); + ), $disk_fields ); continue; } $age_decision['decision'] = 'included'; } - $candidate = array( + $candidate = array_merge( array( 'handle' => $wt['handle'], 'repo' => $repo, 'branch' => $branch, @@ -1979,13 +1996,15 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'pr_url' => $signal['pr_url'] ?? null, 'created_at' => $created_at, 'metadata' => $metadata, - ); + ), $disk_fields ); if ( null !== $age_decision ) { $candidate['age_filter'] = $age_decision; } $candidates[] = $candidate; } + $candidates = $this->sort_worktree_cleanup_rows( $candidates, $sort ); + if ( null !== $planned_candidates ) { $scoped = $this->scope_worktree_cleanup_to_plan( $planned_candidates, $candidates, $skipped ); $candidates = $scoped['candidates']; @@ -2034,6 +2053,7 @@ function () use ( $cand, $force ) { 'reason' => 'remove failed: ' . $remove->get_error_message(), 'created_at' => $cand['created_at'] ?? null, 'metadata' => $cand['metadata'] ?? null, + 'size_bytes' => $cand['size_bytes'] ?? null, ); continue; } @@ -2064,6 +2084,11 @@ function () use ( $cand, $force ) { private function build_worktree_cleanup_summary( array $candidates, array $removed, array $skipped, ?array $age_filter = null ): array { $skipped_by_reason = array(); $candidates_by_signal = array(); + $size_by_repo = array(); + $artifact_by_repo = array(); + $total_size_bytes = 0; + $total_artifact_bytes = 0; + $all_rows = array_merge( $candidates, $removed, $skipped ); foreach ( $skipped as $row ) { $code = (string) ( $row['reason_code'] ?? 'unknown' ); @@ -2075,15 +2100,38 @@ private function build_worktree_cleanup_summary( array $candidates, array $remov $candidates_by_signal[ $signal ] = ( $candidates_by_signal[ $signal ] ?? 0 ) + 1; } + foreach ( $all_rows as $row ) { + $repo = (string) ( $row['repo'] ?? 'unknown' ); + $size_bytes = isset( $row['size_bytes'] ) ? (int) $row['size_bytes'] : 0; + $artifact_bytes = isset( $row['artifact_size_bytes'] ) ? (int) $row['artifact_size_bytes'] : 0; + + if ( $size_bytes > 0 ) { + $size_by_repo[ $repo ] = ( $size_by_repo[ $repo ] ?? 0 ) + $size_bytes; + $total_size_bytes += $size_bytes; + } + if ( $artifact_bytes > 0 ) { + $artifact_by_repo[ $repo ] = ( $artifact_by_repo[ $repo ] ?? 0 ) + $artifact_bytes; + $total_artifact_bytes += $artifact_bytes; + } + } + ksort( $skipped_by_reason ); ksort( $candidates_by_signal ); + arsort( $size_by_repo ); + arsort( $artifact_by_repo ); $summary = array( - 'would_remove' => count( $candidates ), - 'removed' => count( $removed ), - 'skipped' => count( $skipped ), - 'skipped_by_reason' => $skipped_by_reason, - 'candidates_by_signal' => $candidates_by_signal, + 'would_remove' => count( $candidates ), + 'removed' => count( $removed ), + 'skipped' => count( $skipped ), + 'skipped_by_reason' => $skipped_by_reason, + 'candidates_by_signal' => $candidates_by_signal, + 'total_size_bytes' => $total_size_bytes, + 'artifact_size_bytes' => $total_artifact_bytes, + 'size_by_repo' => $size_by_repo, + 'artifact_size_by_repo' => $artifact_by_repo, + 'top_by_size' => $this->summarize_top_worktree_rows( $all_rows, 'size_bytes' ), + 'top_by_age' => $this->summarize_top_worktree_rows( $all_rows, 'age_days' ), ); if ( null !== $age_filter ) { @@ -2094,6 +2142,268 @@ private function build_worktree_cleanup_summary( array $candidates, array $remov return $summary; } + /** + * Determine a non-destructive stale reason for list/reporting surfaces. + * + * @param bool $is_worktree Whether the row is a worktree. + * @param int $dirty_files Dirty file count. + * @param int|null $age_days Whole-day age. + * @param string|null $created_at Lifecycle timestamp. + * @return string|null Stale reason code. + */ + private function detect_worktree_stale_reason( bool $is_worktree, int $dirty_files, ?int $age_days, ?string $created_at ): ?string { + if ( ! $is_worktree ) { + return null; + } + + if ( $dirty_files > 0 ) { + return 'dirty'; + } + + if ( null === $created_at || '' === $created_at || null === $age_days ) { + return 'missing_metadata'; + } + + $threshold = (int) apply_filters( 'datamachine_code_worktree_stale_days', 14 ); + if ( $threshold > 0 && $age_days >= $threshold ) { + return 'older_than_threshold'; + } + + return null; + } + + /** + * Build disk and artifact metadata for a worktree/list row. + * + * @param string $repo Primary repo name. + * @param string $path Worktree path. + * @param bool $is_worktree Whether the row is a linked worktree. + * @param string|null $created_at Lifecycle creation timestamp. + * @param array|null $metadata Stored lifecycle/context metadata. + * @return array + */ + private function build_worktree_disk_report( string $repo, string $path, bool $is_worktree, ?string $created_at, ?array $metadata ): array { + $size_bytes = $this->estimate_path_size_bytes( $path ); + $last_touched_at = $this->detect_worktree_last_touched_at( $path, $metadata, $created_at ); + $age_days = $this->calculate_age_days( $created_at ); + $artifacts = $is_worktree ? $this->detect_worktree_artifacts( $repo, $path ) : array(); + $artifact_bytes = array_sum( array_map( fn( $artifact ) => (int) ( $artifact['size_bytes'] ?? 0 ), $artifacts ) ); + + return array( + 'size_bytes' => $size_bytes, + 'estimated_size_bytes' => $size_bytes, + 'last_touched_at' => $last_touched_at, + 'age_days' => $age_days, + 'artifacts' => $artifacts, + 'artifact_size_bytes' => $artifact_bytes, + ); + } + + /** + * Pull disk fields from an existing worktree row for cleanup output. + * + * @param array $wt Worktree row. + * @return array + */ + private function extract_worktree_disk_fields( array $wt ): array { + return array( + 'size_bytes' => $wt['size_bytes'] ?? null, + 'estimated_size_bytes' => $wt['estimated_size_bytes'] ?? ( $wt['size_bytes'] ?? null ), + 'last_touched_at' => $wt['last_touched_at'] ?? null, + 'age_days' => $wt['age_days'] ?? null, + 'stale_reason' => $wt['stale_reason'] ?? null, + 'artifacts' => $wt['artifacts'] ?? array(), + 'artifact_size_bytes' => $wt['artifact_size_bytes'] ?? 0, + ); + } + + /** + * Estimate a path's on-disk size using the platform's fast `du` primitive. + * + * @param string $path Path to inspect. + * @return int|null Size in bytes, or null when unavailable. + */ + private function estimate_path_size_bytes( string $path ): ?int { + if ( '' === $path || ( ! file_exists( $path ) && ! is_link( $path ) ) ) { + return null; + } + + $output = array(); + $code = 1; + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( sprintf( 'du -sk %s 2>/dev/null', escapeshellarg( $path ) ), $output, $code ); + if ( 0 !== $code || empty( $output[0] ) ) { + return null; + } + + $parts = preg_split( '/\s+/', trim( (string) $output[0] ) ); + $kb = isset( $parts[0] ) && ctype_digit( $parts[0] ) ? (int) $parts[0] : 0; + return $kb > 0 ? $kb * 1024 : 0; + } + + /** + * Detect non-source artifact directories worth reporting separately. + * + * @param string $repo Repo name. + * @param string $path Worktree path. + * @return array> Artifact rows. + */ + private function detect_worktree_artifacts( string $repo, string $path ): array { + $patterns = $this->get_worktree_artifact_profile( $repo, $path ); + $rows = array(); + + foreach ( $patterns as $relative => $label ) { + $relative = trim( (string) $relative, '/' ); + if ( '' === $relative || str_contains( $relative, '..' ) ) { + continue; + } + + $artifact_path = rtrim( $path, '/' ) . '/' . $relative; + if ( ! is_dir( $artifact_path ) ) { + continue; + } + + $size = $this->estimate_path_size_bytes( $artifact_path ); + $rows[] = array( + 'path' => $relative, + 'label' => (string) $label, + 'size_bytes' => $size, + ); + } + + usort( $rows, fn( $a, $b ) => (int) ( $b['size_bytes'] ?? 0 ) <=> (int) ( $a['size_bytes'] ?? 0 ) ); + return $rows; + } + + /** + * Resolve repo-specific artifact profile paths. + * + * @param string $repo Repo name. + * @param string $path Worktree path. + * @return array Relative path => label map. + */ + private function get_worktree_artifact_profile( string $repo, string $path ): array { + $profile = array(); + + if ( is_file( rtrim( $path, '/' ) . '/Cargo.toml' ) || 'homeboy' === $repo ) { + $profile['target'] = 'Rust build artifacts'; + } + + if ( is_file( rtrim( $path, '/' ) . '/package.json' ) ) { + $profile['node_modules'] = 'Node dependencies'; + $profile['.next'] = 'Next.js build cache'; + $profile['dist'] = 'JavaScript build output'; + $profile['coverage'] = 'test coverage output'; + } + + if ( is_file( rtrim( $path, '/' ) . '/composer.json' ) ) { + $profile['vendor'] = 'Composer dependencies'; + } + + /** + * Filters non-source artifact paths reported for a workspace worktree. + * + * Reporting is non-destructive. Future cleanup actions should reuse this + * profile but add separate opt-in delete gates. + * + * @param array $profile Relative path => label map. + * @param string $repo Repo name. + * @param string $path Worktree path. + */ + $filtered = apply_filters( 'datamachine_code_worktree_artifact_profile', $profile, $repo, $path ); + return is_array( $filtered ) ? $filtered : $profile; + } + + /** + * Resolve the most useful last-touched timestamp for a worktree. + * + * @param string $path Worktree path. + * @param array|null $metadata Stored lifecycle/context metadata. + * @param string|null $created_at Created timestamp fallback. + * @return string|null ISO timestamp. + */ + private function detect_worktree_last_touched_at( string $path, ?array $metadata, ?string $created_at ): ?string { + foreach ( array( 'last_touched_at', 'updated_at', 'timestamp' ) as $key ) { + if ( is_array( $metadata ) && ! empty( $metadata[ $key ] ) && false !== strtotime( (string) $metadata[ $key ] ) ) { + return gmdate( 'c', (int) strtotime( (string) $metadata[ $key ] ) ); + } + } + + $git_marker = rtrim( $path, '/' ) . '/.git'; + $mtime = file_exists( $git_marker ) ? filemtime( $git_marker ) : false; + if ( false === $mtime && file_exists( $path ) ) { + $mtime = filemtime( $path ); + } + + if ( false !== $mtime ) { + return gmdate( 'c', (int) $mtime ); + } + + return $created_at; + } + + /** + * Calculate whole-day age from an ISO timestamp. + * + * @param string|null $created_at Created timestamp. + * @return int|null Whole days old, or null when unknown. + */ + private function calculate_age_days( ?string $created_at ): ?int { + if ( null === $created_at || '' === $created_at ) { + return null; + } + + $created_ts = strtotime( $created_at ); + if ( false === $created_ts ) { + return null; + } + + return max( 0, (int) floor( ( time() - $created_ts ) / 86400 ) ); + } + + /** + * Sort cleanup rows by requested reporting dimension. + * + * @param array $rows Rows to sort. + * @param string $sort size|age|empty. + * @return array + */ + private function sort_worktree_cleanup_rows( array $rows, string $sort ): array { + if ( 'size' === $sort ) { + usort( $rows, fn( $a, $b ) => (int) ( $b['size_bytes'] ?? 0 ) <=> (int) ( $a['size_bytes'] ?? 0 ) ); + } elseif ( 'age' === $sort ) { + usort( $rows, fn( $a, $b ) => (int) ( $b['age_days'] ?? -1 ) <=> (int) ( $a['age_days'] ?? -1 ) ); + } + + return $rows; + } + + /** + * Produce compact top-N rows for cleanup summaries. + * + * @param array $rows Rows to summarize. + * @param string $field Numeric field to sort by. + * @return array> + */ + private function summarize_top_worktree_rows( array $rows, string $field ): array { + $rows = array_values( array_filter( $rows, fn( $row ) => isset( $row[ $field ] ) && null !== $row[ $field ] && (int) $row[ $field ] > 0 ) ); + usort( $rows, fn( $a, $b ) => (int) ( $b[ $field ] ?? 0 ) <=> (int) ( $a[ $field ] ?? 0 ) ); + + return array_map( + fn( $row ) => array( + 'handle' => $row['handle'] ?? '', + 'repo' => $row['repo'] ?? '', + 'branch' => $row['branch'] ?? '', + 'path' => $row['path'] ?? '', + 'size_bytes' => $row['size_bytes'] ?? null, + 'artifact_size_bytes' => $row['artifact_size_bytes'] ?? 0, + 'age_days' => $row['age_days'] ?? null, + 'reason_code' => $row['reason_code'] ?? '', + ), + array_slice( $rows, 0, self::CLEANUP_SUMMARY_TOP_LIMIT ) + ); + } + /** * Parse a compact cleanup age duration. * @@ -2181,11 +2491,11 @@ private function scope_worktree_cleanup_to_plan( array $planned_candidates, arra $scoped_skipped = array(); foreach ( $planned_candidates as $plan_row ) { - $handle = (string) ( $plan_row['handle'] ?? '' ); + $handle = (string) ( $plan_row['handle'] ?? '' ); $current = $current_by_handle[ $handle ] ?? null; if ( null === $current ) { - $skip = $skipped_by_handle[ $handle ] ?? array( + $skip = $skipped_by_handle[ $handle ] ?? array( 'handle' => $handle, 'repo' => (string) ( $plan_row['repo'] ?? '' ), 'branch' => (string) ( $plan_row['branch'] ?? '' ), @@ -2366,7 +2676,7 @@ private function detect_merge_signal( string $primary_path, string $repo, string return null; } - $pr = $this->find_merged_pr_for_branch( $gh_slug, $branch, $github_cache ); + $pr = $this->find_closed_pr_for_branch( $gh_slug, $branch, $github_cache ); if ( is_wp_error( $pr ) ) { return array( 'signal' => 'github-unknown', @@ -2377,9 +2687,17 @@ private function detect_merge_signal( string $primary_path, string $repo, string return null; } + if ( ! empty( $pr['merged_at'] ) ) { + return array( + 'signal' => 'pr-merged', + 'reason' => sprintf( 'PR #%d merged (%s)', $pr['number'], $pr['state'] ), + 'pr_url' => $pr['html_url'] ?? null, + ); + } + return array( - 'signal' => 'pr-merged', - 'reason' => sprintf( 'PR #%d merged (%s)', $pr['number'], $pr['state'] ), + 'signal' => 'pr-closed', + 'reason' => sprintf( 'PR #%d closed without merge', $pr['number'] ), 'pr_url' => $pr['html_url'] ?? null, ); } @@ -2452,7 +2770,7 @@ private function resolve_github_slug( string $primary_path ): ?string { } /** - * Look up a merged PR for a branch via a cached GitHub API snapshot. + * Look up a closed PR for a branch via a cached GitHub API snapshot. * * Cleanup may inspect hundreds of worktrees for the same repo. Querying * GitHub once per branch does not scale, so each repo gets one bounded @@ -2463,7 +2781,7 @@ private function resolve_github_slug( string $primary_path ): ?string { * @param array $github_cache Run-local cache keyed by owner/repo. * @return array|null|\WP_Error PR data, null when no PR matched, or lookup failure. */ - private function find_merged_pr_for_branch( string $slug, string $branch, array &$github_cache = array() ): array|\WP_Error|null { + private function find_closed_pr_for_branch( string $slug, string $branch, array &$github_cache = array() ): array|\WP_Error|null { $lookup = $this->get_cleanup_github_lookup( $slug, $github_cache ); if ( is_wp_error( $lookup ) ) { return $lookup; @@ -2477,7 +2795,7 @@ private function find_merged_pr_for_branch( string $slug, string $branch, array } /** - * Load and cache merged same-repo PRs for a GitHub repo. + * Load and cache closed same-repo PRs for a GitHub repo. * * @param string $slug owner/repo. * @param array $github_cache Run-local cache keyed by owner/repo. @@ -2506,7 +2824,7 @@ private function get_cleanup_github_lookup( string $slug, array &$github_cache ) return null; } - $merged = array(); + $closed = array(); $url = GitHubRemote::apiUrl( $slug, 'pulls' ); for ( $page = 1; $page <= self::CLEANUP_GITHUB_MAX_PAGES; $page++ ) { @@ -2535,10 +2853,6 @@ private function get_cleanup_github_lookup( string $slug, array &$github_cache ) $items = (array) ( $response['data'] ?? array() ); foreach ( $items as $pr ) { - if ( empty( $pr['merged_at'] ) ) { - continue; - } - $head = is_array( $pr['head'] ?? null ) ? $pr['head'] : array(); $head_repo = is_array( $head['repo'] ?? null ) ? (string) ( $head['repo']['full_name'] ?? '' ) : ''; $head_ref = (string) ( $head['ref'] ?? '' ); @@ -2546,10 +2860,10 @@ private function get_cleanup_github_lookup( string $slug, array &$github_cache ) continue; } - $merged[ $head_ref ] = array( + $closed[ $head_ref ] = array( 'number' => (int) ( $pr['number'] ?? 0 ), 'state' => (string) ( $pr['state'] ?? 'closed' ), - 'merged_at' => (string) $pr['merged_at'], + 'merged_at' => (string) ( $pr['merged_at'] ?? '' ), 'html_url' => (string) ( $pr['html_url'] ?? '' ), ); } @@ -2559,8 +2873,8 @@ private function get_cleanup_github_lookup( string $slug, array &$github_cache ) } } - $github_cache[ $slug ] = $merged; - return $merged; + $github_cache[ $slug ] = $closed; + return $closed; } /** diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index a0d6403..f015532 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -76,6 +76,9 @@ function datamachine_code_cleanup_report(): array { 'repo' => 'repo', 'branch' => 'feature-' . $i, 'path' => '/workspace/repo@feature-' . $i, + 'age_days' => 20 + $i, + 'size_bytes' => 1024 * 1024 * $i, + 'artifact_size_bytes' => 512 * 1024 * $i, 'reason_code' => 1 === $i ? 'dirty_worktree' : 'no_merge_signal', 'reason' => 1 === $i ? 'working tree dirty (1 files) - pass force=true to override' : 'no merge signal - leaving in place', ); @@ -86,10 +89,12 @@ function datamachine_code_cleanup_report(): array { 'repo' => '', 'branch' => '', 'path' => '', - 'reason_code' => 'missing_metadata', - 'reason' => 'missing repo/branch/path', - 'missing_fields' => array( 'repo', 'branch', 'path' ), - 'hint' => 'Run workspace worktree prune if this is a stale registry entry; inspect manually if the path still exists.', + 'reason_code' => 'missing_metadata', + 'reason' => 'missing repo/branch/path', + 'missing_fields' => array( 'repo', 'branch', 'path' ), + 'hint' => 'Run workspace worktree prune if this is a stale registry entry; inspect manually if the path still exists.', + 'size_bytes' => 0, + 'artifact_size_bytes' => 0, ); return array( @@ -101,6 +106,9 @@ function datamachine_code_cleanup_report(): array { 'repo' => 'repo', 'branch' => 'merged', 'path' => '/workspace/repo@merged', + 'age_days' => 42, + 'size_bytes' => 4 * 1024 * 1024 * 1024, + 'artifact_size_bytes' => 3 * 1024 * 1024 * 1024, 'signal' => 'upstream-gone', 'reason_code' => 'upstream-gone', 'reason' => 'remote branch deleted (likely merged + auto-deleted)', @@ -120,6 +128,10 @@ function datamachine_code_cleanup_report(): array { 'candidates_by_signal' => array( 'upstream-gone' => 1, ), + 'total_size_bytes' => 10 * 1024 * 1024 * 1024, + 'artifact_size_bytes' => 7 * 1024 * 1024 * 1024, + 'size_by_repo' => array( 'repo' => 10 * 1024 * 1024 * 1024 ), + 'artifact_size_by_repo' => array( 'repo' => 7 * 1024 * 1024 * 1024 ), ), ); } @@ -144,14 +156,70 @@ public function execute( array $input ): array { } } + class FakeListAbility { + public function execute( array $input ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + return array( + 'success' => true, + 'worktrees' => array( + array( + 'handle' => 'repo', + 'repo' => 'repo', + 'is_primary' => true, + 'branch' => 'main', + 'head' => 'abcdef123', + 'dirty' => 0, + 'path' => '/workspace/repo', + ), + array( + 'handle' => 'repo@old', + 'repo' => 'repo', + 'is_primary' => false, + 'branch' => 'old', + 'head' => 'abcdef456', + 'dirty' => 0, + 'created_at' => '2026-04-01T00:00:00+00:00', + 'age_days' => 28, + 'size_bytes' => 4 * 1024 * 1024, + 'artifact_size_bytes' => 3 * 1024 * 1024, + 'artifacts' => array( array( 'path' => 'target', 'size_bytes' => 3 * 1024 * 1024 ) ), + 'stale_reason' => 'older_than_threshold', + 'path' => '/workspace/repo@old', + ), + array( + 'handle' => 'repo@dirty', + 'repo' => 'repo', + 'is_primary' => false, + 'branch' => 'dirty', + 'head' => 'abcdef789', + 'dirty' => 1, + 'age_days' => null, + 'size_bytes' => 1024, + 'artifact_size_bytes' => 0, + 'artifacts' => array(), + 'stale_reason' => 'dirty', + 'path' => '/workspace/repo@dirty', + ), + ), + ); + } + } + echo "=== smoke-worktree-cleanup-cli ===\n"; $ability = new FakeCleanupAbility(); + $list_ability = new FakeListAbility(); $GLOBALS['__abilities'] = array( 'datamachine/workspace-worktree-cleanup' => $ability, + 'datamachine/workspace-worktree-list' => $list_ability, ); $command = new \DataMachineCode\Cli\Commands\WorkspaceCommand(); + echo "\n[0] list stale output exposes disk fields\n"; + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree( array( 'list' ), array( 'stale' => true ) ); + datamachine_code_cleanup_assert( in_array( 'table:2:handle,repo,kind,branch,head,dirty,state,created_at,pr,age_days,size,artifacts,stale,path', WP_CLI::$logs, true ), 'worktree list --stale filters to stale rows and includes disk fields' ); + echo "\n[1] JSON output is one parseable document\n"; WP_CLI::$logs = array(); WP_CLI::$successes = array(); @@ -188,8 +256,9 @@ public function execute( array $input ): array { WP_CLI::$successes = array(); $command->worktree( array( 'cleanup' ), array( 'dry-run' => true, 'skip-github' => true ) ); datamachine_code_cleanup_assert( 'Summary:' === ( WP_CLI::$logs[0] ?? '' ), 'human output starts with summary' ); - datamachine_code_cleanup_assert( in_array( 'table:1:handle,branch,signal,reason_code', WP_CLI::$logs, true ), 'default candidate table uses compact fields' ); - datamachine_code_cleanup_assert( in_array( 'table:10:handle,reason_code,reason', WP_CLI::$logs, true ), 'default skipped table omits path and hint fields' ); + datamachine_code_cleanup_assert( in_array( 'table:1:handle,branch,age_days,size,artifacts,signal,reason_code', WP_CLI::$logs, true ), 'default candidate table uses compact disk fields' ); + datamachine_code_cleanup_assert( in_array( 'table:10:handle,reason_code,age_days,size,artifacts,reason', WP_CLI::$logs, true ), 'default skipped table omits path and hint fields but keeps disk fields' ); + datamachine_code_cleanup_assert( in_array( 'Top repos by worktree size:', WP_CLI::$logs, true ), 'human output includes top repo size summary' ); datamachine_code_cleanup_assert( in_array( 'Showing 10 of 12 skipped rows. Re-run with --verbose for all rows or --only= to filter.', WP_CLI::$logs, true ), 'human output truncates skipped rows with hint' ); datamachine_code_cleanup_assert( 1 === count( WP_CLI::$successes ), 'human output keeps success suffix' ); @@ -197,8 +266,8 @@ public function execute( array $input ): array { WP_CLI::$logs = array(); WP_CLI::$successes = array(); $command->worktree( array( 'cleanup' ), array( 'dry-run' => true, 'skip-github' => true, 'verbose' => true ) ); - datamachine_code_cleanup_assert( in_array( 'table:1:handle,branch,signal,reason', WP_CLI::$logs, true ), 'verbose candidate table keeps full reason field' ); - datamachine_code_cleanup_assert( in_array( 'table:12:handle,reason_code,reason,repo,branch,path,primary_path,missing,hint', WP_CLI::$logs, true ), 'verbose skipped table keeps diagnostic fields' ); + datamachine_code_cleanup_assert( in_array( 'table:1:handle,branch,age_days,size,artifacts,signal,reason', WP_CLI::$logs, true ), 'verbose candidate table keeps full reason field' ); + datamachine_code_cleanup_assert( in_array( 'table:12:handle,reason_code,reason,age_days,size,artifacts,repo,branch,path,primary_path,missing,hint', WP_CLI::$logs, true ), 'verbose skipped table keeps diagnostic fields' ); echo "\n[4] --only filters rows while keeping full summary\n"; WP_CLI::$logs = array(); @@ -227,7 +296,7 @@ public function execute( array $input ): array { WP_CLI::$successes = array(); $command->worktree( array( 'cleanup' ), array( 'dry-run' => true, 'skip-github' => true, 'older-than' => '7d' ) ); datamachine_code_cleanup_assert( array( 'dry_run' => true, 'force' => false, 'skip_github' => true, 'older_than' => '7d' ) === $ability->last_input, 'older-than forwards to cleanup ability as older_than' ); - datamachine_code_cleanup_assert( in_array( 'table:8:metric,count', WP_CLI::$logs, true ), 'age filter summary adds two summary rows' ); + datamachine_code_cleanup_assert( in_array( 'table:10:metric,count', WP_CLI::$logs, true ), 'age filter and disk summary rows are rendered' ); WP_CLI::$logs = array(); WP_CLI::$successes = array(); @@ -236,5 +305,11 @@ public function execute( array $input ): array { datamachine_code_cleanup_assert( '7d' === ( $older_than_json['summary']['age_filter']['older_than'] ?? '' ), 'JSON summary exposes older_than filter value' ); datamachine_code_cleanup_assert( 2 === (int) ( $older_than_json['summary']['age_filter']['excluded'] ?? 0 ), 'JSON summary exposes age-filter excluded count' ); + echo "\n[7] --sort forwards cleanup sorting field\n"; + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree( array( 'cleanup' ), array( 'dry-run' => true, 'skip-github' => true, 'sort' => 'size', 'format' => 'json' ) ); + datamachine_code_cleanup_assert( 'size' === ( $ability->last_input['sort'] ?? null ), '--sort forwards to cleanup ability' ); + echo "\nAll worktree cleanup CLI smoke tests passed.\n"; } diff --git a/tests/smoke-worktree-cleanup-github-cache.php b/tests/smoke-worktree-cleanup-github-cache.php index 264c84f..7406526 100644 --- a/tests/smoke-worktree-cleanup-github-cache.php +++ b/tests/smoke-worktree-cleanup-github-cache.php @@ -125,7 +125,7 @@ function size_format( $bytes ): string { echo " ✗ {$message}\n"; }; - $find = new \ReflectionMethod( Workspace::class, 'find_merged_pr_for_branch' ); + $find = new \ReflectionMethod( Workspace::class, 'find_closed_pr_for_branch' ); $detect = new \ReflectionMethod( Workspace::class, 'detect_merge_signal' ); $workspace = new Workspace(); $run = function ( string $command, string $cwd = '' ): string { @@ -165,20 +165,33 @@ function size_format( $bytes ): string { 'repo' => array( 'full_name' => 'someone/data-machine-code' ), ), ); + $closed_pr = array( + 'number' => 44, + 'state' => 'closed', + 'merged_at' => null, + 'html_url' => 'https://github.com/Extra-Chill/data-machine-code/pull/44', + 'head' => array( + 'ref' => 'closed-branch', + 'repo' => array( 'full_name' => 'Extra-Chill/data-machine-code' ), + ), + ); echo "=== smoke-worktree-cleanup-github-cache ===\n"; echo "\n[1] one repo lookup is cached across branch checks\n"; GitHubAbilities::$pat = 'test-token'; GitHubAbilities::$calls = array(); - GitHubAbilities::$responses = array( array( 'data' => array( $merged_pr, $fork_pr ) ) ); + GitHubAbilities::$responses = array( array( 'data' => array( $merged_pr, $fork_pr, $closed_pr ) ) ); $cache = array(); $hit = $find->invokeArgs( $workspace, array( 'Extra-Chill/data-machine-code', 'merged-branch', &$cache ) ); $miss = $find->invokeArgs( $workspace, array( 'Extra-Chill/data-machine-code', 'missing-branch', &$cache ) ); $fork = $find->invokeArgs( $workspace, array( 'Extra-Chill/data-machine-code', 'fork-branch', &$cache ) ); + $closed = $find->invokeArgs( $workspace, array( 'Extra-Chill/data-machine-code', 'closed-branch', &$cache ) ); $assert( 42, $hit['number'] ?? null, 'merged same-repo branch is found' ); + $assert( 44, $closed['number'] ?? null, 'closed same-repo branch is found' ); + $assert( '', $closed['merged_at'] ?? null, 'closed unmerged PR keeps empty merged_at' ); $assert( null, $miss, 'missing branch returns null' ); $assert( null, $fork, 'fork branch with same ref is ignored' ); $assert( 1, count( GitHubAbilities::$calls ), 'GitHub API called once for three branch checks' ); diff --git a/tests/smoke-worktree-cleanup.php b/tests/smoke-worktree-cleanup.php index f617afc..b8a93f6 100644 --- a/tests/smoke-worktree-cleanup.php +++ b/tests/smoke-worktree-cleanup.php @@ -185,7 +185,10 @@ function size_format( $bytes ): string { $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 && git commit -m init', $primary ); + $run( 'git add Cargo.toml .gitignore && git commit -m tooling', $primary ); $run( 'git branch -M main', $primary ); $run( 'git push -u origin main', $primary ); @@ -226,6 +229,8 @@ function size_format( $bytes ): string { // Dirty the dirty worktree. file_put_contents( $tmp . '/demo@dirty-branch/scratch.txt', 'dirty' ); + mkdir( $tmp . '/demo@merged-autodelete/target', 0755, true ); + file_put_contents( $tmp . '/demo@merged-autodelete/target/artifact.bin', str_repeat( 'x', 4096 ) ); \DataMachineCode\Workspace\WorktreeContextInjector::store_metadata( 'demo@merged-autodelete', @@ -293,6 +298,13 @@ function size_format( $bytes ): string { $metadata_item = array_values( $metadata_items )[0] ?? array(); $assert( '2026-04-25T00:00:00+00:00', $metadata_item['created_at'] ?? null, 'worktree list exposes creation metadata for agent runtime' ); $assert( 'agent-one', $metadata_item['metadata']['agent_slug'] ?? null, 'worktree list exposes agent metadata for agent runtime' ); + $assert( true, isset( $metadata_item['size_bytes'] ), 'worktree list exposes estimated size bytes' ); + $assert( true, isset( $metadata_item['age_days'] ), 'worktree list exposes age_days' ); + + $artifact_items = array_filter( $list['worktrees'] ?? array(), fn( $wt ) => ( $wt['handle'] ?? '' ) === 'demo@merged-autodelete' ); + $artifact_item = array_values( $artifact_items )[0] ?? array(); + $assert( true, (int) ( $artifact_item['artifact_size_bytes'] ?? 0 ) > 0, 'worktree list reports Rust target artifact size' ); + $assert( 'target', $artifact_item['artifacts'][0]['path'] ?? '', 'worktree list reports Rust target artifact path' ); $assert_contains( $plan['candidates'] ?? array(), 'demo@merged-autodelete', 'canonical merged worktree flagged prunable' ); $assert_contains( $plan['candidates'] ?? array(), 'demo@merged-recent', 'recent merged worktree is still prunable without age filter' ); @@ -320,6 +332,21 @@ function size_format( $bytes ): string { $assert( 4, (int) ( $plan['summary']['would_remove'] ?? 0 ), 'summary counts cleanup candidates' ); $assert( 1, (int) ( $plan['summary']['skipped_by_reason']['dirty_worktree'] ?? 0 ), 'summary counts dirty skips by reason' ); $assert( true, isset( $plan['summary']['skipped_by_reason']['no_merge_signal'] ), 'summary includes no_merge_signal bucket' ); + $assert( true, (int) ( $plan['summary']['total_size_bytes'] ?? 0 ) > 0, 'summary reports total worktree size bytes' ); + $assert( true, (int) ( $plan['summary']['artifact_size_bytes'] ?? 0 ) > 0, 'summary reports artifact size bytes' ); + $assert( true, ! empty( $plan['summary']['top_by_size'] ), 'summary reports top worktrees by size' ); + + $size_plan = $ws->worktree_cleanup_merged( + array( + 'dry_run' => true, + 'skip_github' => true, + 'sort' => 'size', + ) + ); + $assert( true, ! is_wp_error( $size_plan ) && ( $size_plan['success'] ?? false ), 'size-sorted dry_run returns success' ); + $first_size = (int) ( $size_plan['candidates'][0]['size_bytes'] ?? 0 ); + $second_size = (int) ( $size_plan['candidates'][1]['size_bytes'] ?? 0 ); + $assert( true, $first_size >= $second_size, 'sort=size orders cleanup candidates by size descending' ); $age_plan = $ws->worktree_cleanup_merged( array(