Skip to content

Commit c551383

Browse files
Fix retention cleanup plan worktree rows (#854)
* Fix retention cleanup plan worktree rows * Fix cleanup lint alignment --------- Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent c0099aa commit c551383

4 files changed

Lines changed: 178 additions & 4 deletions

File tree

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,11 @@ public function adopt_repo( array $args, array $assoc_args ): void {
660660
* pages. Walk huge workspaces by feeding the previous response's
661661
* `continuation.next_offset` until `continuation.complete` is true.
662662
*
663+
* [--until-budget=<duration>]
664+
* : For bounded retention plans, cap the worktree safety-probe page by wall
665+
* clock budget, such as 30s or 2m. The stored plan page remains reviewable
666+
* and applyable through `cleanup apply <run-id>`.
667+
*
663668
* [--exhaustive]
664669
* : For `plan`, request a full unbounded audit instead of the default bounded
665670
* 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 {
12351240
if ( ! empty($assoc_args['exhaustive']) ) {
12361241
$input['full_workspace'] = true;
12371242
}
1243+
if ( isset($assoc_args['until-budget']) && '' !== trim( (string) $assoc_args['until-budget']) ) {
1244+
$input['until_budget'] = trim( (string) $assoc_args['until-budget']);
1245+
}
12381246
if ( 'stale-worktrees' === $mode ) {
12391247
$input['worktree_stale_only'] = true;
12401248
if ( empty($input['worktree_older_than']) ) {

inc/Workspace/WorkspaceCleanupPlan.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ public function workspace_cleanup_plan( array $opts = array() ): array|\WP_Error
8181
$worktree_args = array(
8282
'dry_run' => true,
8383
'skip_github' => true,
84-
'inventory_only' => ! $inputs['full_workspace'],
8584
'older_than' => $inputs['worktree_older_than'],
8685
'sort' => $inputs['worktree_sort'],
8786
'stale_liveness_only' => $inputs['worktree_stale_only'],

inc/Workspace/WorkspaceWorktreeCleanupEngine.php

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro
7272

7373
if ( isset($opts['until_budget']) && '' !== trim( (string) $opts['until_budget']) ) {
7474
if ( ! $dry_run ) {
75-
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 ));
75+
return new \WP_Error(
76+
'cleanup_budget_requires_dry_run',
77+
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']))),
78+
array( 'status' => 400 )
79+
);
7680
}
7781

7882
$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
8387
}
8488

8589
if ( ( null !== $limit || $offset > 0 ) && ! $dry_run ) {
86-
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 ));
90+
return new \WP_Error(
91+
'cleanup_pagination_requires_dry_run',
92+
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']) : '')),
93+
array( 'status' => 400 )
94+
);
8795
}
8896

8997
if ( '' !== $sort && ! in_array($sort, array( 'size', 'age' ), true) ) {
@@ -516,7 +524,12 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro
516524
$summary = $this->build_worktree_cleanup_summary($candidates, array(), $skipped, $age_filter);
517525
$pagination = $this->build_worktree_cleanup_pagination($offset, $limit, $processed, $total_worktrees, $budget_stopped, $budget_context);
518526
if ( null !== $pagination ) {
519-
$summary['pagination'] = $pagination;
527+
$pagination['reviewed_apply_path'] = array(
528+
'plan_command' => $this->build_worktree_cleanup_review_plan_command($limit, $offset, null !== $budget_context ? (string) ( $budget_context['label'] ?? '' ) : ''),
529+
'apply_command' => 'studio wp datamachine-code workspace cleanup apply <run-id>',
530+
'note' => 'The plan command stores this reviewed page as DB-backed cleanup rows; apply revalidates each row before deletion.',
531+
);
532+
$summary['pagination'] = $pagination;
520533
}
521534

522535
if ( $dry_run ) {
@@ -637,6 +650,21 @@ private function build_worktree_cleanup_pagination( int $offset, ?int $limit, in
637650
);
638651
}
639652

653+
/**
654+
* Build the DB-backed review command for bounded worktree cleanup rows.
655+
*/
656+
private function build_worktree_cleanup_review_plan_command( ?int $limit, int $offset, string $until_budget ): string {
657+
$parts = array(
658+
'studio wp datamachine-code workspace cleanup plan --mode=retention',
659+
null === $limit ? null : '--limit=' . max(1, $limit),
660+
'--offset=' . max(0, $offset),
661+
'' !== trim($until_budget) ? '--until-budget=' . trim($until_budget) : null,
662+
'--format=json',
663+
);
664+
665+
return implode(' ', array_values(array_filter($parts, fn( $part ) => null !== $part)));
666+
}
667+
640668
/**
641669
* Collapse duplicate cleanup rows emitted by overlapping inventory/git sources.
642670
*
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if ( ! defined('ABSPATH') ) {
6+
define('ABSPATH', __DIR__ . '/fixtures/');
7+
}
8+
9+
require_once dirname(__DIR__) . '/vendor/autoload.php';
10+
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceCleanupPlan.php';
11+
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeCleanupEngine.php';
12+
13+
use DataMachineCode\Workspace\WorkspaceCleanupPlan;
14+
use DataMachineCode\Workspace\WorkspaceWorktreeCleanupEngine;
15+
16+
function cleanup_retention_plan_assert( bool $condition, string $message ): void {
17+
if ( ! $condition ) {
18+
throw new RuntimeException($message);
19+
}
20+
}
21+
22+
final class CleanupRetentionPlanWorktreeRemovalWorkspace {
23+
use WorkspaceCleanupPlan;
24+
25+
public const CLEANUP_PLAN_DEFAULT_LIMIT = 100;
26+
public const CLEANUP_PLAN_DEFAULT_BUDGET = '30s';
27+
28+
private string $workspace_path = '/tmp/dmc-retention-plan-contract';
29+
public array $last_worktree_cleanup_opts = array();
30+
31+
public function worktree_cleanup_artifacts( array $opts = array() ): array {
32+
return array(
33+
'success' => true,
34+
'dry_run' => true,
35+
'candidates' => array(),
36+
'skipped' => array(),
37+
'summary' => array(),
38+
);
39+
}
40+
41+
public function worktree_cleanup_merged( array $opts = array() ): array {
42+
$this->last_worktree_cleanup_opts = $opts;
43+
44+
return array(
45+
'success' => true,
46+
'dry_run' => true,
47+
'candidates' => array(
48+
array(
49+
'handle' => 'repo@local-merged',
50+
'repo' => 'repo',
51+
'branch' => 'local-merged',
52+
'path' => '/tmp/dmc-retention-plan-contract/repo@local-merged',
53+
'signal' => 'local-merged',
54+
'reason_code' => 'local-merged',
55+
'size_bytes' => 100,
56+
),
57+
array(
58+
'handle' => 'repo@remote-clean',
59+
'repo' => 'repo',
60+
'branch' => 'remote-clean',
61+
'path' => '/tmp/dmc-retention-plan-contract/repo@remote-clean',
62+
'signal' => 'remote-tracking-clean',
63+
'reason_code' => 'remote-tracking-clean',
64+
'size_bytes' => 200,
65+
),
66+
array(
67+
'handle' => 'repo@upstream-gone',
68+
'repo' => 'repo',
69+
'branch' => 'upstream-gone',
70+
'path' => '/tmp/dmc-retention-plan-contract/repo@upstream-gone',
71+
'signal' => 'upstream-gone',
72+
'reason_code' => 'upstream-gone',
73+
'size_bytes' => 300,
74+
),
75+
),
76+
'skipped' => array(),
77+
'summary' => array(
78+
'fresh_safe_removable_count' => 3,
79+
),
80+
'pagination' => array(
81+
'total' => 5,
82+
'offset' => 0,
83+
'limit' => 3,
84+
'scanned' => 3,
85+
'partial' => true,
86+
'complete' => false,
87+
'next_offset' => 3,
88+
'next_command' => 'studio wp datamachine-code workspace worktree cleanup --dry-run --limit=3 --offset=3 --until-budget=45s --format=json',
89+
'budget_stopped' => false,
90+
),
91+
);
92+
}
93+
94+
private function stable_cleanup_hash( array $data, string $prefix ): string {
95+
return $prefix . '-' . substr(hash('sha256', wp_json_encode($data)), 0, 12);
96+
}
97+
}
98+
99+
if ( ! function_exists('wp_json_encode') ) {
100+
function wp_json_encode( $data, $options = 0, $depth = 512 ) {
101+
return json_encode($data, $options, $depth);
102+
}
103+
}
104+
105+
$workspace = new CleanupRetentionPlanWorktreeRemovalWorkspace();
106+
$plan = $workspace->workspace_cleanup_plan(
107+
array(
108+
'include_artifacts' => false,
109+
'limit' => 3,
110+
'offset' => 0,
111+
'until_budget' => '45s',
112+
)
113+
);
114+
115+
cleanup_retention_plan_assert(is_array($plan), 'retention cleanup plan should return an array');
116+
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');
117+
cleanup_retention_plan_assert(3 === (int) ( $workspace->last_worktree_cleanup_opts['limit'] ?? 0 ), 'retention plan should pass the bounded page limit to worktree cleanup');
118+
cleanup_retention_plan_assert('45s' === ( $workspace->last_worktree_cleanup_opts['until_budget'] ?? null ), 'retention plan should pass the budget to worktree cleanup');
119+
120+
$rows = (array) ( $plan['rows']['worktree_removal'] ?? array() );
121+
cleanup_retention_plan_assert(3 === count($rows), 'retention plan should preserve fresh safe worktree cleanup candidates as removal rows');
122+
123+
$signals = array_column($rows, 'signal', 'handle');
124+
cleanup_retention_plan_assert('local-merged' === ( $signals['repo@local-merged'] ?? null ), 'local-merged candidates should be applyable worktree_removal rows');
125+
cleanup_retention_plan_assert('remote-tracking-clean' === ( $signals['repo@remote-clean'] ?? null ), 'remote-tracking-clean candidates should be applyable worktree_removal rows');
126+
cleanup_retention_plan_assert('upstream-gone' === ( $signals['repo@upstream-gone'] ?? null ), 'upstream-gone candidates should be applyable worktree_removal rows');
127+
cleanup_retention_plan_assert(3 === (int) ( $plan['summary']['rows_by_action']['remove_worktree'] ?? 0 ), 'remove_worktree action rows should match reviewed safe rows');
128+
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');
129+
130+
$engine = new class {
131+
use WorkspaceWorktreeCleanupEngine;
132+
};
133+
$review_command = ( new ReflectionMethod($engine, 'build_worktree_cleanup_review_plan_command') )->invoke($engine, 100, 25, '180s');
134+
cleanup_retention_plan_assert(
135+
'studio wp datamachine-code workspace cleanup plan --mode=retention --limit=100 --offset=25 --until-budget=180s --format=json' === $review_command,
136+
'bounded worktree cleanup should point to the DB-backed reviewed apply path for the same page'
137+
);
138+
139+
fwrite(STDOUT, "cleanup-retention-plan-worktree-removals ok\n");

0 commit comments

Comments
 (0)