2323use DataMachineCode \Cleanup \CompositeCleanupRunEvidenceStore ;
2424use DataMachineCode \Cleanup \CleanupRunEvidenceStoreInterface ;
2525use DataMachineCode \Workspace \Workspace ;
26+ use DataMachineCode \Workspace \WorkspaceSafeCleanupOrchestrator ;
2627use DataMachineCode \Workspace \WorktreeContextInjector ;
2728use DataMachineCode \Workspace \WorkspaceMutationLock ;
2829
@@ -607,7 +608,7 @@ public function adopt_repo( array $args, array $assoc_args ): void {
607608 * ## OPTIONS
608609 *
609610 * <operation>
610- * : Cleanup operation. One of: <plan|apply|until-empty|run|status|resume|cancel|evidence>.
611+ * : Cleanup operation. One of: <safe| plan|apply|until-empty|run|status|resume|cancel|evidence>.
611612 * Existing task-backed controls remain: <run|status|resume|cancel|evidence>.
612613 *
613614 * [<run-id>]
@@ -628,17 +629,19 @@ public function adopt_repo( array $args, array $assoc_args ): void {
628629 * ---
629630 *
630631 * [--dry-run]
631- * : Run the selected cleanup review synchronously through workspace abilities.
632+ * : Run the selected cleanup review synchronously through workspace abilities. For
633+ * `safe`, preview all safe stages and stale lock pruning without removals.
632634 *
633635 * [--force]
634636 * : Pass force=true into the cleanup task params for modes that support it.
637+ * Refused by `safe`.
635638 *
636- * [--include-artifacts]
637- * : For `plan --mode=retention`, include artifact cleanup rows. Retention
638- * planning includes a bounded artifact inventory page by default; this flag
639- * remains accepted for explicitness and `--mode=artifacts` still creates an
640- * artifact-only plan. `--mode=stale-worktrees` never includes artifacts unless
641- * this flag is passed.
639+ * [--include-artifacts]
640+ * : For `plan --mode=retention`, include artifact cleanup rows. Retention
641+ * planning includes a bounded artifact inventory page by default; this flag
642+ * remains accepted for explicitness and `--mode=artifacts` still creates an
643+ * artifact-only plan. `--mode=stale-worktrees` never includes artifacts unless
644+ * this flag is passed.
642645 *
643646 * [--older-than=<duration>]
644647 * : Pass an age gate such as 7d or 24h into cleanup task params.
@@ -647,22 +650,22 @@ public function adopt_repo( array $args, array $assoc_args ): void {
647650 * : For `plan`, number of largest reclaimable paths to show in the upfront
648651 * summary. Defaults to 10.
649652 *
650- * [--limit=<count>]
651- * : For DB-backed `apply` / `resume`, maximum pending rows to process in this
652- * invocation (default 25, max 100). For `plan`, maximum worktrees to scan in
653- * each cleanup lane page. Plan pages default to 100 so huge workspaces return
654- * actionable JSON quickly. Use --exhaustive for a full audit.
653+ * [--limit=<count>]
654+ * : For DB-backed `apply` / `resume`, maximum pending rows to process in this
655+ * invocation (default 25, max 100). For `plan`, maximum worktrees to scan in
656+ * each cleanup lane page. Plan pages default to 100 so huge workspaces return
657+ * actionable JSON quickly. Use --exhaustive for a full audit.
655658 *
656- * [--offset=<count>]
657- * : Pagination offset (0-indexed) for bounded plan pages and artifact dry-run
658- * pages. Walk huge workspaces by feeding the previous response's
659- * `continuation.next_offset` until `continuation.complete` is true.
659+ * [--offset=<count>]
660+ * : Pagination offset (0-indexed) for bounded plan pages and artifact dry-run
661+ * pages. Walk huge workspaces by feeding the previous response's
662+ * `continuation.next_offset` until `continuation.complete` is true.
660663 *
661- * [--exhaustive]
662- * : For `plan`, request a full unbounded audit instead of the default bounded
663- * inventory-first page. For `--mode=artifacts --dry-run`, scan every worktree
664- * AND run per-worktree git status / unpushed-commit safety probes. Slow on
665- * huge workspaces; use sparingly for full audits.
664+ * [--exhaustive]
665+ * : For `plan`, request a full unbounded audit instead of the default bounded
666+ * inventory-first page. For `--mode=artifacts --dry-run`, scan every worktree
667+ * AND run per-worktree git status / unpushed-commit safety probes. Slow on
668+ * huge workspaces; use sparingly for full audits.
666669 *
667670 * [--safety-probes]
668671 * : For `--mode=artifacts --dry-run`, run the per-worktree git safety probes
@@ -688,6 +691,12 @@ public function adopt_repo( array $args, array $assoc_args ): void {
688691 * : For `cleanup until-empty --mode=artifacts`, stop before starting another
689692 * pass after this many seconds.
690693 *
694+ * [--passes=<count>]
695+ * : For `cleanup safe`, maximum child-drain passes per cycle. Defaults to 10.
696+ *
697+ * [--cycles=<count>]
698+ * : For `cleanup safe`, maximum safe cleanup cycles before stopping. Defaults to 5.
699+ *
691700 * [--format=<format>]
692701 * : Output format.
693702 * ---
@@ -700,6 +709,9 @@ public function adopt_repo( array $args, array $assoc_args ): void {
700709 *
701710 * ## EXAMPLES
702711 *
712+ * # Apply all currently safe DMC workspace cleanup and report blockers
713+ * wp datamachine-code workspace cleanup safe --format=json
714+ *
703715 * # Create a DB-backed cleanup plan for review
704716 * wp datamachine-code workspace cleanup plan --mode=retention
705717 *
@@ -746,11 +758,15 @@ public function adopt_repo( array $args, array $assoc_args ): void {
746758 public function cleanup ( array $ args , array $ assoc_args ): void {
747759 $ operation = (string ) ( $ args [0 ] ?? '' );
748760 if ( '' === $ operation ) {
749- WP_CLI ::error ('Usage: wp datamachine-code workspace cleanup <plan|apply|run|status|resume|cancel|evidence> [<run-id>] [--mode=<mode>] ' );
761+ WP_CLI ::error ('Usage: wp datamachine-code workspace cleanup <safe| plan|apply|run|status|resume|cancel|evidence> [<run-id>] [--mode=<mode>] ' );
750762 return ;
751763 }
752764
753765 switch ( $ operation ) {
766+ case 'safe ' :
767+ $ this ->run_cleanup_safe ($ assoc_args );
768+ return ;
769+
754770 case 'plan ' :
755771 $ this ->run_cleanup_plan ($ assoc_args );
756772 return ;
@@ -797,6 +813,78 @@ public function cleanup( array $args, array $assoc_args ): void {
797813 }
798814 }
799815
816+ private function run_cleanup_safe ( array $ assoc_args ): void {
817+ $ input = array (
818+ 'dry_run ' => ! empty ($ assoc_args ['dry-run ' ]),
819+ 'force ' => ! empty ($ assoc_args ['force ' ]),
820+ 'discard_unpushed ' => ! empty ($ assoc_args ['discard-unpushed ' ]),
821+ 'source ' => self ::CLEANUP_CLI_SOURCE ,
822+ );
823+ foreach ( array ( 'limit ' , 'passes ' , 'cycles ' ) as $ key ) {
824+ if ( isset ($ assoc_args [ $ key ]) ) {
825+ $ input [ $ key ] = (int ) $ assoc_args [ $ key ];
826+ }
827+ }
828+ if ( isset ($ assoc_args ['until-budget ' ]) && '' !== trim ( (string ) $ assoc_args ['until-budget ' ]) ) {
829+ $ input ['until_budget ' ] = trim ( (string ) $ assoc_args ['until-budget ' ]);
830+ }
831+
832+ $ orchestrator = new WorkspaceSafeCleanupOrchestrator ();
833+ $ result = $ orchestrator ->run ($ input );
834+ if ( is_wp_error ($ result ) ) {
835+ $ this ->render_workspace_error ($ result );
836+ return ;
837+ }
838+
839+ $ this ->render_cleanup_safe_result ($ result , $ assoc_args );
840+ }
841+
842+ private function render_cleanup_safe_result ( array $ result , array $ assoc_args ): void {
843+ if ( 'json ' === (string ) ( $ assoc_args ['format ' ] ?? '' ) ) {
844+ if ( empty ($ assoc_args ['verbose ' ]) ) {
845+ $ result ['steps ' ] = $ this ->compact_safe_cleanup_steps ( (array ) ( $ result ['steps ' ] ?? array () ) );
846+ }
847+ $ this ->renderer ()->json ($ result );
848+ return ;
849+ }
850+
851+ $ summary = (array ) ( $ result ['summary ' ] ?? array () );
852+ WP_CLI ::log ('Safe workspace cleanup: ' );
853+ $ this ->format_items (
854+ array (
855+ array ( 'metric ' => 'applied ' , 'value ' => ! empty ($ result ['applied ' ]) ? 'yes ' : 'no ' ),
856+ array ( 'metric ' => 'state ' , 'value ' => (string ) ( $ result ['state ' ] ?? '- ' ) ),
857+ array ( 'metric ' => 'cycles ' , 'value ' => (string ) ( $ summary ['cycles ' ] ?? 0 ) ),
858+ array ( 'metric ' => 'removed ' , 'value ' => (string ) ( $ summary ['removed ' ] ?? 0 ) ),
859+ array ( 'metric ' => 'would_remove ' , 'value ' => (string ) ( $ summary ['would_remove ' ] ?? 0 ) ),
860+ array ( 'metric ' => 'marked_cleanup_eligible ' , 'value ' => (string ) ( $ summary ['marked_cleanup_eligible ' ] ?? 0 ) ),
861+ array ( 'metric ' => 'bytes_reclaimed ' , 'value ' => $ this ->format_bytes ( (int ) ( $ summary ['bytes_reclaimed ' ] ?? 0 ) ) ),
862+ array ( 'metric ' => 'stale_lock_files_removed ' , 'value ' => (string ) ( $ summary ['lock_files_removed ' ] ?? 0 ) ),
863+ array ( 'metric ' => 'blockers ' , 'value ' => (string ) ( $ summary ['blocker_count ' ] ?? 0 ) ),
864+ ),
865+ array ( 'metric ' , 'value ' ),
866+ array ( 'format ' => 'table ' ),
867+ 'metric '
868+ );
869+
870+ $ blockers = (array ) ( $ result ['blockers ' ] ?? array () );
871+ if ( array () !== $ blockers ) {
872+ WP_CLI ::log ('Compact blockers: ' );
873+ $ this ->format_items ($ blockers , array ( 'reason_code ' , 'count ' ), array ( 'format ' => 'table ' ), 'reason_code ' );
874+ }
875+ }
876+
877+ private function compact_safe_cleanup_steps ( array $ steps ): array {
878+ $ compact = array ();
879+ foreach ( $ steps as $ key => $ step ) {
880+ if ( is_array ($ step ) ) {
881+ $ compact [ $ key ] = $ step ;
882+ }
883+ }
884+
885+ return $ compact ;
886+ }
887+
800888 private function run_cleanup_task ( array $ assoc_args ): void {
801889 if ( isset ($ assoc_args ['dry-run ' ]) ) {
802890 $ this ->run_cleanup_review ($ assoc_args );
0 commit comments