Skip to content

Commit 4864820

Browse files
fix: scope cleanup pagination by repo
1 parent 6fceb8d commit 4864820

10 files changed

Lines changed: 349 additions & 26 deletions

inc/Abilities/WorkspaceAbilities.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2007,6 +2007,10 @@ private function registerAbilities(): void {
20072007
'type' => 'boolean',
20082008
'description' => 'With apply=true, schedule bounded metadata reconciliation page jobs instead of applying synchronously.',
20092009
),
2010+
'repo' => array(
2011+
'type' => 'string',
2012+
'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.',
2013+
),
20102014
'limit' => array(
20112015
'type' => 'integer',
20122016
'description' => 'Positive page size for bounded dry-run, direct apply, budgeted apply, or job-backed apply.',
@@ -2060,6 +2064,10 @@ private function registerAbilities(): void {
20602064
'type' => 'string',
20612065
'description' => 'Compact time budget for this report page, such as 60s or 10m.',
20622066
),
2067+
'repo' => array(
2068+
'type' => 'string',
2069+
'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination and evidence are constrained to matching rows.',
2070+
),
20632071
),
20642072
),
20652073
'output_schema' => array(
@@ -2103,6 +2111,10 @@ private function registerAbilities(): void {
21032111
'type' => 'string',
21042112
'description' => 'Compact time budget for the underlying active_no_signal report page, such as 60s or 10m.',
21052113
),
2114+
'repo' => array(
2115+
'type' => 'string',
2116+
'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.',
2117+
),
21062118
),
21072119
),
21082120
'output_schema' => array(
@@ -2147,6 +2159,10 @@ private function registerAbilities(): void {
21472159
'type' => 'string',
21482160
'description' => 'Compact time budget for the underlying active_no_signal report page, such as 60s or 10m.',
21492161
),
2162+
'repo' => array(
2163+
'type' => 'string',
2164+
'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.',
2165+
),
21502166
),
21512167
),
21522168
'output_schema' => array(
@@ -2191,6 +2207,10 @@ private function registerAbilities(): void {
21912207
'type' => 'string',
21922208
'description' => 'Compact time budget for the underlying active_no_signal report page, such as 60s or 10m.',
21932209
),
2210+
'repo' => array(
2211+
'type' => 'string',
2212+
'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.',
2213+
),
21942214
),
21952215
),
21962216
'output_schema' => array(
@@ -2235,6 +2255,10 @@ private function registerAbilities(): void {
22352255
'type' => 'string',
22362256
'description' => 'Compact time budget for the underlying active_no_signal report page, such as 60s or 10m.',
22372257
),
2258+
'repo' => array(
2259+
'type' => 'string',
2260+
'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.',
2261+
),
22382262
),
22392263
),
22402264
'output_schema' => array(
@@ -2296,6 +2320,10 @@ private function registerAbilities(): void {
22962320
'type' => 'string',
22972321
'description' => 'Caller source marker forwarded to underlying cleanup abilities.',
22982322
),
2323+
'repo' => array(
2324+
'type' => 'string',
2325+
'description' => 'Optional primary repo or worktree handle scope forwarded to underlying paginated cleanup abilities.',
2326+
),
22992327
),
23002328
),
23012329
'output_schema' => array(
@@ -4270,6 +4298,9 @@ public static function worktreeReconcileMetadata( array $input ): array|\WP_Erro
42704298
if ( isset($input['source']) && '' !== trim( (string) $input['source']) ) {
42714299
$opts['source'] = trim( (string) $input['source']);
42724300
}
4301+
if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) {
4302+
$opts['repo'] = trim( (string) $input['repo']);
4303+
}
42734304

42744305
return $workspace->worktree_reconcile_metadata($opts);
42754306
}
@@ -4292,6 +4323,9 @@ public static function worktreeActiveNoSignalReport( array $input ): array|\WP_E
42924323
if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) {
42934324
$opts['until_budget'] = trim( (string) $input['until_budget']);
42944325
}
4326+
if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) {
4327+
$opts['repo'] = trim( (string) $input['repo']);
4328+
}
42954329

42964330
return $workspace->worktree_active_no_signal_report($opts);
42974331
}
@@ -4316,6 +4350,9 @@ public static function worktreeActiveNoSignalFinalizedApply( array $input ): arr
43164350
if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) {
43174351
$opts['until_budget'] = trim( (string) $input['until_budget']);
43184352
}
4353+
if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) {
4354+
$opts['repo'] = trim( (string) $input['repo']);
4355+
}
43194356

43204357
return $workspace->worktree_active_no_signal_finalized_apply($opts);
43214358
}
@@ -4340,6 +4377,9 @@ public static function worktreeActiveNoSignalEquivalentCleanApply( array $input
43404377
if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) {
43414378
$opts['until_budget'] = trim( (string) $input['until_budget']);
43424379
}
4380+
if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) {
4381+
$opts['repo'] = trim( (string) $input['repo']);
4382+
}
43434383

