Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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=<duration>]
* : 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 <run-id>`.
*
* [--exhaustive]
* : For `plan`, request a full unbounded audit instead of the default bounded
* inventory-first page. For `--mode=artifacts --dry-run`, scan every worktree
Expand Down Expand Up @@ -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']) ) {
Expand Down
1 change: 0 additions & 1 deletion inc/Workspace/WorkspaceCleanupPlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
34 changes: 31 additions & 3 deletions inc/Workspace/WorkspaceWorktreeCleanupEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <run-id>`.', $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']));
Expand All @@ -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 <run-id>`.', $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) ) {
Expand Down Expand Up @@ -516,7 +524,12 @@ 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 ) {
$summary['pagination'] = $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 <run-id>',
'note' => 'The plan command stores this reviewed page as DB-backed cleanup rows; apply revalidates each row before deletion.',
);
$summary['pagination'] = $pagination;
}

if ( $dry_run ) {
Expand Down Expand Up @@ -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.
*
Expand Down
139 changes: 139 additions & 0 deletions tests/cleanup-retention-plan-worktree-removals.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

declare(strict_types=1);

if ( ! defined('ABSPATH') ) {
define('ABSPATH', __DIR__ . '/fixtures/');
}

require_once dirname(__DIR__) . '/vendor/autoload.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceCleanupPlan.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeCleanupEngine.php';

use DataMachineCode\Workspace\WorkspaceCleanupPlan;
use DataMachineCode\Workspace\WorkspaceWorktreeCleanupEngine;

function cleanup_retention_plan_assert( bool $condition, string $message ): void {
if ( ! $condition ) {
throw new RuntimeException($message);
}
}

final class CleanupRetentionPlanWorktreeRemovalWorkspace {
use WorkspaceCleanupPlan;

public const CLEANUP_PLAN_DEFAULT_LIMIT = 100;
public const CLEANUP_PLAN_DEFAULT_BUDGET = '30s';

private string $workspace_path = '/tmp/dmc-retention-plan-contract';
public array $last_worktree_cleanup_opts = array();

public function worktree_cleanup_artifacts( array $opts = array() ): array {
return array(
'success' => 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 <run-id>' === ( $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");
Loading