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
24 changes: 24 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,30 @@ private function registerAbilities(): void {
'type' => 'object',
'description' => 'Present only when bootstrap=true. Contains success/ran_any booleans and a steps array.',
),
'fetch_failed' => array(
'type' => 'boolean',
'description' => 'Present only when the pre-create `git fetch origin` failed. Worktree creation continues either way; staleness fields are omitted when true.',
),
'fetch_error' => array(
'type' => 'string',
'description' => 'Present only when fetch_failed=true. Trimmed error output from the failing fetch.',
),
'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.',
),
'upstream' => array(
'type' => 'string',
'description' => 'Paired with stale_commits_behind: the upstream ref label (e.g. `origin/fix/foo`).',
),
'base_stale_commits_behind' => array(
'type' => 'integer',
'description' => 'For the new-branch path cut from a local base ref: how many commits that local base is behind its origin counterpart at fetch time.',
),
'base_upstream' => array(
'type' => 'string',
'description' => 'Paired with base_stale_commits_behind: the origin ref the local base was compared against (e.g. `origin/main`).',
),
),
),
'execute_callback' => array( self::class, 'worktreeAdd' ),
Expand Down
66 changes: 66 additions & 0 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,7 @@ private function renderWorktreeResult( string $operation, array $result, array $
WP_CLI::warning( 'Worktree was created but bootstrap had failures. Re-run the failing step manually, or remove and retry.' );
}
}
$this->render_worktree_freshness( $result );
return;

case 'refresh-context':
Expand All @@ -1211,4 +1212,69 @@ private function renderWorktreeResult( string $operation, array $result, array $
return;
}
}

/**
* Render the freshness block for `worktree add` results.
*
* States, in priority order:
* - fetch_failed=true → `⚠ fetch failed — staleness unknown` (warning)
* - stale_commits_behind>0 → `⚠ <N> commits behind <upstream>` (warning + rebase hint)
* - base_stale_commits_behind>0 → `⚠ base was <N> commits behind <base_upstream>` (warning + rebase hint)
* - otherwise → `Freshness: up to date` (log)
*
* When no staleness signal is present at all (no fetch attempt recorded,
* no upstream configured, defaults used) the line is elided entirely —
* silence beats an ambiguous "up to date" we can't actually vouch for.
*
* @param array $result Ability result payload.
* @return void
*/
private function render_worktree_freshness( array $result ): void {
if ( ! empty( $result['fetch_failed'] ) ) {
$msg = 'Freshness: ⚠ fetch failed — staleness unknown';
if ( ! empty( $result['fetch_error'] ) ) {
$msg .= "\n " . $result['fetch_error'];
}
WP_CLI::warning( $msg );
return;
}

if ( isset( $result['stale_commits_behind'] ) ) {
$behind = (int) $result['stale_commits_behind'];
$upstream = isset( $result['upstream'] ) ? (string) $result['upstream'] : 'upstream';
if ( $behind > 0 ) {
WP_CLI::warning( sprintf(
"Freshness: ⚠ %d commits behind %s\n Rebase before opening a PR:\n git -C %s pull --rebase origin %s",
$behind,
$upstream,
$result['path'] ?? '<worktree>',
$result['branch'] ?? '<branch>'
) );
return;
}
WP_CLI::log( sprintf( 'Freshness: up to date (vs %s)', $upstream ) );
return;
}

if ( isset( $result['base_stale_commits_behind'] ) ) {
$behind = (int) $result['base_stale_commits_behind'];
$base_upstream = isset( $result['base_upstream'] ) ? (string) $result['base_upstream'] : 'origin';
if ( $behind > 0 ) {
WP_CLI::warning( sprintf(
"Freshness: ⚠ base was %d commits behind %s\n Rebase before opening a PR:\n git -C %s pull --rebase origin %s",
$behind,
$base_upstream,
$result['path'] ?? '<worktree>',
$result['branch'] ?? '<branch>'
) );
return;
}
WP_CLI::log( sprintf( 'Freshness: up to date (base %s)', $base_upstream ) );
return;
}

// No signal available (default base was origin/HEAD, or no upstream
// configured for the existing branch). Elide the line rather than
// print a potentially-misleading "up to date".
}
}
75 changes: 70 additions & 5 deletions inc/Workspace/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -876,7 +876,7 @@ public function git_diff( string $name, ?string $from = null, ?string $to = null
* @param string|null $from Base ref when creating the branch.
* @param bool $inject_context Whether to inject site-agent context (default true).
* @param bool $bootstrap Whether to run submodule/package/composer install after creation (default true).
* @return array{success: bool, handle: string, path: string, branch: string, slug: string, created_branch: bool, message: string, context_injected?: bool, context_files?: string[], context_skip_reason?: string, bootstrap?: array}|\WP_Error
* @return array{success: bool, handle: string, path: string, branch: string, slug: string, created_branch: bool, message: string, context_injected?: bool, context_files?: string[], context_skip_reason?: string, bootstrap?: array, fetch_failed?: bool, fetch_error?: string, stale_commits_behind?: int, upstream?: string, base_stale_commits_behind?: int, base_upstream?: string}|\WP_Error
*/
public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true ): array|\WP_Error {
$repo = $this->sanitize_name( $repo );
Expand Down Expand Up @@ -907,18 +907,25 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
return new \WP_Error( 'worktree_exists', sprintf( 'Worktree handle "%s" already exists.', $wt_handle ), array( 'status' => 400 ) );
}

