From 27a4905b880e12c99b95b3dd0a97b7019245cbbb Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:39:02 -0400 Subject: [PATCH 1/2] Fix retention cleanup plan worktree rows --- inc/Cli/Commands/WorkspaceCommand.php | 8 + inc/Workspace/WorkspaceCleanupPlan.php | 1 - .../WorkspaceWorktreeCleanupEngine.php | 32 +++- ...eanup-retention-plan-worktree-removals.php | 139 ++++++++++++++++++ 4 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 tests/cleanup-retention-plan-worktree-removals.php diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index d898b25b..6ac75486 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -660,6 +660,11 @@ public function adopt_repo( array $args, array $assoc_args ): void { * pages. Walk huge workspaces by feeding the previous response's * `continuation.next_offset` until `continuation.complete` is true. * + * [--until-budget=] + * : For bounded retention plans, cap the worktree safety-probe page by wall + * clock budget, such as 30s or 2m. The stored plan page remains reviewable + * and applyable through `cleanup apply `. + * * [--exhaustive] * : For `plan`, request a full unbounded audit instead of the default bounded * inventory-first page. For `--mode=artifacts --dry-run`, scan every worktree @@ -1235,6 +1240,9 @@ private function cleanup_plan_input( string $mode, array $assoc_args ): array { if ( ! empty($assoc_args['exhaustive']) ) { $input['full_workspace'] = true; } + if ( isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ) { + $input['until_budget'] = trim( (string) $assoc_args['until-budget']); + } if ( 'stale-worktrees' === $mode ) { $input['worktree_stale_only'] = true; if ( empty($input['worktree_older_than']) ) { diff --git a/inc/Workspace/WorkspaceCleanupPlan.php b/inc/Workspace/WorkspaceCleanupPlan.php index 11da3b71..f8f333f0 100644 --- a/inc/Workspace/WorkspaceCleanupPlan.php +++ b/inc/Workspace/WorkspaceCleanupPlan.php @@ -81,7 +81,6 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error $worktree_args = array( 'dry_run' => true, 'skip_github' => true, - 'inventory_only' => ! $inputs['full_workspace'], 'older_than' => $inputs['worktree_older_than'], 'sort' => $inputs['worktree_sort'], 'stale_liveness_only' => $inputs['worktree_stale_only'], diff --git a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php index cd32f7f6..f989fd21 100644 --- a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php +++ b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php @@ -72,7 +72,11 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro if ( isset($opts['until_budget']) && '' !== trim( (string) $opts['until_budget']) ) { if ( ! $dry_run ) { - return new \WP_Error('cleanup_budget_requires_dry_run', 'Budgeted cleanup is review-only. Use --dry-run with --until-budget, then apply a reviewed cleanup path.', array( 'status' => 400 )); + return new \WP_Error( + 'cleanup_budget_requires_dry_run', + sprintf('Budgeted cleanup is review-only. Run `%s`, review the DB-backed rows, then run `studio wp datamachine-code workspace cleanup apply `.', $this->build_worktree_cleanup_review_plan_command($limit, $offset, trim( (string) $opts['until_budget']))), + array( 'status' => 400 ) + ); } $budget_seconds = $this->parse_worktree_metadata_reconciliation_budget(trim( (string) $opts['until_budget'])); @@ -83,7 +87,11 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro } if ( ( null !== $limit || $offset > 0 ) && ! $dry_run ) { - return new \WP_Error('cleanup_pagination_requires_dry_run', 'Paginated cleanup is review-only. Use --dry-run with --limit/--offset, then apply a reviewed cleanup path.', array( 'status' => 400 )); + return new \WP_Error( + 'cleanup_pagination_requires_dry_run', + sprintf('Paginated cleanup is review-only. Run `%s`, review the DB-backed rows, then run `studio wp datamachine-code workspace cleanup apply `.', $this->build_worktree_cleanup_review_plan_command($limit, $offset, isset($opts['until_budget']) ? trim( (string) $opts['until_budget']) : '')), + array( 'status' => 400 ) + ); } if ( '' !== $sort && ! in_array($sort, array( 'size', 'age' ), true) ) { @@ -516,6 +524,11 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $summary = $this->build_worktree_cleanup_summary($candidates, array(), $skipped, $age_filter); $pagination = $this->build_worktree_cleanup_pagination($offset, $limit, $processed, $total_worktrees, $budget_stopped, $budget_context); if ( null !== $pagination ) { + $pagination['reviewed_apply_path'] = array( + 'plan_command' => $this->build_worktree_cleanup_review_plan_command($limit, $offset, null !== $budget_context ? (string) ( $budget_context['label'] ?? '' ) : ''), + 'apply_command' => 'studio wp datamachine-code workspace cleanup apply ', + 'note' => 'The plan command stores this reviewed page as DB-backed cleanup rows; apply revalidates each row before deletion.', + ); $summary['pagination'] = $pagination; } @@ -637,6 +650,21 @@ private function build_worktree_cleanup_pagination( int $offset, ?int $limit, in ); } + /** + * Build the DB-backed review command for bounded worktree cleanup rows. + */ + private function build_worktree_cleanup_review_plan_command( ?int $limit, int $offset, string $until_budget ): string { + $parts = array( + 'studio wp datamachine-code workspace cleanup plan --mode=retention', + null === $limit ? null : '--limit=' . max(1, $limit), + '--offset=' . max(0, $offset), + '' !== trim($until_budget) ? '--until-budget=' . trim($until_budget) : null, + '--format=json', + ); + + return implode(' ', array_values(array_filter($parts, fn( $part ) => null !== $part))); + } + /** * Collapse duplicate cleanup rows emitted by overlapping inventory/git sources. * diff --git a/tests/cleanup-retention-plan-worktree-removals.php b/tests/cleanup-retention-plan-worktree-removals.php new file mode 100644 index 00000000..2428f2bd --- /dev/null +++ b/tests/cleanup-retention-plan-worktree-removals.php @@ -0,0 +1,139 @@ + true, + 'dry_run' => true, + 'candidates' => array(), + 'skipped' => array(), + 'summary' => array(), + ); + } + + public function worktree_cleanup_merged( array $opts = array() ): array { + $this->last_worktree_cleanup_opts = $opts; + + return array( + 'success' => true, + 'dry_run' => true, + 'candidates' => array( + array( + 'handle' => 'repo@local-merged', + 'repo' => 'repo', + 'branch' => 'local-merged', + 'path' => '/tmp/dmc-retention-plan-contract/repo@local-merged', + 'signal' => 'local-merged', + 'reason_code' => 'local-merged', + 'size_bytes' => 100, + ), + array( + 'handle' => 'repo@remote-clean', + 'repo' => 'repo', + 'branch' => 'remote-clean', + 'path' => '/tmp/dmc-retention-plan-contract/repo@remote-clean', + 'signal' => 'remote-tracking-clean', + 'reason_code' => 'remote-tracking-clean', + 'size_bytes' => 200, + ), + array( + 'handle' => 'repo@upstream-gone', + 'repo' => 'repo', + 'branch' => 'upstream-gone', + 'path' => '/tmp/dmc-retention-plan-contract/repo@upstream-gone', + 'signal' => 'upstream-gone', + 'reason_code' => 'upstream-gone', + 'size_bytes' => 300, + ), + ), + 'skipped' => array(), + 'summary' => array( + 'fresh_safe_removable_count' => 3, + ), + 'pagination' => array( + 'total' => 5, + 'offset' => 0, + 'limit' => 3, + 'scanned' => 3, + 'partial' => true, + 'complete' => false, + 'next_offset' => 3, + 'next_command' => 'studio wp datamachine-code workspace worktree cleanup --dry-run --limit=3 --offset=3 --until-budget=45s --format=json', + 'budget_stopped' => false, + ), + ); + } + + private function stable_cleanup_hash( array $data, string $prefix ): string { + return $prefix . '-' . substr(hash('sha256', wp_json_encode($data)), 0, 12); + } +} + +if ( ! function_exists('wp_json_encode') ) { + function wp_json_encode( $data, $options = 0, $depth = 512 ) { + return json_encode($data, $options, $depth); + } +} + +$workspace = new CleanupRetentionPlanWorktreeRemovalWorkspace(); +$plan = $workspace->workspace_cleanup_plan( + array( + 'include_artifacts' => false, + 'limit' => 3, + 'offset' => 0, + 'until_budget' => '45s', + ) +); + +cleanup_retention_plan_assert(is_array($plan), 'retention cleanup plan should return an array'); +cleanup_retention_plan_assert(empty($workspace->last_worktree_cleanup_opts['inventory_only']), 'retention plan should run the probed worktree cleanup preview, not inventory-only cleanup'); +cleanup_retention_plan_assert(3 === (int) ( $workspace->last_worktree_cleanup_opts['limit'] ?? 0 ), 'retention plan should pass the bounded page limit to worktree cleanup'); +cleanup_retention_plan_assert('45s' === ( $workspace->last_worktree_cleanup_opts['until_budget'] ?? null ), 'retention plan should pass the budget to worktree cleanup'); + +$rows = (array) ( $plan['rows']['worktree_removal'] ?? array() ); +cleanup_retention_plan_assert(3 === count($rows), 'retention plan should preserve fresh safe worktree cleanup candidates as removal rows'); + +$signals = array_column($rows, 'signal', 'handle'); +cleanup_retention_plan_assert('local-merged' === ( $signals['repo@local-merged'] ?? null ), 'local-merged candidates should be applyable worktree_removal rows'); +cleanup_retention_plan_assert('remote-tracking-clean' === ( $signals['repo@remote-clean'] ?? null ), 'remote-tracking-clean candidates should be applyable worktree_removal rows'); +cleanup_retention_plan_assert('upstream-gone' === ( $signals['repo@upstream-gone'] ?? null ), 'upstream-gone candidates should be applyable worktree_removal rows'); +cleanup_retention_plan_assert(3 === (int) ( $plan['summary']['rows_by_action']['remove_worktree'] ?? 0 ), 'remove_worktree action rows should match reviewed safe rows'); +cleanup_retention_plan_assert('studio wp datamachine-code workspace cleanup apply ' === ( $plan['summary']['apply_command'] ?? '' ), 'retention plan should expose the DB-backed apply command'); + +$engine = new class { + use WorkspaceWorktreeCleanupEngine; +}; +$review_command = ( new ReflectionMethod($engine, 'build_worktree_cleanup_review_plan_command') )->invoke($engine, 100, 25, '180s'); +cleanup_retention_plan_assert( + 'studio wp datamachine-code workspace cleanup plan --mode=retention --limit=100 --offset=25 --until-budget=180s --format=json' === $review_command, + 'bounded worktree cleanup should point to the DB-backed reviewed apply path for the same page' +); + +fwrite(STDOUT, "cleanup-retention-plan-worktree-removals ok\n"); From a8e49900a193bf104d3c5f4b0ea9296601f14805 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:04:41 -0400 Subject: [PATCH 2/2] Fix cleanup lint alignment --- inc/Workspace/WorkspaceWorktreeCleanupEngine.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php index f989fd21..81bfb100 100644 --- a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php +++ b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php @@ -529,7 +529,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro 'apply_command' => 'studio wp datamachine-code workspace cleanup apply ', 'note' => 'The plan command stores this reviewed page as DB-backed cleanup rows; apply revalidates each row before deletion.', ); - $summary['pagination'] = $pagination; + $summary['pagination'] = $pagination; } if ( $dry_run ) {