43444384
return $workspace->worktree_active_no_signal_equivalent_clean_apply($opts);
43454385
}
@@ -4364,6 +4404,9 @@ public static function worktreeActiveNoSignalMergedApply( array $input ): array|
43644404
if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) {
43654405
$opts['until_budget'] = trim( (string) $input['until_budget']);
43664406
}
4407+
if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) {
4408+
$opts['repo'] = trim( (string) $input['repo']);
4409+
}
43674410

43684411
return $workspace->worktree_active_no_signal_merged_apply($opts);
43694412
}
@@ -4388,6 +4431,9 @@ public static function worktreeActiveNoSignalRemoteCleanApply( array $input ): a
43884431
if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) {
43894432
$opts['until_budget'] = trim( (string) $input['until_budget']);
43904433
}
4434+
if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) {
4435+
$opts['repo'] = trim( (string) $input['repo']);
4436+
}
43914437

43924438
return $workspace->worktree_active_no_signal_remote_clean_apply($opts);
43934439
}

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3800,7 +3800,7 @@ public function worktree( array $args, array $assoc_args ): void {
38003800
}
38013801

38023802
if ( 'active-no-signal-drain' === $operation ) {
3803-
$result = $this->run_worktree_active_no_signal_drain($assoc_args);
3803+
$result = $this->run_worktree_active_no_signal_drain($assoc_args, isset($args[1]) ? (string) $args[1] : '');
38043804
if ( is_wp_error($result) ) {
38053805
$this->render_workspace_error($result);
38063806
return;
@@ -3871,7 +3871,7 @@ public function worktree( array $args, array $assoc_args ): void {
38713871
$input = array();
38723872
$input_builder = (string) ( $operation_config['input_builder'] ?? '' );
38733873
if ( '' !== $input_builder ) {
3874-
$input = $this->{$input_builder}($operation, $assoc_args);
3874+
$input = $this->{$input_builder}($operation, $assoc_args, isset($args[1]) ? (string) $args[1] : '');
38753875
}
38763876

38773877
switch ( $operation ) {
@@ -4028,6 +4028,9 @@ public function worktree( array $args, array $assoc_args ): void {
40284028
}
40294029
break;
40304030
case 'reconcile-metadata':
4031+
if ( ! empty($args[1]) ) {
4032+
$input['repo'] = (string) $args[1];
4033+
}
40314034
$input['dry_run'] = ! empty($assoc_args['dry-run']);
40324035
$input['apply'] = ! empty($assoc_args['apply']);
40334036
$input['via_jobs'] = ! empty($assoc_args['via-jobs']);
@@ -4149,8 +4152,11 @@ public function worktree( array $args, array $assoc_args ): void {
41494152
* @param array<string,mixed> $assoc_args CLI args.
41504153
* @return array<string,mixed>
41514154
*/
4152-
private function build_worktree_active_no_signal_input( string $operation, array $assoc_args ): array {
4155+
private function build_worktree_active_no_signal_input( string $operation, array $assoc_args, string $repo = '' ): array {
41534156
$input = array();
4157+
if ( '' !== trim($repo) ) {
4158+
$input['repo'] = trim($repo);
4159+
}
41544160

41554161
if ( in_array($operation, array( 'active-no-signal-finalized-apply', 'active-no-signal-equivalent-clean-apply', 'active-no-signal-merged-apply', 'active-no-signal-remote-clean-apply' ), true) ) {
41564162
$input['dry_run'] = ! empty($assoc_args['dry-run']);
@@ -4203,7 +4209,7 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra
42034209
* @param array<string,mixed> $assoc_args CLI args.
42044210
* @return array<string,mixed>|\WP_Error
42054211
*/
4206-
private function run_worktree_active_no_signal_drain( array $assoc_args ): array|\WP_Error {
4212+
private function run_worktree_active_no_signal_drain( array $assoc_args, string $repo = '' ): array|\WP_Error {
42074213
if ( ! empty($assoc_args['force']) ) {
42084214
return new \WP_Error('active_no_signal_drain_refuses_force', 'Active/no-signal drain will not force cleanup. Protected blockers remain blocked.', array( 'status' => 400 ));
42094215
}
@@ -4225,6 +4231,12 @@ private function run_worktree_active_no_signal_drain( array $assoc_args ): array
42254231
$input[ $key ] = $assoc_args[ $key ];
42264232
}
42274233
}
4234+
if ( '' !== trim($repo) ) {
4235+
$input['repo'] = trim($repo);
4236+
if ( empty($input['scope']) ) {
4237+
$input['scope'] = trim($repo);
4238+
}
4239+
}
42284240
if ( isset($assoc_args['until-budget']) ) {
42294241
$input['until_budget'] = $assoc_args['until-budget'];
42304242
}

inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ public function run( array $input ): array|\WP_Error {
5858
$until_budget = isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ? trim( (string) $input['until_budget']) : '';
5959
$source = isset($input['source']) && '' !== trim( (string) $input['source']) ? trim( (string) $input['source']) : self::DEFAULT_SOURCE;
6060
$scope = isset($input['scope']) && '' !== trim( (string) $input['scope']) ? $this->sanitize_scope( (string) $input['scope']) : '';
61+
$repo_scope = isset($input['repo']) && '' !== trim( (string) $input['repo']) ? trim( (string) $input['repo']) : '';
62+
if ( '' === $scope && '' !== $repo_scope ) {
63+
$scope = $this->sanitize_scope($repo_scope);
64+
}
65+
if ( '' === $repo_scope && '' !== $scope && $this->scope_is_worktree_filter($scope) ) {
66+
$repo_scope = $scope;
67+
}
6168
$deadline = null;
6269
$stage_order = $this->stage_order();
6370

@@ -86,6 +93,9 @@ public function run( array $input ): array|\WP_Error {
8693
if ( '' !== $scope ) {
8794
$common_page['scope'] = $scope;
8895
}
96+
if ( '' !== $repo_scope ) {
97+
$common_page['repo'] = $repo_scope;
98+
}
8999

90100
if ( $apply ) {
91101
$bounded = $this->run_bounded_apply($abilities['bounded_apply'], $result, true, $force, $limit, $source, 'initial');
@@ -300,6 +310,10 @@ private function sanitize_scope( string $scope ): string {
300310
return substr($scope, 0, 120);
301311
}
302312

313+
private function scope_is_worktree_filter( string $scope ): bool {
314+
return 1 === preg_match('/^[a-zA-Z0-9._-]+(?:@[a-zA-Z0-9._-]+)?$/', $scope);
315+
}
316+
303317
/** @return array<string,mixed> */
304318
private function initial_result( bool $apply, bool $force, int $limit, string $stage, int $offset, int $passes, bool $active_no_signal_drain = false, string $scope = '' ): array {
305319
return array(

inc/Workspace/WorkspaceActiveNoSignalCleanup.php

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public function worktree_active_no_signal_report( array $opts = array() ): array
2929
? $opts['next_command_operation']
3030
: 'active-no-signal-report';
3131
$next_dry_run = 'active-no-signal-report' !== $next_operation && ! empty($opts['dry_run']);
32+
$scope = $this->normalize_worktree_operation_scope(isset($opts['repo']) ? (string) $opts['repo'] : '');
33+
if ( is_wp_error($scope) ) {
34+
return $scope;
35+
}
36+
$scope_arg = $this->worktree_operation_scope_cli_arg($scope);
3237
if ( $limit <= 0 ) {
3338
return new \WP_Error('invalid_active_no_signal_limit', 'Active/no-signal report --limit must be greater than 0.', array( 'status' => 400 ));
3439
}
@@ -39,7 +44,7 @@ public function worktree_active_no_signal_report( array $opts = array() ): array
3944
}
4045
}
4146

42-
$inventory = $this->worktree_cleanup_inventory_only('', '', false);
47+
$inventory = $this->worktree_cleanup_inventory_only('', '', false, null, 0, null === $scope ? '' : (string) $scope['argument']);
4348
if ( is_wp_error($inventory) ) {
4449
return $inventory;
4550
}
@@ -124,7 +129,7 @@ public function worktree_active_no_signal_report( array $opts = array() ): array
124129
'partial' => null !== $next_offset,
125130
'complete' => null === $next_offset,
126131
'next_offset' => $next_offset,
127-
'next_command' => null === $next_offset ? null : sprintf('studio wp datamachine-code workspace worktree %s%s --limit=%d --offset=%d%s --format=json', $next_operation, $next_dry_run ? ' --dry-run' : '', $limit, $next_offset, null !== $budget_context ? ' --until-budget=' . (string) $budget_context['label'] : ''),
132+
'next_command' => null === $next_offset ? null : sprintf('studio wp datamachine-code workspace worktree %s%s%s --limit=%d --offset=%d%s --format=json', $next_operation, $scope_arg, $next_dry_run ? ' --dry-run' : '', $limit, $next_offset, null !== $budget_context ? ' --until-budget=' . (string) $budget_context['label'] : ''),
128133
);
129134
if ( $budget_stopped ) {
130135
$pagination['partial'] = true;
@@ -140,7 +145,8 @@ public function worktree_active_no_signal_report( array $opts = array() ): array
140145
'summary' => array_merge($summary, array( 'slow_rows' => $this->summarize_slow_worktree_rows($rows) )),
141146
'pagination' => $pagination,
142147
'evidence' => array(
143-
'scope' => 'review-only active/no-signal and lifecycle reconciliation worktree evidence',
148+
'scope' => null === $scope ? 'review-only active/no-signal and lifecycle reconciliation worktree evidence' : 'review-only active/no-signal and lifecycle reconciliation worktree evidence scoped to ' . (string) $scope['argument'],
149+
'repo_scope' => $scope,
144150
'safety' => 'No worktrees or remote branches are deleted. Dirty and unpushed probes are evidence only.',
145151
'budget' => null === $budget_context ? null : $this->summarize_worktree_loop_budget_context($budget_context, $budget_stopped),
146152
'probe_cache' => $probe_cache['stats'],
@@ -484,6 +490,8 @@ private function build_active_no_signal_apply_pagination( array $pagination, str
484490
$budget_label = $matches[1];
485491
}
486492

493+
$scope = $this->normalize_worktree_operation_scope(isset($opts['repo']) ? (string) $opts['repo'] : '');
494+
$scope_arg = is_wp_error($scope) ? '' : $this->worktree_operation_scope_cli_arg($scope);
487495
$budget_arg = '' !== $budget_label ? ' --until-budget=' . $budget_label : '';
488496
$dry_run_arg = $dry_run ? ' --dry-run' : '';
489497
$limit = (int) ( $pagination['limit'] ?? 25 );
@@ -494,8 +502,9 @@ private function build_active_no_signal_apply_pagination( array $pagination, str
494502
}
495503

496504
$pagination['next_command'] = sprintf(
497-
'studio wp datamachine-code workspace worktree %s%s --limit=%d --offset=%d%s --format=json',
505+
'studio wp datamachine-code workspace worktree %s%s%s --limit=%d --offset=%d%s --format=json',
498506
$operation,
507+
$scope_arg,
499508
$dry_run_arg,
500509
$limit,
501510
$next_offset,

inc/Workspace/WorkspaceCoreUtilities.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,70 @@ public function parse_handle( string $handle ): array {
230230
return WorkspaceHandle::parse($handle)->to_array();
231231
}
232232

233+
/**
234+
* Normalize an optional worktree cleanup/reconciliation scope.
235+
*
236+
* @param string|null $scope Optional primary repo or worktree handle.
237+
* @return array{argument: string, repo: string, handle: string|null}|null|\WP_Error
238+
*/
239+
protected function normalize_worktree_operation_scope( ?string $scope ): array|null|\WP_Error {
240+
$scope = null === $scope ? '' : trim($scope);
241+
if ( '' === $scope ) {
242+
return null;
243+
}
244+
245+
$parsed = $this->parse_handle($scope);
246+
if ( '' === $parsed['repo'] || '' === $parsed['dir_name'] ) {
247+
return new \WP_Error('invalid_worktree_scope', sprintf('Worktree cleanup scope "%s" must be a primary repo or worktree handle.', $scope), array( 'status' => 400 ));
248+
}
249+
250+
return array(
251+
'argument' => $scope,
252+
'repo' => $parsed['repo'],
253+
'handle' => $parsed['is_worktree'] ? $parsed['dir_name'] : null,
254+
);
255+
}
256+
257+
/**
258+
* Whether an inventory/worktree row matches a normalized operation scope.
259+
*
260+
* @param array<string,mixed> $row Inventory or evidence row.
261+
* @param array{argument: string, repo: string, handle: string|null}|null $scope Optional normalized scope.
262+
* @return bool
263+
*/
264+
protected function worktree_row_matches_operation_scope( array $row, ?array $scope ): bool {
265+
if ( null === $scope ) {
266+
return true;
267+
}
268+
269+
$handle = (string) ( $row['handle'] ?? $row['name'] ?? '' );
270+
if ( null !== $scope['handle'] ) {
271+
return $handle === $scope['handle'];
272+
}
273+
274+
$repo = (string) ( $row['repo'] ?? '' );
275+
if ( '' === $repo && '' !== $handle ) {
276+
$repo = $this->parse_handle($handle)['repo'];
277+
}
278+
279+
return $repo === $scope['repo'];
280+
}
281+
282+
/**
283+
* Build the positional CLI scope segment for continuation commands.
284+
*
285+
* @param array{argument: string, repo: string, handle: string|null}|null $scope Optional normalized scope.
286+
* @return string
287+
*/
288+
protected function worktree_operation_scope_cli_arg( ?array $scope ): string {
289+
if ( null === $scope ) {
290+
return '';
291+
}
292+
293+
$argument = (string) $scope['argument'];
294+
return ' ' . ( preg_match('/^[A-Za-z0-9._@-]+$/', $argument) ? $argument : escapeshellarg($argument) );
295+
}
296+
233297
/**
234298
* Require file-operation callers to name a workspace handle explicitly.
235299
*

0 commit comments

Comments
 (0)