// Always fetch first so staleness data (and the default base) reflects the
// current remote. Failure is logged but never aborts — offline work should
// still be possible, the agent just needs to know staleness is unknown.
$fetch = WorktreeStalenessProbe::fetch( $primary_path );
$fetch_failed = ! $fetch['ok'];
$fetch_error = $fetch['error'] ?? null;

// Does the branch already exist locally?
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
exec( sprintf( 'git -C %s show-ref --verify --quiet %s 2>&1', escapeshellarg( $primary_path ), escapeshellarg( 'refs/heads/' . $branch ) ), $_unused, $exists_local );
$created_branch = false;
$resolved_base = null;

if ( 0 === $exists_local ) {
$cmd = sprintf( 'worktree add %s %s', escapeshellarg( $wt_path ), escapeshellarg( $branch ) );
} else {
$base = $from && '' !== trim( $from ) ? trim( $from ) : $this->resolve_default_base( $primary_path );
// Fetch first to make sure remote refs are current.
$this->run_git( $primary_path, 'fetch --quiet origin' );
$cmd = sprintf( 'worktree add -b %s %s %s', escapeshellarg( $branch ), escapeshellarg( $wt_path ), escapeshellarg( $base ) );
$base = $from && '' !== trim( $from ) ? trim( $from ) : $this->resolve_default_base( $primary_path );
$resolved_base = $base;
$cmd = sprintf( 'worktree add -b %s %s %s', escapeshellarg( $branch ), escapeshellarg( $wt_path ), escapeshellarg( $base ) );
$created_branch = true;
}

Expand All @@ -937,6 +944,49 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
'message' => sprintf( 'Worktree "%s" added at %s (branch %s).', $wt_handle, $wt_path, $branch ),
);

if ( $fetch_failed ) {
$response['fetch_failed'] = true;
if ( null !== $fetch_error && '' !== $fetch_error ) {
$response['fetch_error'] = $fetch_error;
}
}

// Compute staleness. Only meaningful when fetch succeeded — otherwise the
// upstream refs are potentially stale themselves and any behind-count we
// produce would be misleading.
if ( ! $fetch_failed ) {
if ( ! $created_branch ) {
// Existing local branch: compare against its configured upstream.
$behind = WorktreeStalenessProbe::behind_count( $wt_path, $branch, '@{upstream}' );
if ( is_int( $behind ) ) {
$response['stale_commits_behind'] = $behind;
// Derive a human-readable upstream label. Best-effort; silently
// skipped when git's plumbing doesn't cooperate.
$upstream_name = $this->run_git(
$wt_path,
sprintf( 'rev-parse --abbrev-ref --symbolic-full-name %s', escapeshellarg( $branch . '@{upstream}' ) )
);
if ( ! is_wp_error( $upstream_name ) ) {
$label = trim( (string) ( $upstream_name['output'] ?? '' ) );
if ( '' !== $label ) {
$response['upstream'] = $label;
}
}
}
// null → no upstream configured; WP_Error → unexpected failure.
// Both cases: silently omit staleness fields.
} elseif ( null !== $resolved_base && ! $this->is_remote_tracking_ref( $resolved_base ) && 'HEAD' !== $resolved_base ) {
// New branch cut from a local ref: compare that ref to its origin
// counterpart so the agent sees when the base itself was stale.
$base_upstream = 'origin/' . $resolved_base;
$behind = WorktreeStalenessProbe::behind_count( $primary_path, $resolved_base, $base_upstream );
if ( is_int( $behind ) ) {
$response['base_stale_commits_behind'] = $behind;
$response['base_upstream'] = $base_upstream;
}
}
}

