@@ -664,13 +664,13 @@ private function summarize_workspace_worktrees( array $worktrees, ?array $cleanu
664664 $ summary ['duplicates ' ] = $ duplicates ;
665665
666666 if ( null !== $ cleanup ) {
667- $ by_reason = (array ) ( $ cleanup ['summary ' ]['skipped_by_reason ' ] ?? array () );
668- $ dirty_blockers = (int ) ( $ by_reason ['dirty_worktree ' ] ?? 0 );
669- $ unpushed_blockers = (int ) ( $ by_reason ['unpushed_commits ' ] ?? 0 );
670- $ summary ['protected_dirty ' ] = $ dirty_blockers ;
671- $ summary ['protected_unpushed ' ] = $ unpushed_blockers ;
672- $ summary ['missing_metadata ' ] = (int ) ( $ by_reason ['missing_metadata ' ] ?? 0 );
673- $ summary ['external ' ] = max ($ summary ['external ' ], (int ) ( $ by_reason ['external_worktree ' ] ?? 0 ));
667+ $ by_reason = (array ) ( $ cleanup ['summary ' ]['skipped_by_reason ' ] ?? array () );
668+ $ dirty_blockers = (int ) ( $ by_reason ['dirty_worktree ' ] ?? 0 );
669+ $ unpushed_blockers = (int ) ( $ by_reason ['unpushed_commits ' ] ?? 0 );
670+ $ summary ['protected_dirty ' ] = $ dirty_blockers ;
671+ $ summary ['protected_unpushed ' ] = $ unpushed_blockers ;
672+ $ summary ['missing_metadata ' ] = (int ) ( $ by_reason ['missing_metadata ' ] ?? 0 );
673+ $ summary ['external ' ] = max ($ summary ['external ' ], (int ) ( $ by_reason ['external_worktree ' ] ?? 0 ));
674674
675675 $ probe_source = ! empty ($ cleanup ['inventory_only ' ]) ? 'inventory_known ' : 'fresh_probe ' ;
676676 $ summary ['protected_count_probe_source ' ] = $ probe_source ;
@@ -721,22 +721,23 @@ private function summarize_workspace_cleanup( ?array $cleanup, ?array $error, ar
721721 }
722722 unset($ candidate );
723723 usort ($ candidates , fn ( $ a , $ b ) => (int ) ( $ b ['size_bytes ' ] ?? 0 ) <=> (int ) ( $ a ['size_bytes ' ] ?? 0 ));
724- $ summary = (array ) ( $ cleanup ['summary ' ] ?? array () );
725- $ skipped_reason = (array ) ( $ summary ['skipped_by_reason ' ] ?? array () );
726- $ probe_source = ! empty ($ cleanup ['inventory_only ' ]) ? 'inventory_known ' : 'fresh_probe ' ;
727- $ empty_blockers = array (
724+ $ summary = (array ) ( $ cleanup ['summary ' ] ?? array () );
725+ $ skipped_reason = (array ) ( $ summary ['skipped_by_reason ' ] ?? array () );
726+ $ probe_source = ! empty ($ cleanup ['inventory_only ' ]) ? 'inventory_known ' : 'fresh_probe ' ;
727+ $ candidates = $ this ->annotate_workspace_cleanup_candidates_for_hygiene ($ candidates , $ probe_source );
728+ $ empty_blockers = array (
728729 'dirty_worktree ' => 0 ,
729730 'unpushed_commits ' => 0 ,
730731 );
731- $ blocker_counts = array (
732+ $ blocker_counts = array (
732733 'inventory_known ' => $ empty_blockers ,
733734 'fresh_probe ' => $ empty_blockers ,
734735 );
735736 $ blocker_counts [ $ probe_source ] = array (
736737 'dirty_worktree ' => (int ) ( $ skipped_reason ['dirty_worktree ' ] ?? 0 ),
737738 'unpushed_commits ' => (int ) ( $ skipped_reason ['unpushed_commits ' ] ?? 0 ),
738739 );
739- return array (
740+ $ result = array (
740741 'included ' => true ,
741742 'dry_run ' => true ,
742743 'skip_github ' => true ,
@@ -748,6 +749,58 @@ private function summarize_workspace_cleanup( ?array $cleanup, ?array $error, ar
748749 'skipped_by_reason ' => $ skipped_reason ,
749750 'candidates_by_signal ' => $ summary ['candidates_by_signal ' ] ?? array (),
750751 );
752+
753+ $ expected_outcome = $ this ->workspace_cleanup_expected_outcome ($ summary , ! empty ($ cleanup ['inventory_only ' ]));
754+ if ( '' !== $ expected_outcome ) {
755+ $ result ['expected_outcome ' ] = $ expected_outcome ;
756+ }
757+
758+ return $ result ;
759+ }
760+
761+ /**
762+ * Add concise fresh-revalidation context to cleanup candidate examples.
763+ *
764+ * @param array<int,array<string,mixed>> $candidates Candidate rows.
765+ * @param string $probe_source inventory_known|fresh_probe.
766+ * @return array<int,array<string,mixed>>
767+ */
768+ private function annotate_workspace_cleanup_candidates_for_hygiene ( array $ candidates , string $ probe_source ): array {
769+ foreach ( $ candidates as &$ candidate ) {
770+ if ( 'inventory_known ' === $ probe_source ) {
771+ $ candidate ['fresh_revalidation_status ' ] = (string ) ( $ candidate ['safety_probe_status ' ] ?? 'not_run_inventory_only ' );
772+ $ candidate ['fresh_revalidation_blockers ' ] = array ( 'pending_fresh_revalidation ' );
773+ $ candidate ['fresh_revalidation_checks ' ] = array ( 'dirty_worktree ' , 'unpushed_commits ' , 'primary_or_protected_worktree ' , 'containment_failure ' );
774+ continue ;
775+ }
776+
777+ $ candidate ['fresh_revalidation_status ' ] = 'passed ' ;
778+ if ( ! array_key_exists ('unpushed ' , $ candidate ) ) {
779+ $ candidate ['unpushed ' ] = 0 ;
780+ }
781+ }
782+ unset($ candidate );
783+
784+ return $ candidates ;
785+ }
786+
787+ /**
788+ * Explain what the suggested cleanup/apply path can do when hygiene has no fresh-safe rows.
789+ *
790+ * @param array<string,mixed> $summary Cleanup summary.
791+ * @param bool $inventory_only Whether hygiene used inventory-only cleanup.
792+ */
793+ private function workspace_cleanup_expected_outcome ( array $ summary , bool $ inventory_only ): string {
794+ $ fresh_safe = (int ) ( $ summary ['fresh_safe_removable_count ' ] ?? 0 );
795+ $ pending = (int ) ( $ summary ['cleanup_buckets ' ][ WorktreeCleanupClassifier::BUCKET_CLEANUP_ELIGIBLE_UNPROBED ] ?? 0 );
796+ if ( ! $ inventory_only || $ fresh_safe > 0 || $ pending <= 0 ) {
797+ return '' ;
798+ }
799+
800+ return sprintf (
801+ 'Expected outcome: hygiene found %d cleanup-eligible row(s) pending fresh revalidation and 0 fresh-safe removals. The bounded apply path will remove only rows that still pass dirty, unpushed, primary/protected, and containment checks; blockers are skipped with fresh reason codes. ' ,
802+ $ pending
803+ );
751804 }
752805
753806 /**
@@ -764,18 +817,18 @@ private function build_workspace_fast_stats( array $worktrees, ?array $cleanup,
764817 $ cleanup_summary = (array ) ( $ cleanup ['summary ' ] ?? array () );
765818
766819 $ counts = array (
767- 'total_candidates ' => count ($ worktrees ),
768- 'cleanup_eligible_unprobed_count ' => count ($ cleanup_candidates ),
769- 'valid_clean_count ' => 0 ,
770- 'valid_dirty_count ' => 0 ,
771- 'inventory_known_dirty_count ' => 0 ,
772- 'inventory_known_blocker_count ' => 0 ,
773- 'fresh_probed_blocker_count ' => 0 ,
774- 'invalid_broken_orphan_count ' => 0 ,
775- 'unmanaged_skipped_count ' => 0 ,
776- 'dirty_probe_skipped_count ' => 0 ,
777- 'known_worktree_count ' => 0 ,
778- 'known_primary_count ' => 0 ,
820+ 'total_candidates ' => count ($ worktrees ),
821+ 'cleanup_eligible_unprobed_count ' => count ($ cleanup_candidates ),
822+ 'valid_clean_count ' => 0 ,
823+ 'valid_dirty_count ' => 0 ,
824+ 'inventory_known_dirty_count ' => 0 ,
825+ 'inventory_known_blocker_count ' => 0 ,
826+ 'fresh_probed_blocker_count ' => 0 ,
827+ 'invalid_broken_orphan_count ' => 0 ,
828+ 'unmanaged_skipped_count ' => 0 ,
829+ 'dirty_probe_skipped_count ' => 0 ,
830+ 'known_worktree_count ' => 0 ,
831+ 'known_primary_count ' => 0 ,
779832 );
780833
781834 foreach ( $ worktrees as $ row ) {
@@ -843,8 +896,8 @@ private function build_workspace_fast_stats( array $worktrees, ?array $cleanup,
843896 return array (
844897 'mode ' => $ include_worktree_status ? 'full_git_status ' : 'cheap_metadata_first ' ,
845898 'partial ' => ! $ include_worktree_status || empty ($ size_report ['scan_complete ' ]),
846- 'safety_probe_status ' => $ safety_probe_status ,
847- 'safety_probe_note ' => $ safety_probe_note ,
899+ 'safety_probe_status ' => $ safety_probe_status ,
900+ 'safety_probe_note ' => $ safety_probe_note ,
848901 'status_probe_required_for_summary ' => ! $ include_worktree_status ,
849902 'counts ' => $ counts ,
850903 'estimated_reclaimable_bytes ' => $ estimated_reclaimable ,
0 commit comments