diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index dd5b88d..0156f0c 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1376,6 +1376,14 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Present only when fetch_failed=true. Trimmed error output from the failing fetch.', ), + 'fetch_timed_out' => array( + 'type' => 'boolean', + 'description' => 'Present only when the pre-create freshness fetch timed out and allow_unverified_freshness=true allowed creation to continue. This is not stale-repository evidence.', + ), + 'fetch_timeout_seconds' => array( + 'type' => 'integer', + 'description' => 'Present only when fetch_timed_out=true. The bounded freshness-fetch budget in seconds.', + ), 'stale_commits_behind' => array( 'type' => 'integer', 'description' => 'For the existing-local-branch path, how many commits the worktree branch is behind its configured upstream. Omitted when no upstream is configured.', diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 33c9ba0..23e2819 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -265,6 +265,8 @@ private function worktree_add_locked( $fetch = WorktreeStalenessProbe::fetch($primary_path); $fetch_failed = ! $fetch['ok']; $fetch_error = $fetch['error'] ?? null; + $fetch_timed_out = ! empty($fetch['timed_out']); + $fetch_timeout_seconds = $fetch['timeout_seconds'] ?? null; if ( $fetch_failed && ! $allow_unverified_freshness ) { return new \WP_Error( 'worktree_freshness_unverified', @@ -273,6 +275,8 @@ private function worktree_add_locked( 'status' => 409, 'fetch_failed' => true, 'fetch_error' => $fetch_error, + 'fetch_timed_out' => $fetch_timed_out, + 'fetch_timeout_seconds' => $fetch_timeout_seconds, 'allow_unverified_freshness' => false, ) ); @@ -321,6 +325,10 @@ private function worktree_add_locked( if ( $fetch_failed ) { $response['fetch_failed'] = true; + if ( $fetch_timed_out ) { + $response['fetch_timed_out'] = true; + $response['fetch_timeout_seconds'] = $fetch_timeout_seconds; + } if ( null !== $fetch_error && '' !== $fetch_error ) { $response['fetch_error'] = $fetch_error; } diff --git a/inc/Workspace/WorktreeStalenessProbe.php b/inc/Workspace/WorktreeStalenessProbe.php index d9f5399..702842f 100644 --- a/inc/Workspace/WorktreeStalenessProbe.php +++ b/inc/Workspace/WorktreeStalenessProbe.php @@ -32,21 +32,38 @@ final class WorktreeStalenessProbe { + /** + * Keep the worktree-creation freshness check aligned with bounded lifecycle + * git probes, without allowing a remote to hold the workspace mutation lock. + */ + private const FETCH_TIMEOUT_SECONDS = 5; /** * Fetch `origin` in a repository. Returns structured result instead of * throwing so the caller can decide whether to continue on failure. * - * @param string $repo_path Primary repo path (passed to `git -C`). - * @return array{ok: bool, error?: string} + * @param string $repo_path Primary repo path (passed to `git -C`). + * @param callable|null $runner Optional git runner, used by deterministic tests. + * @return array{ok: bool, error?: string, timed_out?: bool, timeout_seconds?: int} */ - public static function fetch( string $repo_path ): array { - $result = GitRunner::run($repo_path, 'fetch --quiet origin'); + public static function fetch( string $repo_path, ?callable $runner = null ): array { + $runner = $runner ?? static fn( string $path, string $args, int $timeout ): array|\WP_Error => GitRunner::run($path, $args, $timeout); + $result = $runner($repo_path, 'fetch --quiet origin', self::FETCH_TIMEOUT_SECONDS); if ( is_wp_error($result) ) { $data = $result->get_error_data(); $tail = is_array($data) && isset($data['output']) ? trim( (string) $data['output']) : ''; $error = '' !== $tail ? $tail : $result->get_error_message(); + $code = method_exists($result, 'get_error_code') ? $result->get_error_code() : ''; + if ( 'git_command_timeout' === $code ) { + return array( + 'ok' => false, + 'timed_out' => true, + 'timeout_seconds' => self::FETCH_TIMEOUT_SECONDS, + 'error' => sprintf('Remote freshness fetch timed out after %d seconds. Check origin connectivity or credentials and retry; use allow_unverified_freshness=true only for intentional offline work.', self::FETCH_TIMEOUT_SECONDS), + ); + } + return array( 'ok' => false, 'error' => $error, diff --git a/tests/process-runner-command-spec.php b/tests/process-runner-command-spec.php index 614ba46..0702520 100644 --- a/tests/process-runner-command-spec.php +++ b/tests/process-runner-command-spec.php @@ -70,6 +70,16 @@ function process_runner_assert_not_error( mixed $result, string $message ): void process_runner_assert_same('argv-ok|' . basename($cwd), $result['stdout'], 'CommandSpec preserves argv, cwd, and env policy.'); process_runner_assert_same('', $result['stderr'], 'CommandSpec captures stderr separately.'); +$timed_out = ProcessRunner::run( + CommandSpec::from_argv(array( PHP_BINARY, '-r', 'while (true) {}' )), + array( + 'timeout_seconds' => 1, + 'poll_interval_microseconds' => 1000, + ) +); +process_runner_assert_same(true, $timed_out instanceof WP_Error, 'ProcessRunner should terminate a controlled hanging process.'); +process_runner_assert_same(1, $timed_out->get_error_data()['timeout'] ?? null, 'ProcessRunner timeout result carries the configured budget.'); + $invalid = CommandSpec::from_argv(array()); process_runner_assert_same(true, $invalid instanceof WP_Error, 'CommandSpec rejects empty argv.'); diff --git a/tests/worktree-staleness-fetch-timeout.php b/tests/worktree-staleness-fetch-timeout.php new file mode 100644 index 0000000..5cdda82 --- /dev/null +++ b/tests/worktree-staleness-fetch-timeout.php @@ -0,0 +1,47 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } + } +} + +function is_wp_error( mixed $value ): bool { + return $value instanceof WP_Error; +} + +require_once dirname(__DIR__) . '/inc/Workspace/WorktreeStalenessProbe.php'; + +use DataMachineCode\Workspace\WorktreeStalenessProbe; + +function staleness_timeout_assert_same( mixed $expected, mixed $actual, string $message ): void { + if ( $expected !== $actual ) { + throw new RuntimeException($message . ' Expected ' . var_export($expected, true) . ', got ' . var_export($actual, true) . '.'); + } +} + +$calls = array(); +$result = WorktreeStalenessProbe::fetch( + '/repo', + static function ( string $path, string $args, int $timeout ) use ( &$calls ): WP_Error { + $calls[] = array( $path, $args, $timeout ); + return new WP_Error('git_command_timeout', 'Process command timed out after 5 second(s).', array( 'timeout' => 5 )); + } +); + +staleness_timeout_assert_same(array( array( '/repo', 'fetch --quiet origin', 5 ) ), $calls, 'Freshness fetch must use the bounded GitRunner timeout contract.'); +staleness_timeout_assert_same(false, $result['ok'], 'Timed-out freshness fetch must fail verification.'); +staleness_timeout_assert_same(true, $result['timed_out'] ?? null, 'Timed-out freshness fetch must remain distinct from stale evidence.'); +staleness_timeout_assert_same(5, $result['timeout_seconds'] ?? null, 'Timed-out freshness fetch must surface its budget.'); +staleness_timeout_assert_same(true, str_contains((string) ( $result['error'] ?? '' ), 'allow_unverified_freshness=true'), 'Timed-out freshness fetch must provide an actionable opt-in diagnostic.'); + +fwrite(STDOUT, "worktree-staleness-fetch-timeout: ok\n");