if ( ! $inject_context ) {
$response['context_injected'] = false;
$response['context_skip_reason'] = 'inject_context flag disabled';
Expand Down Expand Up @@ -1613,6 +1663,21 @@ private function resolve_default_base( string $repo_path ): string {
return 'HEAD';
}

/**
* Does a ref look like a remote-tracking ref?
*
* `resolve_default_base()` returns fully-qualified paths
* (`refs/remotes/origin/main`), but callers may pass short forms like
* `origin/main`. Both are "already at-tip post-fetch" and staleness
* comparisons against them would be nonsensical.
*
* @param string $ref Ref name to classify.
* @return bool
*/
private function is_remote_tracking_ref( string $ref ): bool {
return str_starts_with( $ref, 'refs/remotes/' ) || str_starts_with( $ref, 'origin/' );
}

/**
* Parse a `git worktree list --porcelain` block.
*
Expand Down
129 changes: 129 additions & 0 deletions inc/Workspace/WorktreeStalenessProbe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php
/**
* Worktree Staleness Probe
*
* Helper for `Workspace::worktree_add()` to always fetch remote refs first
* and compute how far behind upstream the new worktree's branch (or the
* local base it was cut from) is. The result is surfaced verbatim in the
* ability response and CLI output so agents can decide whether to rebase
* before cooking more changes on a stale base.
*
* Design choices:
*
* - `fetch()` never aborts the caller. Network failures, missing `origin`
* remote, or transient DNS hiccups are logged into the result so the
* agent knows staleness data is untrustworthy, but the worktree is still
* created.
* - `behind_count()` returns null when no upstream is configured (fresh
* local branch with no tracking). Callers MUST distinguish null from 0 —
* 0 means "up to date", null means "cannot tell".
* - All git invocations route through `GitRunner` so stderr handling and
* exit-code semantics stay consistent with the rest of the plugin.
*
* @package DataMachineCode\Workspace
* @since 0.7.x
*/

namespace DataMachineCode\Workspace;

use DataMachineCode\Support\GitRunner;

defined( 'ABSPATH' ) || exit;

final class WorktreeStalenessProbe {

/**
* 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}
*/
public static function fetch( string $repo_path ): array {
$result = GitRunner::run( $repo_path, 'fetch --quiet origin' );
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();
return array(
'ok' => false,
'error' => $error,
);
}
return array( 'ok' => true );
}

/**
* Count commits `$ref` is behind `$upstream` via `git rev-list --count`.
*
* Returns an integer ≥ 0 on success, null when `$upstream` is not
* configured / does not exist, and a WP_Error on any other git failure
* so the caller can surface it without conflating "no upstream" with
* "command errored".
*
* @param string $repo_path Repository path (worktree path or primary).
* @param string $ref Left-hand revision (e.g. current branch name).
* @param string $upstream Right-hand revision (e.g. `@{upstream}` or `origin/main`).
* @return int|null|\WP_Error
*/
public static function behind_count( string $repo_path, string $ref, string $upstream ): int|null|\WP_Error {
$args = sprintf( 'rev-list --count %s..%s', escapeshellarg( $ref ), escapeshellarg( $upstream ) );
$result = GitRunner::run( $repo_path, $args );
if ( is_wp_error( $result ) ) {
$data = $result->get_error_data();
$out = is_array( $data ) && isset( $data['output'] ) ? (string) $data['output'] : '';

// Missing upstream configuration. Treated as "unknown", not an error.
if ( self::is_missing_upstream( $out ) ) {
return null;
}
return $result;
}

$count = self::parse_count( (string) ( $result['output'] ?? '' ) );
return $count;
}

/**
* Parse a `rev-list --count` stdout payload into an int. Tolerant of
* trailing whitespace / empty output (returns 0 for empty, matching git).
*
* @param string $output Raw stdout.
* @return int
*/
public static function parse_count( string $output ): int {
$trimmed = trim( $output );
if ( '' === $trimmed ) {
return 0;
}
if ( ! preg_match( '/^\d+$/', $trimmed ) ) {
return 0;
}
return (int) $trimmed;
}

/**
* Heuristic: does a git error blob signal "no upstream configured" rather
* than a real failure? Matches the common phrasings git uses across
* versions.
*
* @param string $output Git stderr/stdout.
* @return bool
*/
public static function is_missing_upstream( string $output ): bool {
$needles = array(
'no upstream configured',
'unknown revision',
'bad revision',
'ambiguous argument',
'No such ref',
'Needed a single revision',
);
foreach ( $needles as $needle ) {
if ( false !== stripos( $output, $needle ) ) {
return true;
}
}
return false;
}
}
Loading