diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 739cc693..d5f1135a 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2288,6 +2288,10 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Schedule each candidate as a single-row worktree_cleanup_chunk job for resumable async apply.', ), + 'remove_timeout' => array( + 'type' => 'integer', + 'description' => 'Timeout in seconds for destructive git worktree remove calls during apply. Defaults to the cleanup removal timeout.', + ), 'include_repaired_metadata' => array( 'type' => 'boolean', 'description' => 'Also include repaired metadata rows. Requires explicit opt-in and still runs fresh safety probes before removal.', @@ -4031,7 +4035,7 @@ public static function worktreeCleanupArtifacts( array $input ): array|\WP_Error /** * Apply only worktrees with explicit lifecycle cleanup_eligible metadata in a bounded batch. * - * @param array $input Input parameters (dry_run, limit, older_than, sort, force, via_jobs, source). + * @param array $input Input parameters (dry_run, limit, older_than, sort, force, via_jobs, remove_timeout, source). * @return array|\WP_Error */ public static function worktreeBoundedCleanupEligibleApply( array $input ): array|\WP_Error { @@ -4051,6 +4055,9 @@ public static function worktreeBoundedCleanupEligibleApply( array $input ): arra if ( isset($input['sort']) && '' !== trim( (string) $input['sort']) ) { $opts['sort'] = trim( (string) $input['sort']); } + if ( isset($input['remove_timeout']) ) { + $opts['remove_timeout'] = (int) $input['remove_timeout']; + } if ( isset($input['source']) && '' !== trim( (string) $input['source']) ) { $opts['source'] = trim( (string) $input['source']); } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index dd13e9f7..1c855f6d 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -2960,10 +2960,14 @@ private function renderGitOperationResult( string $operation, array $result, arr * metadata older than the compact duration (cleanup only, e.g. 7d, 24h). * Candidate worktrees without valid `created_at` metadata are skipped. * - * [--sort=] - * : Sort cleanup candidates by reporting field. For artifact cleanup, - * `--sort=size` scans the cheap inventory once and returns the largest - * artifact opportunities without manual pagination. + * [--remove-timeout=] + * : Timeout for destructive `git worktree remove` during cleanup apply. + * Defaults to a larger removal-specific budget than cheap git probes. + * + * [--sort=] + * : Sort cleanup candidates by reporting field. For artifact cleanup, + * `--sort=size` scans the cheap inventory once and returns the largest + * artifact opportunities without manual pagination. * --- * options: * - size @@ -3492,6 +3496,9 @@ public function worktree( array $args, array $assoc_args ): void { if ( isset($assoc_args['sort']) && '' !== trim( (string) $assoc_args['sort']) ) { $input['sort'] = trim( (string) $assoc_args['sort']); } + if ( isset($assoc_args['remove-timeout']) && '' !== trim( (string) $assoc_args['remove-timeout']) ) { + $input['remove_timeout'] = (int) $assoc_args['remove-timeout']; + } break; } diff --git a/inc/Tasks/WorktreeCleanupChunkTask.php b/inc/Tasks/WorktreeCleanupChunkTask.php index 34051f96..03b920fd 100644 --- a/inc/Tasks/WorktreeCleanupChunkTask.php +++ b/inc/Tasks/WorktreeCleanupChunkTask.php @@ -111,6 +111,7 @@ public function executeTask( int $jobId, array $params ): void { 'skip_github' => array_key_exists('skip_github', $params) ? (bool) $params['skip_github'] : true, 'include_repaired_metadata' => ! empty($params['include_repaired_metadata']), 'stale_liveness_only' => ! empty($params['stale_liveness_only']), + 'remove_timeout' => isset($params['remove_timeout']) ? (int) $params['remove_timeout'] : null, ) ), default => new \WP_Error('invalid_cleanup_chunk_type', sprintf('Unknown cleanup chunk type: %s', $chunk_type), array( 'status' => 400 )), diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 113dc9d0..5d7b4745 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -59,6 +59,11 @@ class Workspace { */ protected const CLEANUP_GIT_PROBE_TIMEOUT = 5; + /** + * Bound destructive git worktree removal separately from cheap probes. + */ + protected const CLEANUP_GIT_REMOVE_TIMEOUT = 60; + /** * Closed PR pages to inspect per repo during cleanup. */ diff --git a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php index 409d8e52..89b6985a 100644 --- a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php +++ b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php @@ -64,6 +64,10 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $offset = array_key_exists('offset', $opts) ? max(0, (int) $opts['offset']) : 0; $budget_context = null; $budget_stopped = false; + $remove_timeout_seconds = $this->normalize_worktree_cleanup_remove_timeout($opts['remove_timeout'] ?? null); + if ( is_wp_error($remove_timeout_seconds) ) { + return $remove_timeout_seconds; + } if ( isset($opts['until_budget']) && '' !== trim( (string) $opts['until_budget']) ) { if ( ! $dry_run ) { @@ -108,7 +112,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $force = false; if ( $direct_apply_plan && ! $dry_run ) { - return $this->apply_worktree_cleanup_plan_candidates($planned_candidates, $force, $started_at, $stale_liveness_only); + return $this->apply_worktree_cleanup_plan_candidates($planned_candidates, $force, $started_at, $stale_liveness_only, $remove_timeout_seconds); } } @@ -650,8 +654,8 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $remove = WorkspaceMutationLock::with_repo( $this->workspace_path, $cand['repo'], - function () use ( $cand, $force ) { - $remove = $this->remove_worktree_by_path($cand['repo'], $cand['branch'], $cand['path'], $force); + function () use ( $cand, $force, $remove_timeout_seconds ) { + $remove = $this->remove_worktree_by_path($cand['repo'], $cand['branch'], $cand['path'], $force, $remove_timeout_seconds); if ( is_wp_error($remove) ) { return $remove; } @@ -664,17 +668,7 @@ function () use ( $cand, $force ) { } ); if ( is_wp_error($remove) ) { - $skipped[] = array( - 'handle' => $cand['handle'], - 'repo' => $cand['repo'] ?? '', - 'branch' => $cand['branch'] ?? '', - 'path' => $cand['path'] ?? '', - 'reason_code' => 'remove_failed', - 'reason' => 'remove failed: ' . $remove->get_error_message(), - 'created_at' => $cand['created_at'] ?? null, - 'metadata' => $cand['metadata'] ?? null, - 'size_bytes' => $cand['size_bytes'] ?? null, - ); + $skipped[] = $this->build_worktree_remove_failure_skip($cand, $remove, $remove_timeout_seconds); continue; } $removed[] = array_merge( @@ -754,9 +748,6 @@ private function dedupe_worktree_cleanup_rows( array $rows ): array { $seen = array(); $result = array(); foreach ( $rows as $row ) { - if ( ! is_array($row) ) { - continue; - } $key = implode('|', array( (string) ( $row['handle'] ?? '' ), (string) ( $row['path'] ?? '' ), @@ -913,6 +904,7 @@ private function classify_worktree_git_probe_failure( string $handle, string $re * @return string */ private function get_wp_error_code( \WP_Error $error ): string { + // @phpstan-ignore-next-line Smoke tests provide a minimal WP_Error stub. return method_exists($error, 'get_error_code') ? (string) $error->get_error_code() : (string) ( $error->code ?? '' ); } @@ -923,6 +915,7 @@ private function get_wp_error_code( \WP_Error $error ): string { * @return mixed */ private function get_wp_error_data( \WP_Error $error ): mixed { + // @phpstan-ignore-next-line Smoke tests provide a minimal WP_Error stub. return method_exists($error, 'get_error_data') ? $error->get_error_data() : ( $error->data ?? null ); } @@ -961,6 +954,10 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ) $older_than = isset($opts['older_than']) ? trim( (string) $opts['older_than']) : ''; $sort = isset($opts['sort']) ? trim( (string) $opts['sort']) : 'age'; $source = isset($opts['source']) ? trim( (string) $opts['source']) : 'workspace_bounded_cleanup_eligible_apply'; + $remove_timeout_seconds = $this->normalize_worktree_cleanup_remove_timeout($opts['remove_timeout'] ?? null); + if ( is_wp_error($remove_timeout_seconds) ) { + return $remove_timeout_seconds; + } $limit = isset($opts['limit']) ? (int) $opts['limit'] : 25; if ( $limit < 1 ) { @@ -1000,6 +997,7 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ) 'next_call_hint' => count($deferred) > 0 ? sprintf('Run the bounded cleanup-eligible apply again to drain the next %d candidate(s).', min($limit, count($deferred))) : null, 'inventory_skipped' => count($inventory_skipped), 'limit_applied' => $limit, + 'remove_timeout' => $remove_timeout_seconds, ); if ( $dry_run ) { @@ -1025,19 +1023,21 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ) 'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000), 'inventory_total' => count($all_candidates), 'planned_handles' => array_values(array_filter(array_map(fn( $row ) => is_array($row) ? (string) ( $row['handle'] ?? '' ) : '', $batch))), + 'remove_timeout' => $remove_timeout_seconds, 'source' => $source, ), ); } if ( $via_jobs ) { - return $this->schedule_bounded_cleanup_eligible_chunks($batch, $deferred, $force, $source, $started_at, $continuation, $include_repaired_metadata); + return $this->schedule_bounded_cleanup_eligible_chunks($batch, $deferred, $force, $source, $started_at, $continuation, $include_repaired_metadata, $remove_timeout_seconds); } $processed = 0; $removed = array(); $skipped = $inventory_skipped; $bytes_reclaimed = 0; + $timeout_handles = array(); foreach ( $batch as $candidate ) { ++$processed; @@ -1060,8 +1060,8 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ) $remove = WorkspaceMutationLock::with_repo( $this->workspace_path, $repo, - function () use ( $repo, $branch, $wt_path, $force ) { - $result = $this->remove_worktree_by_path($repo, $branch, $wt_path, $force); + function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) { + $result = $this->remove_worktree_by_path($repo, $branch, $wt_path, $force, $remove_timeout_seconds); if ( is_wp_error($result) ) { return $result; } @@ -1082,14 +1082,11 @@ function () use ( $repo, $branch, $wt_path, $force ) { ); if ( is_wp_error($remove) ) { - $skipped[] = array( - 'handle' => (string) ( $candidate['handle'] ?? '' ), - 'repo' => $repo, - 'branch' => $branch, - 'path' => $wt_path, - 'reason_code' => 'remove_failed', - 'reason' => 'remove failed: ' . $remove->get_error_message(), - ); + $skip = $this->build_worktree_remove_failure_skip($candidate, $remove, $remove_timeout_seconds); + $skipped[] = $skip; + if ( 'remove_timeout' === (string) ( $skip['reason_code'] ?? '' ) ) { + $timeout_handles[] = (string) ( $skip['handle'] ?? '' ); + } continue; } @@ -1107,6 +1104,12 @@ function () use ( $repo, $branch, $wt_path, $force ) { $bytes_reclaimed += max(0, $size); } + if ( array() !== array_filter($timeout_handles) ) { + $continuation['timeout_handles'] = array_values(array_filter($timeout_handles)); + $continuation['timeout_resume_command'] = $this->build_bounded_cleanup_resume_command($limit, $opts, $this->next_worktree_cleanup_remove_timeout($remove_timeout_seconds)); + $continuation['timeout_hint'] = 'Removal timed out after passing safety checks; rerun with a larger --remove-timeout to resume the remaining cleanup-eligible rows.'; + } + // Best-effort prune in case any registry rows are now stale. $this->worktree_prune(); @@ -1133,6 +1136,7 @@ function () use ( $repo, $branch, $wt_path, $force ) { 'inventory_total' => count($all_candidates), 'removed_handles' => array_values(array_filter(array_map(fn( $row ) => (string) $row['handle'], $removed))), 'skipped_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $skipped))), + 'remove_timeout' => $remove_timeout_seconds, 'source' => $source, ), ); @@ -1146,7 +1150,7 @@ function () use ( $repo, $branch, $wt_path, $force ) { * @param float $started_at Start timestamp. * @return array */ - private function apply_worktree_cleanup_plan_candidates( array $candidates, bool $force, float $started_at, bool $stale_liveness_only = false ): array { + private function apply_worktree_cleanup_plan_candidates( array $candidates, bool $force, float $started_at, bool $stale_liveness_only = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT ): array { $processed = 0; $removed = array(); $skipped = array(); @@ -1173,8 +1177,8 @@ private function apply_worktree_cleanup_plan_candidates( array $candidates, bool $remove = WorkspaceMutationLock::with_repo( $this->workspace_path, $repo, - function () use ( $repo, $branch, $wt_path, $force ) { - $result = $this->remove_worktree_by_path($repo, $branch, $wt_path, $force); + function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) { + $result = $this->remove_worktree_by_path($repo, $branch, $wt_path, $force, $remove_timeout_seconds); if ( is_wp_error($result) ) { return $result; } @@ -1192,14 +1196,9 @@ function () use ( $repo, $branch, $wt_path, $force ) { ); if ( is_wp_error($remove) ) { - $skipped[] = array( - 'handle' => (string) ( $candidate['handle'] ?? '' ), - 'repo' => $repo, - 'branch' => $branch, - 'path' => $wt_path, - 'reason_code' => 'remove_failed', - 'reason' => 'remove failed: ' . $remove->get_error_message(), - 'size_bytes' => $size, + $skipped[] = array_merge( + $this->build_worktree_remove_failure_skip($candidate, $remove, $remove_timeout_seconds), + array( 'size_bytes' => $size ) ); continue; } @@ -1468,7 +1467,7 @@ private function revalidate_bounded_cleanup_eligible_candidate( array $candidate * @param array $continuation Continuation envelope. * @return array|\WP_Error */ - private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $deferred, bool $force, string $source, float $started_at, array $continuation, bool $include_repaired_metadata = false ): array|\WP_Error { + private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $deferred, bool $force, string $source, float $started_at, array $continuation, bool $include_repaired_metadata = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT ): array|\WP_Error { if ( ! class_exists('\DataMachine\Engine\Tasks\TaskScheduler') ) { return new \WP_Error('task_scheduler_unavailable', 'Data Machine TaskScheduler is unavailable; cannot schedule bounded cleanup-eligible apply chunks.', array( 'status' => 500 )); } @@ -1523,6 +1522,7 @@ private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $ 'force' => $force, 'skip_github' => true, 'include_repaired_metadata' => $include_repaired_metadata, + 'remove_timeout' => $remove_timeout_seconds, 'source' => $source, ); } @@ -1563,11 +1563,106 @@ private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $ 'planned_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $batch))), 'batch_job_id' => (int) ( $batch_result['batch_job_id'] ?? 0 ), 'direct_job_ids' => $batch_result['job_ids'] ?? array(), + 'remove_timeout' => $remove_timeout_seconds, 'source' => $source, ), ); } + /** + * Normalize a cleanup worktree removal timeout. + * + * @param mixed $value Raw option value. + * @return int|\WP_Error + */ + private function normalize_worktree_cleanup_remove_timeout( mixed $value ): int|\WP_Error { + if ( null === $value || '' === $value ) { + return self::CLEANUP_GIT_REMOVE_TIMEOUT; + } + + $timeout = (int) $value; + if ( $timeout < self::CLEANUP_GIT_PROBE_TIMEOUT ) { + return new \WP_Error('invalid_cleanup_remove_timeout', sprintf('Cleanup remove timeout must be at least %d seconds.', self::CLEANUP_GIT_PROBE_TIMEOUT), array( 'status' => 400 )); + } + + return min($timeout, 3600); + } + + /** + * Suggest a larger timeout while keeping reruns bounded. + * + * @param int $timeout Current timeout in seconds. + * @return int + */ + private function next_worktree_cleanup_remove_timeout( int $timeout ): int { + return min(3600, max($timeout + 30, $timeout * 2)); + } + + /** + * Build a resumable bounded cleanup command for timeout rows. + * + * @param int $limit Candidate limit. + * @param array $opts Original apply options. + * @param int $remove_timeout_seconds Timeout to include. + * @return string + */ + private function build_bounded_cleanup_resume_command( int $limit, array $opts, int $remove_timeout_seconds ): string { + $parts = array( + 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply', + '--limit=' . max(1, $limit), + '--remove-timeout=' . $remove_timeout_seconds, + ); + + if ( ! empty($opts['force']) ) { + $parts[] = '--force'; + } + if ( ! empty($opts['include_repaired_metadata']) ) { + $parts[] = '--include-repaired-metadata'; + } + if ( isset($opts['older_than']) && '' !== trim( (string) $opts['older_than']) ) { + $parts[] = '--older-than=' . escapeshellarg(trim( (string) $opts['older_than'])); + } + if ( isset($opts['sort']) && '' !== trim( (string) $opts['sort']) ) { + $parts[] = '--sort=' . escapeshellarg(trim( (string) $opts['sort'])); + } + + return implode(' ', $parts); + } + + /** + * Build a stable skipped row for git worktree removal failures. + * + * @param array $candidate Candidate row. + * @param \WP_Error $error Removal error. + * @param int $remove_timeout_seconds Timeout used. + * @return array + */ + private function build_worktree_remove_failure_skip( array $candidate, \WP_Error $error, int $remove_timeout_seconds ): array { + $is_timeout = $this->is_git_timeout_error($error); + $row = array( + 'handle' => (string) ( $candidate['handle'] ?? '' ), + 'repo' => (string) ( $candidate['repo'] ?? '' ), + 'branch' => (string) ( $candidate['branch'] ?? '' ), + 'path' => (string) ( $candidate['path'] ?? '' ), + 'reason_code' => $is_timeout ? 'remove_timeout' : 'remove_failed', + 'reason' => ( $is_timeout ? sprintf('remove timed out after %d second(s): ', $remove_timeout_seconds) : 'remove failed: ' ) . $error->get_error_message(), + 'created_at' => $candidate['created_at'] ?? null, + 'metadata' => $candidate['metadata'] ?? null, + ); + + if ( isset($candidate['size_bytes']) ) { + $row['size_bytes'] = $candidate['size_bytes']; + } + + if ( $is_timeout ) { + $row['remove_timeout'] = $remove_timeout_seconds; + $row['hint'] = 'Safety checks passed, but git worktree remove exceeded the cleanup removal timeout. Rerun bounded cleanup with a larger --remove-timeout value.'; + $row['continuation_command'] = $this->build_bounded_cleanup_resume_command(25, array(), $this->next_worktree_cleanup_remove_timeout($remove_timeout_seconds)); + } + + return $row; + } + /** * Build stable cleanup counts for CLI and automation consumers. * @@ -1666,10 +1761,6 @@ private function dedupe_worktree_cleanup_scan_rows( array $rows ): array { $seen = array(); $result = array(); foreach ( $rows as $row ) { - if ( ! is_array($row) ) { - continue; - } - $handle = (string) ( $row['handle'] ?? '' ); $path = (string) ( $row['path'] ?? '' ); $key = '' !== $handle || '' !== $path ? $handle . '|' . $path : wp_json_encode($row); @@ -2471,7 +2562,7 @@ private function scope_worktree_cleanup_to_plan( array $planned_candidates, arra * @param bool $force Pass --force to `git worktree remove`. * @return array{success: bool, handle: string, message: string}|\WP_Error */ - private function remove_worktree_by_path( string $repo, string $branch, string $wt_path, bool $force ): array|\WP_Error { + private function remove_worktree_by_path( string $repo, string $branch, string $wt_path, bool $force, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT ): array|\WP_Error { $repo = $this->sanitize_name($repo); if ( '' === $repo ) { return new \WP_Error('invalid_repo', 'Repository name is required.', array( 'status' => 400 )); @@ -2518,7 +2609,7 @@ private function remove_worktree_by_path( string $repo, string $branch, string $ } $cmd = sprintf('worktree remove %s%s', $force ? '--force ' : '', escapeshellarg($real_path)); - $result = $this->run_git($primary_path, $cmd, self::CLEANUP_GIT_PROBE_TIMEOUT); + $result = $this->run_git($primary_path, $cmd, $remove_timeout_seconds); if ( is_wp_error($result) ) { return $result; diff --git a/tests/smoke-worktree-bounded-cleanup-eligible-apply.php b/tests/smoke-worktree-bounded-cleanup-eligible-apply.php index c50a66c0..0d6a7434 100644 --- a/tests/smoke-worktree-bounded-cleanup-eligible-apply.php +++ b/tests/smoke-worktree-bounded-cleanup-eligible-apply.php @@ -352,9 +352,15 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra $assert(2, count($dry['candidates'] ?? array()), 'bounded limit caps dry-run candidates'); $remaining = (int) ( $dry['continuation']['remaining_total'] ?? 0 ); $assert(true, $remaining >= 4, 'continuation reports remaining cleanup-eligible candidates'); + $assert(60, (int) ( $dry['continuation']['remove_timeout'] ?? 0 ), 'dry-run exposes default cleanup removal timeout'); $assert_skipped($dry['skipped'] ?? array(), 'demo@repaired-metadata-clean', 'active_no_signal', 'repaired metadata rows require explicit include flag'); $assert_skipped($dry['skipped'] ?? array(), 'demo@eligible-submodule', 'submodule_worktree', 'submodule worktree is skipped before planning removal'); + $custom_timeout_dry = $ws->worktree_bounded_cleanup_eligible_apply(array( 'dry_run' => true, 'limit' => 2, 'remove_timeout' => 120 )); + $assert(true, ! is_wp_error($custom_timeout_dry) && ( $custom_timeout_dry['success'] ?? false ), 'custom remove-timeout dry-run returns success'); + $assert(120, (int) ( $custom_timeout_dry['continuation']['remove_timeout'] ?? 0 ), 'custom remove-timeout is reflected in continuation'); + $assert(120, (int) ( $custom_timeout_dry['evidence']['remove_timeout'] ?? 0 ), 'custom remove-timeout is reflected in evidence'); + $repaired_dry = $ws->worktree_bounded_cleanup_eligible_apply(array( 'dry_run' => true, 'limit' => 20, 'include_repaired_metadata' => true, 'older_than' => '24h' )); $assert(true, ! is_wp_error($repaired_dry) && ( $repaired_dry['success'] ?? false ), 'repaired-metadata dry-run returns success'); $assert_contains($repaired_dry['candidates'] ?? array(), 'demo@repaired-metadata-clean', 'repaired metadata clean worktree is candidate with explicit flag'); @@ -376,9 +382,27 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra $bad_limit = $ws->worktree_bounded_cleanup_eligible_apply(array( 'dry_run' => true, 'limit' => 0 )); $assert(true, is_wp_error($bad_limit), 'invalid limit returns WP_Error'); + $bad_remove_timeout = $ws->worktree_bounded_cleanup_eligible_apply(array( 'dry_run' => true, 'remove_timeout' => 1 )); + $assert(true, is_wp_error($bad_remove_timeout), 'remove-timeout below probe timeout returns WP_Error'); + $bad_via_jobs_dry_run = $ws->worktree_bounded_cleanup_eligible_apply(array( 'dry_run' => true, 'via_jobs' => true )); $assert(true, is_wp_error($bad_via_jobs_dry_run), 'via_jobs combined with dry_run is rejected'); + $timeout_skip_reflection = new \ReflectionMethod($ws, 'build_worktree_remove_failure_skip'); + $timeout_skip = $timeout_skip_reflection->invoke( + $ws, + array( + 'handle' => 'demo@eligible-clean', + 'repo' => 'demo', + 'branch' => 'eligible-clean', + 'path' => $tmp . '/demo@eligible-clean', + ), + new \WP_Error('git_command_timeout', 'Git command timed out after 7 second(s).', array( 'timeout' => 7 )), + 7 + ); + $assert('remove_timeout', $timeout_skip['reason_code'] ?? '', 'git remove timeout is classified separately from generic remove failure'); + $assert(true, str_contains((string) ( $timeout_skip['continuation_command'] ?? '' ), '--remove-timeout='), 'timeout skip includes resumable remove-timeout command'); + echo "\nApply scenario (synchronous)\n"; $apply = $ws->worktree_bounded_cleanup_eligible_apply(array( 'limit' => 10 )); $assert(true, ! is_wp_error($apply) && ( $apply['success'] ?? false ), 'apply returns success'); diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index a85c88c0..c6b4afdb 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -1216,6 +1216,7 @@ public function execute( array $input ): array datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--include-repaired-metadata]"), 'worktree synopsis declares --include-repaired-metadata at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--apply]"), 'worktree synopsis declares --apply at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--via-jobs]"), 'worktree synopsis declares --via-jobs at top level'); + datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--remove-timeout=]"), 'worktree synopsis declares --remove-timeout at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--passes=]"), 'worktree synopsis declares abandoned --passes at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--stage=]"), 'worktree synopsis declares abandoned --stage at top level'); datamachine_code_cleanup_assert(! str_contains($doc_comment, "\n\t\t * [--apply-plan=]"), 'cleanup flags are not hidden behind nested docblock indentation'); @@ -1832,11 +1833,17 @@ public function execute( array $input ): array WP_CLI::$logs = array(); WP_CLI::$successes = array(); $command->worktree(array( 'bounded-cleanup-eligible-apply' ), array( 'limit' => 25 )); + datamachine_code_cleanup_assert(25 === (int) ( $bounded_apply_ability->last_input['limit'] ?? 0 ), 'bounded cleanup apply forwards limit'); datamachine_code_cleanup_assert(in_array('Result: removed 1 worktree(s); reclaimed 4.0 KiB; skipped 2.', WP_CLI::$logs, true), 'bounded cleanup apply highlights removed/reclaimed totals first'); datamachine_code_cleanup_assert(in_array('Skipped summary:', WP_CLI::$logs, true), 'bounded cleanup apply summarizes skipped rows by default'); datamachine_code_cleanup_assert(in_array('table:4:reason_code,count,examples', WP_CLI::$logs, true), 'bounded cleanup apply groups skipped reasons'); datamachine_code_cleanup_assert(! in_array('table:32:handle,reason_code,reason', WP_CLI::$logs, true), 'bounded cleanup apply default output does not list every skipped row'); + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree(array( 'bounded-cleanup-eligible-apply' ), array( 'limit' => 25, 'remove-timeout' => 120, 'format' => 'json' )); + datamachine_code_cleanup_assert(120 === (int) ( $bounded_apply_ability->last_input['remove_timeout'] ?? 0 ), 'bounded cleanup apply forwards remove-timeout'); + WP_CLI::$logs = array(); WP_CLI::$successes = array(); $command->worktree(array( 'bounded-cleanup-eligible-apply' ), array( 'limit' => 25, 'verbose' => true )); diff --git a/tests/smoke-worktree-cleanup-remove-guard.php b/tests/smoke-worktree-cleanup-remove-guard.php index e9abe23c..d770ba06 100644 --- a/tests/smoke-worktree-cleanup-remove-guard.php +++ b/tests/smoke-worktree-cleanup-remove-guard.php @@ -9,10 +9,12 @@ $source_path = __DIR__ . '/../inc/Workspace/WorkspaceWorktreeCleanupEngine.php'; $service_path = __DIR__ . '/../inc/Workspace/CleanupRunService.php'; +$workspace_path = __DIR__ . '/../inc/Workspace/Workspace.php'; $source = file_get_contents($source_path); $service = file_get_contents($service_path); +$workspace = file_get_contents($workspace_path); -if ( false === $source || false === $service ) { +if ( false === $source || false === $service || false === $workspace ) { fwrite(STDERR, "Could not read cleanup source.\n"); exit(1); } @@ -25,8 +27,20 @@ $function = $matches[0]; $failures = array(); -if ( ! str_contains($function, '$this->run_git($primary_path, $cmd, self::CLEANUP_GIT_PROBE_TIMEOUT)') ) { - $failures[] = 'git worktree remove must use the cleanup git timeout'; +if ( ! str_contains($function, '$this->run_git($primary_path, $cmd, $remove_timeout_seconds)') ) { + $failures[] = 'git worktree remove must use the configurable cleanup removal timeout'; +} + +if ( ! str_contains($workspace, 'protected const CLEANUP_GIT_REMOVE_TIMEOUT = 60') ) { + $failures[] = 'cleanup removal must have a larger removal-specific default timeout'; +} + +if ( ! str_contains($source, "'reason_code' => \$is_timeout ? 'remove_timeout' : 'remove_failed'") ) { + $failures[] = 'cleanup removal timeout failures must be distinguished from generic remove failures'; +} + +if ( ! str_contains($source, 'timeout_resume_command') || ! str_contains($source, '--remove-timeout=') ) { + $failures[] = 'bounded cleanup timeout rows must emit a resumable remove-timeout command'; } if ( str_contains($function, 'rm -rf') ) { @@ -41,7 +55,7 @@ $failures[] = 'DB-backed cleanup apply must have a direct plan path'; } -if ( ! str_contains($service, "'direct_apply_plan' => true") ) { +if ( ! preg_match("/'direct_apply_plan'\s*=>\s*true/", $service) ) { $failures[] = 'CleanupRunService must request direct plan apply for worktree rows'; }