diff --git a/inc/Workspace/WorkspaceHygieneReport.php b/inc/Workspace/WorkspaceHygieneReport.php index 5e607d1..be7696f 100644 --- a/inc/Workspace/WorkspaceHygieneReport.php +++ b/inc/Workspace/WorkspaceHygieneReport.php @@ -726,24 +726,17 @@ private function summarize_workspace_cleanup( ?array $cleanup, ?array $error, ar private function build_workspace_fast_stats( array $worktrees, ?array $cleanup, array $size_report, bool $include_worktree_status ): array { $cleanup_candidates = (array) ( $cleanup['candidates'] ?? array() ); $cleanup_summary = (array) ( $cleanup['summary'] ?? array() ); - $safe_handles = array(); - foreach ( $cleanup_candidates as $candidate ) { - $handle = is_array($candidate) ? (string) ( $candidate['handle'] ?? '' ) : ''; - if ( '' !== $handle ) { - $safe_handles[ $handle ] = true; - } - } $counts = array( - 'total_candidates' => count($worktrees), - 'safe_removable_count' => count($cleanup_candidates), - 'valid_clean_count' => 0, - 'valid_dirty_count' => 0, - 'invalid_broken_orphan_count' => 0, - 'unmanaged_skipped_count' => 0, - 'dirty_probe_skipped_count' => 0, - 'known_worktree_count' => 0, - 'known_primary_count' => 0, + 'total_candidates' => count($worktrees), + 'cleanup_eligible_unprobed_count' => count($cleanup_candidates), + 'valid_clean_count' => 0, + 'valid_dirty_count' => 0, + 'invalid_broken_orphan_count' => 0, + 'unmanaged_skipped_count' => 0, + 'dirty_probe_skipped_count' => 0, + 'known_worktree_count' => 0, + 'known_primary_count' => 0, ); foreach ( $worktrees as $row ) { @@ -776,9 +769,6 @@ private function build_workspace_fast_stats( array $worktrees, ?array $cleanup, $dirty = $row['dirty'] ?? null; if ( null === $dirty ) { ++$counts['dirty_probe_skipped_count']; - if ( isset($safe_handles[ (string) ( $row['handle'] ?? '' ) ]) ) { - ++$counts['valid_clean_count']; - } continue; } @@ -800,11 +790,17 @@ private function build_workspace_fast_stats( array $worktrees, ?array $cleanup, $estimated_reclaimable += max(0, (int) ( is_array($candidate) ? ( $candidate['size_bytes'] ?? 0 ) : 0 )); } } + $safety_probe_status = $include_worktree_status ? 'worktree_status_requested' : 'not_run_inventory_only'; + $safety_probe_note = $include_worktree_status + ? 'Worktree status was requested for the inventory rows; destructive cleanup still revalidates each row before removal.' + : 'Cleanup-eligible rows came from cheap inventory only; bounded apply revalidates dirty state, unpushed commits, containment, and primary safety before deletion.'; return array( 'mode' => $include_worktree_status ? 'full_git_status' : 'cheap_metadata_first', 'partial' => ! $include_worktree_status || empty($size_report['scan_complete']), - 'status_probe_required_for_summary' => false, + 'safety_probe_status' => $safety_probe_status, + 'safety_probe_note' => $safety_probe_note, + 'status_probe_required_for_summary' => ! $include_worktree_status, 'counts' => $counts, 'estimated_reclaimable_bytes' => $estimated_reclaimable, 'estimated_reclaimable_human' => $this->format_bytes($estimated_reclaimable), diff --git a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php index 37a39c0..88b9a75 100644 --- a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php +++ b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php @@ -1782,9 +1782,17 @@ private function build_worktree_remove_failure_skip( array $candidate, \WP_Error * @param array $candidates Candidate rows. * @param array $removed Removed rows. * @param array $skipped Skipped rows. + * @param array|null $age_filter Optional age filter summary. + * @param string $candidate_bucket Bucket to use for candidate rows. * @return array */ - private function build_worktree_cleanup_summary( array $candidates, array $removed, array $skipped, ?array $age_filter = null ): array { + private function build_worktree_cleanup_summary( + array $candidates, + array $removed, + array $skipped, + ?array $age_filter = null, + string $candidate_bucket = WorktreeCleanupClassifier::BUCKET_SAFE_TO_REMOVE_NOW + ): array { $skipped_by_reason = array(); $candidates_by_signal = array(); $stale_reasons = array(); @@ -1840,7 +1848,7 @@ private function build_worktree_cleanup_summary( array $candidates, array $remov 'skipped' => count($skipped), 'skipped_by_reason' => $skipped_by_reason, 'skipped_next_commands' => $this->worktree_cleanup_skipped_next_commands($skipped_by_reason), - 'cleanup_buckets' => $this->worktree_cleanup_buckets(count($candidates), $candidates_by_signal, $skipped_by_reason), + 'cleanup_buckets' => $this->worktree_cleanup_buckets(count($candidates), $candidates_by_signal, $skipped_by_reason, $candidate_bucket), 'candidates_by_signal' => $candidates_by_signal, 'stale_reasons' => $stale_reasons, 'liveness' => $liveness, @@ -1944,10 +1952,16 @@ private function worktree_declares_submodules( string $path ): bool { * @param int $candidate_count Candidate row count. * @param array $candidates_by_signal Candidate signal counts. * @param array $skipped_by_reason Skipped reason counts. + * @param string $candidate_bucket Bucket to use for candidate rows. * @return array */ - private function worktree_cleanup_buckets( int $candidate_count, array $candidates_by_signal, array $skipped_by_reason ): array { - return WorktreeCleanupClassifier::buckets($candidate_count, $candidates_by_signal, $skipped_by_reason); + private function worktree_cleanup_buckets( + int $candidate_count, + array $candidates_by_signal, + array $skipped_by_reason, + string $candidate_bucket = WorktreeCleanupClassifier::BUCKET_SAFE_TO_REMOVE_NOW + ): array { + return WorktreeCleanupClassifier::buckets($candidate_count, $candidates_by_signal, $skipped_by_reason, $candidate_bucket); } /** diff --git a/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php b/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php index 08534a1..980579c 100644 --- a/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php +++ b/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php @@ -173,7 +173,8 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so $candidate = array_merge( $base_row, array( - 'dirty' => 0, + 'dirty' => null, + 'safety_probe_status' => 'not_run_inventory_only', ), WorktreeCleanupSignal::candidate_fields($signal ?? array(), true) ); @@ -186,7 +187,7 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so $candidates = $this->sort_worktree_cleanup_rows($candidates, $sort); $pagination = $this->build_worktree_cleanup_pagination($offset, $limit, $processed, $total, false, null); - $summary = $this->build_worktree_cleanup_summary($candidates, array(), $skipped, $age_filter); + $summary = $this->build_worktree_cleanup_summary($candidates, array(), $skipped, $age_filter, WorktreeCleanupClassifier::BUCKET_CLEANUP_ELIGIBLE_UNPROBED); if ( null !== $pagination ) { $summary['pagination'] = $pagination; } diff --git a/inc/Workspace/WorktreeCleanupClassifier.php b/inc/Workspace/WorktreeCleanupClassifier.php index 4a99035..cc1bfbc 100644 --- a/inc/Workspace/WorktreeCleanupClassifier.php +++ b/inc/Workspace/WorktreeCleanupClassifier.php @@ -16,6 +16,7 @@ final class WorktreeCleanupClassifier { public const BUCKET_SAFE_TO_REMOVE_NOW = 'safe_to_remove_now'; + public const BUCKET_CLEANUP_ELIGIBLE_UNPROBED = 'cleanup_eligible_pending_revalidation'; public const BUCKET_NEEDS_RECONCILIATION = 'needs_reconciliation'; public const BUCKET_NEEDS_FULL_REVIEW = 'needs_full_review'; public const BUCKET_BLOCKED_BY_DIRTY_OR_UNPUSHED = 'blocked_by_dirty_or_unpushed'; @@ -122,16 +123,24 @@ public static function bucket_for_reason( string $reason_code ): string { * @param int $candidate_count Candidate row count. * @param array $candidates_by_signal Candidate signal counts. * @param array $skipped_by_reason Skipped reason counts. + * @param string $candidate_bucket Bucket to use for candidate rows. * @return array */ - public static function buckets( int $candidate_count, array $candidates_by_signal, array $skipped_by_reason ): array { + public static function buckets( + int $candidate_count, + array $candidates_by_signal, + array $skipped_by_reason, + string $candidate_bucket = self::BUCKET_SAFE_TO_REMOVE_NOW + ): array { $buckets = array( self::BUCKET_ARTIFACT_ONLY_DIRTY => 0, self::BUCKET_BLOCKED_BY_DIRTY_OR_UNPUSHED => 0, + self::BUCKET_CLEANUP_ELIGIBLE_UNPROBED => 0, self::BUCKET_NEEDS_FULL_REVIEW => 0, self::BUCKET_NEEDS_RECONCILIATION => 0, - self::BUCKET_SAFE_TO_REMOVE_NOW => $candidate_count, + self::BUCKET_SAFE_TO_REMOVE_NOW => 0, ); + $buckets[ $candidate_bucket ] = ( $buckets[ $candidate_bucket ] ?? 0 ) + $candidate_count; foreach ( $skipped_by_reason as $reason_code => $count ) { $bucket = self::bucket_for_reason( (string) $reason_code ); diff --git a/inc/Workspace/WorktreeDiskBudget.php b/inc/Workspace/WorktreeDiskBudget.php index a933361..1dcdc67 100644 --- a/inc/Workspace/WorktreeDiskBudget.php +++ b/inc/Workspace/WorktreeDiskBudget.php @@ -203,12 +203,13 @@ private static function cleanup_recommendations( ?int $free_bytes, int $effectiv ), array( 'priority' => 2, - 'action' => 'review and apply bounded cleanup-eligible worktrees', + 'action' => 'review bounded cleanup-eligible worktrees; apply revalidates before removal', 'expected_reclaim_bytes' => $target_reclaim, 'expected_reclaim' => $target_human, 'command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25', 'preview_command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25', 'apply_command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --limit=25', + 'apply_note' => 'Apply runs fresh dirty, unpushed, containment, and primary safety probes and may skip rows that the cheap inventory review listed.', ), array( 'priority' => 3, diff --git a/tests/workspace-compact-output.php b/tests/workspace-compact-output.php index 8670dae..977b2df 100644 --- a/tests/workspace-compact-output.php +++ b/tests/workspace-compact-output.php @@ -163,10 +163,24 @@ function compact_output_large_rows( int $count ): array { 'success' => true, 'workspace_path' => '/workspace', 'disk' => array( 'free_bytes' => 999 ), + 'fast_stats' => array( + 'counts' => array( + 'cleanup_eligible_unprobed_count' => 40, + 'dirty_probe_skipped_count' => 40, + ), + 'safety_probe_status' => 'not_run_inventory_only', + ), 'worktrees' => array( 'worktrees' => 40, 'protected_dirty' => 20 ), 'locks' => array( 'active' => 2, 'stale' => 40, 'database' => array( 'locks' => $large_rows ) ), 'cleanup' => array( - 'summary' => array( 'would_remove' => 40, 'artifact_size_bytes' => 654321 ), + 'summary' => array( + 'would_remove' => 40, + 'artifact_size_bytes' => 654321, + 'cleanup_buckets' => array( + 'cleanup_eligible_pending_revalidation' => 40, + 'safe_to_remove_now' => 0, + ), + ), 'biggest_candidates' => $large_rows, ), 'size' => array( @@ -179,6 +193,10 @@ function compact_output_large_rows( int $count ): array { ); compact_output_assert(40 === ( $hygiene['worktrees']['worktrees'] ?? null ), 'Compact hygiene output must preserve worktree counts.'); +compact_output_assert(40 === ( $hygiene['fast_stats']['counts']['cleanup_eligible_unprobed_count'] ?? null ), 'Compact hygiene output must label cheap cleanup candidates as unprobed.'); +compact_output_assert(! isset($hygiene['fast_stats']['counts']['safe_removable_count']), 'Compact hygiene output must not expose misleading safe_removable_count for cheap inventory.'); +compact_output_assert(40 === ( $hygiene['cleanup']['summary']['cleanup_buckets']['cleanup_eligible_pending_revalidation'] ?? null ), 'Compact cleanup summary must preserve pending-revalidation bucket.'); +compact_output_assert(0 === ( $hygiene['cleanup']['summary']['cleanup_buckets']['safe_to_remove_now'] ?? null ), 'Compact cleanup summary must not mark unprobed inventory candidates safe.'); compact_output_assert(123456 === ( $hygiene['size']['total_bytes'] ?? null ), 'Compact hygiene output must preserve size bytes.'); compact_output_assert(40 === ( $hygiene['size']['entry_count'] ?? null ), 'Compact hygiene output must preserve size entry count.'); compact_output_assert(count((array) ( $hygiene['cleanup']['biggest_candidates'] ?? array() )) <= 5, 'Compact hygiene output must sample cleanup candidates.'); diff --git a/tests/worktree-cleanup-candidate-classifier.php b/tests/worktree-cleanup-candidate-classifier.php index 8988088..bed636b 100644 --- a/tests/worktree-cleanup-candidate-classifier.php +++ b/tests/worktree-cleanup-candidate-classifier.php @@ -8,9 +8,11 @@ require_once dirname(__DIR__) . '/inc/Workspace/WorktreeAgeFilter.php'; require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupSignal.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupClassifier.php'; require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupCandidateClassifier.php'; use DataMachineCode\Workspace\WorktreeAgeFilter; +use DataMachineCode\Workspace\WorktreeCleanupClassifier; use DataMachineCode\Workspace\WorktreeCleanupCandidateClassifier; function worktree_cleanup_candidate_assert_same( mixed $expected, mixed $actual, string $message ): void { @@ -86,4 +88,16 @@ function (): array { worktree_cleanup_candidate_assert_same('age_filter', $age_skip['row']['reason_code'], 'age skip reason_code matches cleanup contract'); worktree_cleanup_candidate_assert_same(1, $recent_age_filter['excluded'], 'age filter excluded counter is updated'); +$inventory_buckets = WorktreeCleanupClassifier::buckets( + 3, + array( 'cleanup_eligible' => 3 ), + array(), + WorktreeCleanupClassifier::BUCKET_CLEANUP_ELIGIBLE_UNPROBED +); +worktree_cleanup_candidate_assert_same(3, $inventory_buckets['cleanup_eligible_pending_revalidation'], 'inventory-only candidates are pending revalidation'); +worktree_cleanup_candidate_assert_same(0, $inventory_buckets['safe_to_remove_now'], 'inventory-only candidates are not labeled safe to remove now'); + +$probed_buckets = WorktreeCleanupClassifier::buckets(2, array(), array()); +worktree_cleanup_candidate_assert_same(2, $probed_buckets['safe_to_remove_now'], 'probed cleanup candidates keep the safe-to-remove bucket'); + echo "worktree-cleanup-candidate-classifier: ok\n"; diff --git a/tests/worktree-disk-budget.php b/tests/worktree-disk-budget.php index c40a2ac..e0ea4aa 100644 --- a/tests/worktree-disk-budget.php +++ b/tests/worktree-disk-budget.php @@ -45,8 +45,10 @@ function assert_true( bool $condition, string $message ): void { assert_true(in_array('studio wp datamachine-code workspace worktree emergency-cleanup --format=json', $commands, true), 'emergency cleanup report command is missing'); $bounded = $budget['cleanup_recommendations'][1]; + assert_true(str_contains($bounded['action'], 'apply revalidates'), 'bounded cleanup action must explain apply revalidation'); assert_true('studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25' === $bounded['preview_command'], 'bounded cleanup preview command is missing'); assert_true('studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --limit=25' === $bounded['apply_command'], 'bounded cleanup apply command is missing'); + assert_true(str_contains($bounded['apply_note'], 'may skip rows'), 'bounded cleanup apply note must explain dirty/unpushed revalidation can block removal'); fwrite(STDOUT, "worktree-disk-budget ok\n"); } catch (Throwable $e) {