diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index e2e004eb..8cab7b6a 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2007,6 +2007,10 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'With apply=true, schedule bounded metadata reconciliation page jobs instead of applying synchronously.', ), + 'repo' => array( + 'type' => 'string', + 'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.', + ), 'limit' => array( 'type' => 'integer', 'description' => 'Positive page size for bounded dry-run, direct apply, budgeted apply, or job-backed apply.', @@ -2060,6 +2064,10 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Compact time budget for this report page, such as 60s or 10m.', ), + 'repo' => array( + 'type' => 'string', + 'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination and evidence are constrained to matching rows.', + ), ), ), 'output_schema' => array( @@ -2103,6 +2111,10 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Compact time budget for the underlying active_no_signal report page, such as 60s or 10m.', ), + 'repo' => array( + 'type' => 'string', + 'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.', + ), ), ), 'output_schema' => array( @@ -2147,6 +2159,10 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Compact time budget for the underlying active_no_signal report page, such as 60s or 10m.', ), + 'repo' => array( + 'type' => 'string', + 'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.', + ), ), ), 'output_schema' => array( @@ -2191,6 +2207,10 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Compact time budget for the underlying active_no_signal report page, such as 60s or 10m.', ), + 'repo' => array( + 'type' => 'string', + 'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.', + ), ), ), 'output_schema' => array( @@ -2235,6 +2255,10 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Compact time budget for the underlying active_no_signal report page, such as 60s or 10m.', ), + 'repo' => array( + 'type' => 'string', + 'description' => 'Optional primary repo or worktree handle scope. When supplied, pagination, evidence, and writes are constrained to matching rows.', + ), ), ), 'output_schema' => array( @@ -2296,6 +2320,10 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Caller source marker forwarded to underlying cleanup abilities.', ), + 'repo' => array( + 'type' => 'string', + 'description' => 'Optional primary repo or worktree handle scope forwarded to underlying paginated cleanup abilities.', + ), ), ), 'output_schema' => array( @@ -4270,6 +4298,9 @@ public static function worktreeReconcileMetadata( array $input ): array|\WP_Erro if ( isset($input['source']) && '' !== trim( (string) $input['source']) ) { $opts['source'] = trim( (string) $input['source']); } + if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) { + $opts['repo'] = trim( (string) $input['repo']); + } return $workspace->worktree_reconcile_metadata($opts); } @@ -4292,6 +4323,9 @@ public static function worktreeActiveNoSignalReport( array $input ): array|\WP_E if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { $opts['until_budget'] = trim( (string) $input['until_budget']); } + if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) { + $opts['repo'] = trim( (string) $input['repo']); + } return $workspace->worktree_active_no_signal_report($opts); } @@ -4316,6 +4350,9 @@ public static function worktreeActiveNoSignalFinalizedApply( array $input ): arr if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { $opts['until_budget'] = trim( (string) $input['until_budget']); } + if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) { + $opts['repo'] = trim( (string) $input['repo']); + } return $workspace->worktree_active_no_signal_finalized_apply($opts); } @@ -4340,6 +4377,9 @@ public static function worktreeActiveNoSignalEquivalentCleanApply( array $input if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { $opts['until_budget'] = trim( (string) $input['until_budget']); } + if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) { + $opts['repo'] = trim( (string) $input['repo']); + } return $workspace->worktree_active_no_signal_equivalent_clean_apply($opts); } @@ -4364,6 +4404,9 @@ public static function worktreeActiveNoSignalMergedApply( array $input ): array| if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { $opts['until_budget'] = trim( (string) $input['until_budget']); } + if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) { + $opts['repo'] = trim( (string) $input['repo']); + } return $workspace->worktree_active_no_signal_merged_apply($opts); } @@ -4388,6 +4431,9 @@ public static function worktreeActiveNoSignalRemoteCleanApply( array $input ): a if ( isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ) { $opts['until_budget'] = trim( (string) $input['until_budget']); } + if ( isset($input['repo']) && '' !== trim( (string) $input['repo']) ) { + $opts['repo'] = trim( (string) $input['repo']); + } return $workspace->worktree_active_no_signal_remote_clean_apply($opts); } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index e881d281..23fb156e 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -3800,7 +3800,7 @@ public function worktree( array $args, array $assoc_args ): void { } if ( 'active-no-signal-drain' === $operation ) { - $result = $this->run_worktree_active_no_signal_drain($assoc_args); + $result = $this->run_worktree_active_no_signal_drain($assoc_args, isset($args[1]) ? (string) $args[1] : ''); if ( is_wp_error($result) ) { $this->render_workspace_error($result); return; @@ -3871,7 +3871,7 @@ public function worktree( array $args, array $assoc_args ): void { $input = array(); $input_builder = (string) ( $operation_config['input_builder'] ?? '' ); if ( '' !== $input_builder ) { - $input = $this->{$input_builder}($operation, $assoc_args); + $input = $this->{$input_builder}($operation, $assoc_args, isset($args[1]) ? (string) $args[1] : ''); } switch ( $operation ) { @@ -4028,6 +4028,9 @@ public function worktree( array $args, array $assoc_args ): void { } break; case 'reconcile-metadata': + if ( ! empty($args[1]) ) { + $input['repo'] = (string) $args[1]; + } $input['dry_run'] = ! empty($assoc_args['dry-run']); $input['apply'] = ! empty($assoc_args['apply']); $input['via_jobs'] = ! empty($assoc_args['via-jobs']); @@ -4149,8 +4152,11 @@ public function worktree( array $args, array $assoc_args ): void { * @param array $assoc_args CLI args. * @return array */ - private function build_worktree_active_no_signal_input( string $operation, array $assoc_args ): array { + private function build_worktree_active_no_signal_input( string $operation, array $assoc_args, string $repo = '' ): array { $input = array(); + if ( '' !== trim($repo) ) { + $input['repo'] = trim($repo); + } 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) ) { $input['dry_run'] = ! empty($assoc_args['dry-run']); @@ -4203,7 +4209,7 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra * @param array $assoc_args CLI args. * @return array|\WP_Error */ - private function run_worktree_active_no_signal_drain( array $assoc_args ): array|\WP_Error { + private function run_worktree_active_no_signal_drain( array $assoc_args, string $repo = '' ): array|\WP_Error { if ( ! empty($assoc_args['force']) ) { 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 )); } @@ -4225,6 +4231,12 @@ private function run_worktree_active_no_signal_drain( array $assoc_args ): array $input[ $key ] = $assoc_args[ $key ]; } } + if ( '' !== trim($repo) ) { + $input['repo'] = trim($repo); + if ( empty($input['scope']) ) { + $input['scope'] = trim($repo); + } + } if ( isset($assoc_args['until-budget']) ) { $input['until_budget'] = $assoc_args['until-budget']; } diff --git a/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php b/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php index 0f686158..cc78fbbf 100644 --- a/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php +++ b/inc/Workspace/WorkspaceAbandonedCleanupOrchestrator.php @@ -58,8 +58,15 @@ public function run( array $input ): array|\WP_Error { $until_budget = isset($input['until_budget']) && '' !== trim( (string) $input['until_budget']) ? trim( (string) $input['until_budget']) : ''; $source = isset($input['source']) && '' !== trim( (string) $input['source']) ? trim( (string) $input['source']) : self::DEFAULT_SOURCE; $scope = isset($input['scope']) && '' !== trim( (string) $input['scope']) ? $this->sanitize_scope( (string) $input['scope']) : ''; - $deadline = null; - $stage_order = $this->stage_order(); + $repo_scope = isset($input['repo']) && '' !== trim( (string) $input['repo']) ? trim( (string) $input['repo']) : ''; + if ( '' === $scope && '' !== $repo_scope ) { + $scope = $this->sanitize_scope($repo_scope); + } + if ( '' === $repo_scope && '' !== $scope && $this->scope_is_worktree_filter($scope) ) { + $repo_scope = $scope; + } + $deadline = null; + $stage_order = $this->stage_order(); if ( ! isset($stage_order[ $stage ]) ) { return new \WP_Error('invalid_worktree_abandoned_stage', 'Invalid stage value. Use reconcile, finalized, equivalent-clean, merged, remote-clean, or bounded.', array( 'status' => 400 )); @@ -86,6 +93,9 @@ public function run( array $input ): array|\WP_Error { if ( '' !== $scope ) { $common_page['scope'] = $scope; } + if ( '' !== $repo_scope ) { + $common_page['repo'] = $repo_scope; + } if ( $apply ) { $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 { return substr($scope, 0, 120); } + private function scope_is_worktree_filter( string $scope ): bool { + return 1 === preg_match('/^[a-zA-Z0-9._-]+(?:@[a-zA-Z0-9._-]+)?$/', $scope); + } + /** @return array */ 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 { return array( diff --git a/inc/Workspace/WorkspaceActiveNoSignalCleanup.php b/inc/Workspace/WorkspaceActiveNoSignalCleanup.php index a01b1982..0c9af36a 100644 --- a/inc/Workspace/WorkspaceActiveNoSignalCleanup.php +++ b/inc/Workspace/WorkspaceActiveNoSignalCleanup.php @@ -29,6 +29,11 @@ public function worktree_active_no_signal_report( array $opts = array() ): array ? $opts['next_command_operation'] : 'active-no-signal-report'; $next_dry_run = 'active-no-signal-report' !== $next_operation && ! empty($opts['dry_run']); + $scope = $this->normalize_worktree_operation_scope(isset($opts['repo']) ? (string) $opts['repo'] : ''); + if ( is_wp_error($scope) ) { + return $scope; + } + $scope_arg = $this->worktree_operation_scope_cli_arg($scope); if ( $limit <= 0 ) { return new \WP_Error('invalid_active_no_signal_limit', 'Active/no-signal report --limit must be greater than 0.', array( 'status' => 400 )); } @@ -39,7 +44,7 @@ public function worktree_active_no_signal_report( array $opts = array() ): array } } - $inventory = $this->worktree_cleanup_inventory_only('', '', false); + $inventory = $this->worktree_cleanup_inventory_only('', '', false, null, 0, null === $scope ? '' : (string) $scope['argument']); if ( is_wp_error($inventory) ) { return $inventory; } @@ -124,7 +129,7 @@ public function worktree_active_no_signal_report( array $opts = array() ): array 'partial' => null !== $next_offset, 'complete' => null === $next_offset, 'next_offset' => $next_offset, - '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'] : ''), + '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'] : ''), ); if ( $budget_stopped ) { $pagination['partial'] = true; @@ -140,7 +145,8 @@ public function worktree_active_no_signal_report( array $opts = array() ): array 'summary' => array_merge($summary, array( 'slow_rows' => $this->summarize_slow_worktree_rows($rows) )), 'pagination' => $pagination, 'evidence' => array( - 'scope' => 'review-only active/no-signal and lifecycle reconciliation worktree evidence', + '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'], + 'repo_scope' => $scope, 'safety' => 'No worktrees or remote branches are deleted. Dirty and unpushed probes are evidence only.', 'budget' => null === $budget_context ? null : $this->summarize_worktree_loop_budget_context($budget_context, $budget_stopped), 'probe_cache' => $probe_cache['stats'], @@ -484,6 +490,8 @@ private function build_active_no_signal_apply_pagination( array $pagination, str $budget_label = $matches[1]; } + $scope = $this->normalize_worktree_operation_scope(isset($opts['repo']) ? (string) $opts['repo'] : ''); + $scope_arg = is_wp_error($scope) ? '' : $this->worktree_operation_scope_cli_arg($scope); $budget_arg = '' !== $budget_label ? ' --until-budget=' . $budget_label : ''; $dry_run_arg = $dry_run ? ' --dry-run' : ''; $limit = (int) ( $pagination['limit'] ?? 25 ); @@ -494,8 +502,9 @@ private function build_active_no_signal_apply_pagination( array $pagination, str } $pagination['next_command'] = sprintf( - 'studio wp datamachine-code workspace worktree %s%s --limit=%d --offset=%d%s --format=json', + 'studio wp datamachine-code workspace worktree %s%s%s --limit=%d --offset=%d%s --format=json', $operation, + $scope_arg, $dry_run_arg, $limit, $next_offset, diff --git a/inc/Workspace/WorkspaceCoreUtilities.php b/inc/Workspace/WorkspaceCoreUtilities.php index 7051b6f7..c3053ac0 100644 --- a/inc/Workspace/WorkspaceCoreUtilities.php +++ b/inc/Workspace/WorkspaceCoreUtilities.php @@ -230,6 +230,70 @@ public function parse_handle( string $handle ): array { return WorkspaceHandle::parse($handle)->to_array(); } + /** + * Normalize an optional worktree cleanup/reconciliation scope. + * + * @param string|null $scope Optional primary repo or worktree handle. + * @return array{argument: string, repo: string, handle: string|null}|null|\WP_Error + */ + protected function normalize_worktree_operation_scope( ?string $scope ): array|null|\WP_Error { + $scope = null === $scope ? '' : trim($scope); + if ( '' === $scope ) { + return null; + } + + $parsed = $this->parse_handle($scope); + if ( '' === $parsed['repo'] || '' === $parsed['dir_name'] ) { + return new \WP_Error('invalid_worktree_scope', sprintf('Worktree cleanup scope "%s" must be a primary repo or worktree handle.', $scope), array( 'status' => 400 )); + } + + return array( + 'argument' => $scope, + 'repo' => $parsed['repo'], + 'handle' => $parsed['is_worktree'] ? $parsed['dir_name'] : null, + ); + } + + /** + * Whether an inventory/worktree row matches a normalized operation scope. + * + * @param array $row Inventory or evidence row. + * @param array{argument: string, repo: string, handle: string|null}|null $scope Optional normalized scope. + * @return bool + */ + protected function worktree_row_matches_operation_scope( array $row, ?array $scope ): bool { + if ( null === $scope ) { + return true; + } + + $handle = (string) ( $row['handle'] ?? $row['name'] ?? '' ); + if ( null !== $scope['handle'] ) { + return $handle === $scope['handle']; + } + + $repo = (string) ( $row['repo'] ?? '' ); + if ( '' === $repo && '' !== $handle ) { + $repo = $this->parse_handle($handle)['repo']; + } + + return $repo === $scope['repo']; + } + + /** + * Build the positional CLI scope segment for continuation commands. + * + * @param array{argument: string, repo: string, handle: string|null}|null $scope Optional normalized scope. + * @return string + */ + protected function worktree_operation_scope_cli_arg( ?array $scope ): string { + if ( null === $scope ) { + return ''; + } + + $argument = (string) $scope['argument']; + return ' ' . ( preg_match('/^[A-Za-z0-9._@-]+$/', $argument) ? $argument : escapeshellarg($argument) ); + } + /** * Require file-operation callers to name a workspace handle explicitly. * diff --git a/inc/Workspace/WorkspaceMetadataReconciliation.php b/inc/Workspace/WorkspaceMetadataReconciliation.php index 6ca6d535..922dfd4a 100644 --- a/inc/Workspace/WorkspaceMetadataReconciliation.php +++ b/inc/Workspace/WorkspaceMetadataReconciliation.php @@ -34,9 +34,14 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_ $source = isset($opts['source']) ? trim( (string) $opts['source']) : 'workspace_metadata_reconcile'; $apply_plan = isset($opts['apply_plan']) && is_array($opts['apply_plan']) ? $opts['apply_plan'] : null; $until_budget = isset($opts['until_budget']) ? trim( (string) $opts['until_budget']) : ''; - $paged = array_key_exists('limit', $opts) || array_key_exists('offset', $opts) || '' !== $until_budget; - $limit = $paged ? ( array_key_exists('limit', $opts) ? (int) $opts['limit'] : self::METADATA_RECONCILE_DEFAULT_LIMIT ) : 0; - $offset = $paged ? max(0, (int) ( $opts['offset'] ?? 0 )) : 0; + $scope = $this->normalize_worktree_operation_scope(isset($opts['repo']) ? (string) $opts['repo'] : ''); + if ( is_wp_error($scope) ) { + return $scope; + } + $scope_arg = $this->worktree_operation_scope_cli_arg($scope); + $paged = array_key_exists('limit', $opts) || array_key_exists('offset', $opts) || '' !== $until_budget; + $limit = $paged ? ( array_key_exists('limit', $opts) ? (int) $opts['limit'] : self::METADATA_RECONCILE_DEFAULT_LIMIT ) : 0; + $offset = $paged ? max(0, (int) ( $opts['offset'] ?? 0 )) : 0; if ( null !== $apply_plan ) { return $this->apply_worktree_metadata_reconciliation_plan($apply_plan); @@ -61,7 +66,7 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_ } if ( $apply && ! $dry_run ) { - return $this->drain_worktree_metadata_reconciliation_budget($limit, $offset, $until_budget, $budget_seconds); + return $this->drain_worktree_metadata_reconciliation_budget($limit, $offset, $until_budget, $budget_seconds, null === $scope ? '' : (string) $scope['argument']); } } if ( $via_jobs && ! $paged ) { @@ -71,7 +76,7 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_ } $listing = $this->worktree_list( - null, + null === $scope ? null : (string) $scope['repo'], null, $paged ? array( 'include_status' => false, @@ -85,7 +90,7 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_ $all_worktrees = array_values( array_filter( (array) ( $listing['worktrees'] ?? array() ), - fn( $wt ) => empty($wt['is_primary']) + fn( $wt ) => empty($wt['is_primary']) && is_array($wt) && $this->worktree_row_matches_operation_scope($wt, $scope) ) ); $prefilter = $this->prefilter_worktree_metadata_reconciliation_rows($all_worktrees); @@ -121,13 +126,13 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_ $classified_skips = $this->classify_worktree_metadata_reconciliation_skips($skipped); $pagination = $paged ? $this->build_worktree_metadata_reconciliation_pagination($total_worktrees, count($page_worktrees), $limit, $offset) : null; if ( null !== $pagination && null !== ( $pagination['next_offset'] ?? null ) ) { - $pagination['next_command'] = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata --%s --limit=%d --offset=%d%s --format=json', $apply ? 'apply' : 'dry-run', $limit, (int) $pagination['next_offset'], null !== $budget_context ? ' --until-budget=' . (string) $budget_context['label'] : ''); + $pagination['next_command'] = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata%s --%s --limit=%d --offset=%d%s --format=json', $scope_arg, $apply ? 'apply' : 'dry-run', $limit, (int) $pagination['next_offset'], null !== $budget_context ? ' --until-budget=' . (string) $budget_context['label'] : ''); } if ( null !== $pagination && $budget_stopped ) { $pagination['partial'] = true; $pagination['complete'] = false; $pagination['next_offset'] = $offset + count($page_worktrees); - $pagination['next_command'] = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata --%s --limit=%d --offset=%d%s --format=json', $apply ? 'apply' : 'dry-run', $limit, (int) $pagination['next_offset'], null !== $budget_context ? ' --until-budget=' . (string) $budget_context['label'] : ''); + $pagination['next_command'] = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata%s --%s --limit=%d --offset=%d%s --format=json', $scope_arg, $apply ? 'apply' : 'dry-run', $limit, (int) $pagination['next_offset'], null !== $budget_context ? ' --until-budget=' . (string) $budget_context['label'] : ''); } $plan = array( @@ -144,13 +149,15 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_ 'summary' => $this->build_worktree_metadata_reconciliation_summary($paged ? count($page_worktrees) : count($page_scope), $proposals, array(), $skipped), ); $plan['summary']['prefiltered'] = $prefilter['summary']; + $plan['scope'] = $scope; if ( null !== $pagination ) { $plan['pagination'] = $pagination; if ( ! empty($pagination['complete']) ) { - $plan['remaining_blockers'] = $this->build_worktree_metadata_reconciliation_remaining_blockers($proposals, array(), $skipped, $dry_run, $limit, null !== $budget_context ? (string) $budget_context['label'] : ''); + $plan['remaining_blockers'] = $this->build_worktree_metadata_reconciliation_remaining_blockers($proposals, array(), $skipped, $dry_run, $limit, null !== $budget_context ? (string) $budget_context['label'] : '', $scope); } $plan['evidence'] = array( - 'scope' => 'paginated metadata reconciliation dry-run', + 'scope' => null === $scope ? 'paginated metadata reconciliation dry-run' : 'paginated metadata reconciliation scoped to ' . (string) $scope['argument'], + 'repo_scope' => $scope, 'note' => 'Only candidate rows with missing, incomplete, invalid, or finalizable metadata ran per-worktree dirty, unpushed, merge-signal, and GitHub probes. Run the next_offset page until complete for full inventory review.', 'fields_skipped_by_listing' => (array) ( $listing['fields_skipped'] ?? array() ), 'prefilter' => $prefilter['summary'], @@ -180,7 +187,7 @@ public function worktree_reconcile_metadata( array $opts = array() ): array|\WP_ * @param int $budget_seconds Parsed budget in seconds. * @return array|\WP_Error */ - private function drain_worktree_metadata_reconciliation_budget( int $limit, int $offset, string $budget_label, int $budget_seconds ): array|\WP_Error { + private function drain_worktree_metadata_reconciliation_budget( int $limit, int $offset, string $budget_label, int $budget_seconds, string $scope_arg = '' ): array|\WP_Error { $started_at = microtime(true); $reserve_seconds = min(5.0, max(1.0, $budget_seconds * 0.1)); $pages = array(); @@ -190,11 +197,17 @@ private function drain_worktree_metadata_reconciliation_budget( int $limit, int $scanned = 0; $next_offset = $offset; $last_pagination = null; + $scope = $this->normalize_worktree_operation_scope($scope_arg); + if ( is_wp_error($scope) ) { + return $scope; + } + $scope_cli_arg = $this->worktree_operation_scope_cli_arg($scope); do { $page = $this->worktree_reconcile_metadata( array( 'apply' => true, + 'repo' => null === $scope ? '' : (string) $scope['argument'], 'limit' => $limit, 'offset' => $next_offset, 'internal_budget_label' => $budget_label, @@ -235,7 +248,8 @@ private function drain_worktree_metadata_reconciliation_budget( int $limit, int $mutation_count = count($written); $restart_offset = $mutation_count > 0 ? 0 : (int) ( $last_pagination['next_offset'] ?? $next_offset ); $next_command = $partial ? sprintf( - 'studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=%d --offset=%d --until-budget=%s --format=json', + 'studio wp datamachine-code workspace worktree reconcile-metadata%s --apply --limit=%d --offset=%d --until-budget=%s --format=json', + $scope_cli_arg, $limit, $restart_offset, $budget_label @@ -271,7 +285,8 @@ private function drain_worktree_metadata_reconciliation_budget( int $limit, int 'pagination' => $pagination, 'evidence' => array_filter( array( - 'scope' => 'time-budgeted metadata reconciliation direct apply', + 'scope' => null === $scope ? 'time-budgeted metadata reconciliation direct apply' : 'time-budgeted metadata reconciliation direct apply scoped to ' . (string) $scope['argument'], + 'repo_scope' => $scope, 'apply_source' => 'direct_apply', 'budget' => $budget_label, 'budget_seconds' => $budget_seconds, @@ -284,7 +299,7 @@ private function drain_worktree_metadata_reconciliation_budget( int $limit, int ), ); if ( $complete ) { - $result['remaining_blockers'] = $this->build_worktree_metadata_reconciliation_remaining_blockers($proposals, $written, $skipped, false, $limit, $budget_label); + $result['remaining_blockers'] = $this->build_worktree_metadata_reconciliation_remaining_blockers($proposals, $written, $skipped, false, $limit, $budget_label, $scope); } return $result; @@ -1500,6 +1515,9 @@ private function apply_worktree_metadata_reconciliation_plan( array $plan ): arr 'external_worktrees' => $classified_skips['external_worktrees'], 'summary' => $this->build_worktree_metadata_reconciliation_summary($inspected, $planned, $written, $skipped), ); + if ( isset($plan['scope']) && is_array($plan['scope']) ) { + $result['scope'] = $plan['scope']; + } if ( isset($plan['pagination']) && is_array($plan['pagination']) ) { $result['pagination'] = $plan['pagination']; @@ -1507,14 +1525,15 @@ private function apply_worktree_metadata_reconciliation_plan( array $plan ): arr $result['pagination'] = $this->restart_worktree_metadata_reconciliation_pagination( (array) $result['pagination'] ); } if ( ! empty($result['pagination']['complete']) ) { - $result['remaining_blockers'] = $this->build_worktree_metadata_reconciliation_remaining_blockers($planned, $written, $skipped, false, (int) ( $result['pagination']['limit'] ?? self::METADATA_RECONCILE_DEFAULT_LIMIT ), ''); + $result_scope = is_array($result['scope'] ?? null) ? $result['scope'] : null; + $result['remaining_blockers'] = $this->build_worktree_metadata_reconciliation_remaining_blockers($planned, $written, $skipped, false, (int) ( $result['pagination']['limit'] ?? self::METADATA_RECONCILE_DEFAULT_LIMIT ), '', $result_scope); } } if ( isset($plan['evidence']) && is_array($plan['evidence']) ) { $result['evidence'] = array_merge( $plan['evidence'], array( - 'scope' => ! empty($plan['direct_apply']) ? 'paginated metadata reconciliation direct apply' : (string) ( $plan['evidence']['scope'] ?? 'paginated metadata reconciliation apply-plan' ), + 'scope' => (string) ( $plan['evidence']['scope'] ?? ( ! empty($plan['direct_apply']) ? 'paginated metadata reconciliation direct apply' : 'paginated metadata reconciliation apply-plan' ) ), 'apply_source' => ! empty($plan['direct_apply']) ? 'direct_apply' : 'apply_plan', ) ); @@ -1711,9 +1730,10 @@ private function build_worktree_metadata_reconciliation_summary( int $inspected, * @param bool $dry_run Whether this was a dry-run page. * @param int $limit Page size for generated commands. * @param string $budget Optional budget label. + * @param array{argument: string, repo: string, handle: string|null}|null $scope Optional operation scope. * @return array */ - private function build_worktree_metadata_reconciliation_remaining_blockers( array $proposals, array $written, array $skipped, bool $dry_run, int $limit, string $budget ): array { + private function build_worktree_metadata_reconciliation_remaining_blockers( array $proposals, array $written, array $skipped, bool $dry_run, int $limit, string $budget, ?array $scope = null ): array { $written_handles = array_flip(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $written)); $pending_proposals = array_values(array_filter($proposals, fn( $row ) => '' === (string) ( $row['handle'] ?? '' ) || ! isset($written_handles[ (string) $row['handle'] ] ))); @@ -1743,13 +1763,14 @@ private function build_worktree_metadata_reconciliation_remaining_blockers( arra ksort($skipped_by_reason); $budget_arg = '' !== $budget ? ' --until-budget=' . $budget : ''; + $scope_arg = $this->worktree_operation_scope_cli_arg($scope); if ( $dry_run && array() !== $pending_proposals ) { $next_action = 'apply_reviewed_plan'; - $next_command = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=%d --offset=0%s --format=json', $limit, $budget_arg); + $next_command = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata%s --apply --limit=%d --offset=0%s --format=json', $scope_arg, $limit, $budget_arg); $why = 'The complete dry-run found repairable rows; applying the reviewed plan can reduce these blockers.'; } elseif ( $retry_may_help > 0 ) { $next_action = 'retry_after_transient_probe_failure'; - $next_command = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=%d --offset=0%s --format=json', $limit, $budget_arg); + $next_command = sprintf('studio wp datamachine-code workspace worktree reconcile-metadata%s --apply --limit=%d --offset=0%s --format=json', $scope_arg, $limit, $budget_arg); $why = 'Some rows were skipped because probes were unavailable or timed out; another pass may help after the underlying probe issue clears.'; } elseif ( $manual_repair > 0 ) { $next_action = 'manual_repair_required'; diff --git a/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php b/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php index 29477dfc..6cd1bad5 100644 --- a/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php +++ b/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php @@ -26,9 +26,10 @@ trait WorkspaceWorktreeInventoryCleanup { * @param bool $include_repaired_metadata Whether repaired metadata rows can be candidates. * @param int|null $limit Optional worktree page size. * @param int $offset Optional worktree page offset. + * @param string $scope_arg Optional primary repo or worktree handle scope. * @return array|\WP_Error */ - private function worktree_cleanup_inventory_only( string $older_than, string $sort, bool $include_repaired_metadata = false, ?int $limit = null, int $offset = 0 ): array|\WP_Error { + private function worktree_cleanup_inventory_only( string $older_than, string $sort, bool $include_repaired_metadata = false, ?int $limit = null, int $offset = 0, string $scope_arg = '' ): array|\WP_Error { $age_filter = null; if ( '' !== $older_than ) { $duration_seconds = $this->parse_worktree_cleanup_duration($older_than); @@ -42,7 +43,12 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so $candidates = array(); $skipped = array(); - $inventory_rows = array_values(array_filter($this->build_workspace_inventory_rows(), fn( $wt ) => ! empty($wt['is_worktree']) )); + $scope = $this->normalize_worktree_operation_scope($scope_arg); + if ( is_wp_error($scope) ) { + return $scope; + } + + $inventory_rows = array_values(array_filter($this->build_workspace_inventory_rows(), fn( $wt ) => ! empty($wt['is_worktree']) && $this->worktree_row_matches_operation_scope($wt, $scope) )); $total = count($inventory_rows); $offset = max(0, $offset); $page_rows = null === $limit ? array_slice($inventory_rows, $offset) : array_slice($inventory_rows, $offset, max(1, $limit)); @@ -203,6 +209,7 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so 'success' => true, 'dry_run' => true, 'inventory_only' => true, + 'scope' => $scope, 'candidates' => $candidates, 'removed' => array(), 'skipped' => $skipped, diff --git a/tests/smoke-abandoned-cleanup-orchestrator.php b/tests/smoke-abandoned-cleanup-orchestrator.php index 70c9a4ab..35592669 100644 --- a/tests/smoke-abandoned-cleanup-orchestrator.php +++ b/tests/smoke-abandoned-cleanup-orchestrator.php @@ -396,6 +396,7 @@ static function () use ( &$clock_index ): float { abandoned_cleanup_assert(in_array('studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=10 --scope=repo:stage-finalized --format=json', (array) $abandoned_zero_yield_result['continuation']['alternative_commands'], true), 'abandoned zero-yield suggests scoped bounded cleanup review'); abandoned_cleanup_assert(str_contains((string) $abandoned_zero_yield_result['continuation']['next_command'], ' worktree abandoned --apply --stage=finalized --offset=10 --limit=10 --passes=3 --until-budget=300s --scope=repo:stage-finalized --format=json'), 'abandoned zero-yield continuation preserves stage, budget, scope, and next page'); abandoned_cleanup_assert('repo:stage-finalized' === (string) ( $abandoned_zero_yield_finalized->calls[0]['scope'] ?? '' ), 'abandoned zero-yield forwards scope to child stage ability'); +abandoned_cleanup_assert(! array_key_exists('repo', $abandoned_zero_yield_finalized->calls[0]), 'abandoned zero-yield does not treat free-form operator scope as a repo filter'); abandoned_cleanup_assert(0 === count($abandoned_zero_yield_equivalent->calls), 'abandoned zero-yield stop avoids advancing into later active/no-signal stages'); abandoned_cleanup_assert(0 === count($abandoned_zero_yield_abilities['datamachine-code/workspace-worktree-prune']->calls), 'abandoned zero-yield stop skips prune while continuation remains'); diff --git a/tests/worktree-active-no-signal-scope.php b/tests/worktree-active-no-signal-scope.php new file mode 100644 index 00000000..435aff1a --- /dev/null +++ b/tests/worktree-active-no-signal-scope.php @@ -0,0 +1,124 @@ +code; + } + + public function get_error_message(): string { + return $this->message; + } + } +} + +if ( ! function_exists('is_wp_error') ) { + function is_wp_error( mixed $value ): bool { + return $value instanceof WP_Error; + } +} + +require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceHandle.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceCoreUtilities.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorktreeContextInjector.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorktreeAgeFilter.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupSignal.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupClassifier.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeInventoryCleanup.php'; + +function active_no_signal_scope_assert_same( mixed $expected, mixed $actual, string $message ): void { + if ( $expected !== $actual ) { + throw new RuntimeException(sprintf('%s Expected %s, got %s.', $message, var_export($expected, true), var_export($actual, true))); + } +} + +$cleanup = new class { + use DataMachineCode\Workspace\WorkspaceCoreUtilities; + use DataMachineCode\Workspace\WorkspaceWorktreeInventoryCleanup; + + public function expose( string $scope ): array|WP_Error { + return $this->worktree_cleanup_inventory_only('', '', false, 10, 0, $scope); + } + + private function build_workspace_inventory_rows(): array { + return array( + $this->row('homeboy@one', 'homeboy'), + $this->row('homeboy@two', 'homeboy'), + $this->row('homeboy-action@one', 'homeboy-action'), + ); + } + + private function row( string $handle, string $repo ): array { + return array( + 'is_worktree' => true, + 'handle' => $handle, + 'repo' => $repo, + 'branch' => 'feature', + 'path' => '/tmp/' . $handle, + 'created_at' => '2026-07-01T00:00:00+00:00', + 'liveness' => DataMachineCode\Workspace\WorktreeContextInjector::LIVENESS_STALE, + 'metadata' => array( + 'handle' => $handle, + 'repo' => $repo, + 'branch' => 'feature', + 'path' => '/tmp/' . $handle, + 'lifecycle_state' => DataMachineCode\Workspace\WorktreeContextInjector::STATE_ACTIVE, + ), + ); + } + + private function workspace_row_triage_status_from_metadata( array $metadata ): string { + return 'active'; + } + + private function build_submodule_worktree_cleanup_skip( array $row, string $real_path = '' ): ?array { + return null; + } + + private function build_worktree_cleanup_pagination( int $offset, ?int $limit, int $processed, int $total, bool $budget_stopped, ?array $budget_context ): ?array { + return array( + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'scanned' => $processed, + 'partial' => false, + 'complete' => true, + 'next_offset' => null, + ); + } + + private function build_worktree_cleanup_summary( array $candidates, array $removed, array $skipped, ?array $age_filter, string $bucket ): array { + return array( + 'candidates' => count($candidates), + 'skipped' => count($skipped), + ); + } + + private function sort_worktree_cleanup_rows( array $rows, string $sort ): array { + return $rows; + } +}; + +$repo_scoped = $cleanup->expose('homeboy'); +if ( is_wp_error($repo_scoped) ) { + throw new RuntimeException($repo_scoped->get_error_message()); +} +active_no_signal_scope_assert_same(2, $repo_scoped['pagination']['total'], 'repo-scoped active/no-signal inventory totals only matching repo rows'); +active_no_signal_scope_assert_same(array( 'homeboy@one', 'homeboy@two' ), array_column($repo_scoped['skipped'], 'handle'), 'repo-scoped active/no-signal examples only include matching repo rows'); + +$handle_scoped = $cleanup->expose('homeboy@two'); +if ( is_wp_error($handle_scoped) ) { + throw new RuntimeException($handle_scoped->get_error_message()); +} +active_no_signal_scope_assert_same(1, $handle_scoped['pagination']['total'], 'handle-scoped active/no-signal inventory totals only exact handle rows'); +active_no_signal_scope_assert_same(array( 'homeboy@two' ), array_column($handle_scoped['skipped'], 'handle'), 'handle-scoped active/no-signal examples only include exact handle row'); + +echo "worktree-active-no-signal-scope: ok\n"; diff --git a/tests/worktree-metadata-reconciliation-remaining-blockers.php b/tests/worktree-metadata-reconciliation-remaining-blockers.php index 13f43f2c..7d6ec23e 100644 --- a/tests/worktree-metadata-reconciliation-remaining-blockers.php +++ b/tests/worktree-metadata-reconciliation-remaining-blockers.php @@ -6,6 +6,8 @@ define('ABSPATH', __DIR__ . '/fixtures/'); } +require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceHandle.php'; +require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceCoreUtilities.php'; require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceMetadataReconciliation.php'; function worktree_metadata_reconciliation_remaining_blockers_assert_same( mixed $expected, mixed $actual, string $message ): void { @@ -15,6 +17,7 @@ function worktree_metadata_reconciliation_remaining_blockers_assert_same( mixed } $reconciler = new class { + use DataMachineCode\Workspace\WorkspaceCoreUtilities; use DataMachineCode\Workspace\WorkspaceMetadataReconciliation; }; @@ -49,6 +52,28 @@ function worktree_metadata_reconciliation_remaining_blockers_assert_same( mixed worktree_metadata_reconciliation_remaining_blockers_assert_same('apply_reviewed_plan', $dry_run['next_action'], 'dry-run next action points at apply when it can help'); worktree_metadata_reconciliation_remaining_blockers_assert_same('studio wp datamachine-code workspace worktree reconcile-metadata --apply --limit=25 --offset=0 --until-budget=30s --format=json', $dry_run['next_command'], 'dry-run next command is actionable'); +$scoped_dry_run = $method->invoke( + $reconciler, + array( + array( + 'handle' => 'homeboy@one', + 'reason_code' => 'metadata_backfill', + 'proposed_metadata' => array( 'lifecycle_state' => 'active' ), + ), + ), + array(), + array(), + true, + 25, + '30s', + array( + 'argument' => 'homeboy', + 'repo' => 'homeboy', + 'handle' => null, + ) +); +worktree_metadata_reconciliation_remaining_blockers_assert_same('studio wp datamachine-code workspace worktree reconcile-metadata homeboy --apply --limit=25 --offset=0 --until-budget=30s --format=json', $scoped_dry_run['next_command'], 'scoped dry-run next command preserves repo scope'); + $manual = $method->invoke( $reconciler, array(