Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
8 changes: 8 additions & 0 deletions inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
)
);
Expand Down Expand Up @@ -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;
}
Expand Down
25 changes: 21 additions & 4 deletions inc/Workspace/WorktreeStalenessProbe.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions tests/process-runner-command-spec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');

Expand Down
47 changes: 47 additions & 0 deletions tests/worktree-staleness-fetch-timeout.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

if ( ! defined('ABSPATH') ) {
define('ABSPATH', __DIR__ . '/fixtures/');
}

if ( ! class_exists('WP_Error') ) {
final class WP_Error {
public function __construct( private string $code = '', private string $message = '', private array $data = array() ) {}
public function get_error_code(): string { return $this->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");