Skip to content

Commit 6a5ba52

Browse files
authored
feat(workspace): surface worktree staleness at create-time (#53)
refs #52 `worktree add` now unconditionally fetches `origin` before creating the new checkout and then compares the materialized branch (or the local base it was cut from) against its origin counterpart, surfacing the behind-count in the ability response and CLI output. Agents can decide whether to rebase before cooking instead of discovering day-one merge conflicts at PR-review time. Non-breaking: no gating, no auto-rebase, all new response fields are optional, CLI warns but never fails. The `--allow-stale` + `--rebase-base` behavior change lives in a follow-up PR (option 2 in #52). Changes: - `WorktreeStalenessProbe` helper — fetch + behind-count + output parsing, returns structured results instead of throwing (offline work still possible) - `Workspace::worktree_add()` — unconditional fetch at the top, post-creation staleness computation for both the existing-local-branch path and the new-branch-off-local-base path - `is_remote_tracking_ref()` helper — recognizes `refs/remotes/origin/*` (what `resolve_default_base()` returns) AND `origin/*` short form as "already at-tip post-fetch" - `WorkspaceAbilities` — output schema gains six optional fields: `fetch_failed`, `fetch_error`, `stale_commits_behind`, `upstream`, `base_stale_commits_behind`, `base_upstream` - `WorkspaceCommand::render_worktree_freshness()` — renders a `Freshness:` block after bootstrap output. Elides the line entirely when no signal is available rather than print a misleading "up to date" - `tests/smoke-worktree-staleness.php` — 23 assertions: parse_count, is_missing_upstream heuristics, real-git fixtures for fetch success, fetch failure (no origin), behind-count parsing, no-upstream → null, at-tip → 0
1 parent 844902e commit 6a5ba52

5 files changed

Lines changed: 493 additions & 5 deletions

File tree

inc/Abilities/WorkspaceAbilities.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,30 @@ private function registerAbilities(): void {
755755
'type' => 'object',
756756
'description' => 'Present only when bootstrap=true. Contains success/ran_any booleans and a steps array.',
757757
),
758+
'fetch_failed' => array(
759+
'type' => 'boolean',
760+
'description' => 'Present only when the pre-create `git fetch origin` failed. Worktree creation continues either way; staleness fields are omitted when true.',
761+
),
762+
'fetch_error' => array(
763+
'type' => 'string',
764+
'description' => 'Present only when fetch_failed=true. Trimmed error output from the failing fetch.',
765+
),
766+
'stale_commits_behind' => array(
767+
'type' => 'integer',
768+
'description' => 'For the existing-local-branch path, how many commits the worktree branch is behind its configured upstream. Omitted when no upstream is configured.',
769+
),
770+
'upstream' => array(
771+
'type' => 'string',
772+
'description' => 'Paired with stale_commits_behind: the upstream ref label (e.g. `origin/fix/foo`).',
773+
),
774+
'base_stale_commits_behind' => array(
775+
'type' => 'integer',
776+
'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.',
777+
),
778+
'base_upstream' => array(
779+
'type' => 'string',
780+
'description' => 'Paired with base_stale_commits_behind: the origin ref the local base was compared against (e.g. `origin/main`).',
781+
),
758782
),
759783
),
760784
'execute_callback' => array( self::class, 'worktreeAdd' ),

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,7 @@ private function renderWorktreeResult( string $operation, array $result, array $
11881188
WP_CLI::warning( 'Worktree was created but bootstrap had failures. Re-run the failing step manually, or remove and retry.' );
11891189
}
11901190
}
1191+
$this->render_worktree_freshness( $result );
11911192
return;
11921193

11931194
case 'refresh-context':
@@ -1211,4 +1212,69 @@ private function renderWorktreeResult( string $operation, array $result, array $
12111212
return;
12121213
}
12131214
}
1215+
1216+
/**
1217+
* Render the freshness block for `worktree add` results.
1218+
*
1219+
* States, in priority order:
1220+
* - fetch_failed=true → `⚠ fetch failed — staleness unknown` (warning)
1221+
* - stale_commits_behind>0 → `⚠ <N> commits behind <upstream>` (warning + rebase hint)
1222+
* - base_stale_commits_behind>0 → `⚠ base was <N> commits behind <base_upstream>` (warning + rebase hint)
1223+
* - otherwise → `Freshness: up to date` (log)
1224+
*
1225+
* When no staleness signal is present at all (no fetch attempt recorded,
1226+
* no upstream configured, defaults used) the line is elided entirely —
1227+
* silence beats an ambiguous "up to date" we can't actually vouch for.
1228+
*
1229+
* @param array $result Ability result payload.
1230+
* @return void
1231+
*/
1232+
private function render_worktree_freshness( array $result ): void {
1233+
if ( ! empty( $result['fetch_failed'] ) ) {
1234+
$msg = 'Freshness: ⚠ fetch failed — staleness unknown';
1235+
if ( ! empty( $result['fetch_error'] ) ) {
1236+
$msg .= "\n " . $result['fetch_error'];
1237+
}
1238+
WP_CLI::warning( $msg );
1239+
return;
1240+
}
1241+
1242+
if ( isset( $result['stale_commits_behind'] ) ) {
1243+
$behind = (int) $result['stale_commits_behind'];
1244+
$upstream = isset( $result['upstream'] ) ? (string) $result['upstream'] : 'upstream';
1245+
if ( $behind > 0 ) {
1246+
WP_CLI::warning( sprintf(
1247+
"Freshness: ⚠ %d commits behind %s\n Rebase before opening a PR:\n git -C %s pull --rebase origin %s",
1248+
$behind,
1249+
$upstream,
1250+
$result['path'] ?? '<worktree>',
1251+
$result['branch'] ?? '<branch>'
1252+
) );
1253+
return;
1254+
}
1255+
WP_CLI::log( sprintf( 'Freshness: up to date (vs %s)', $upstream ) );
1256+
return;
1257+
}
1258+
1259+
if ( isset( $result['base_stale_commits_behind'] ) ) {
1260+
$behind = (int) $result['base_stale_commits_behind'];
1261+
$base_upstream = isset( $result['base_upstream'] ) ? (string) $result['base_upstream'] : 'origin';
1262+
if ( $behind > 0 ) {
1263+
WP_CLI::warning( sprintf(
1264+
"Freshness: ⚠ base was %d commits behind %s\n Rebase before opening a PR:\n git -C %s pull --rebase origin %s",
1265+
$behind,
1266+
$base_upstream,
1267+
$result['path'] ?? '<worktree>',
1268+
$result['branch'] ?? '<branch>'
1269+
) );
1270+
return;
1271+
}
1272+
WP_CLI::log( sprintf( 'Freshness: up to date (base %s)', $base_upstream ) );
1273+
return;
1274+
}
1275+
1276+
// No signal available (default base was origin/HEAD, or no upstream
1277+
// configured for the existing branch). Elide the line rather than
1278+
// print a potentially-misleading "up to date".
1279+
}
12141280
}

inc/Workspace/Workspace.php

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -876,7 +876,7 @@ public function git_diff( string $name, ?string $from = null, ?string $to = null
876876
* @param string|null $from Base ref when creating the branch.
877877
* @param bool $inject_context Whether to inject site-agent context (default true).
878878
* @param bool $bootstrap Whether to run submodule/package/composer install after creation (default true).
879-
* @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
879+
* @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
880880
*/
881881
public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true ): array|\WP_Error {
882882
$repo = $this->sanitize_name( $repo );
@@ -907,18 +907,25 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
907907
return new \WP_Error( 'worktree_exists', sprintf( 'Worktree handle "%s" already exists.', $wt_handle ), array( 'status' => 400 ) );
908908
}
909909

910+
// Always fetch first so staleness data (and the default base) reflects the
911+
// current remote. Failure is logged but never aborts — offline work should
912+
// still be possible, the agent just needs to know staleness is unknown.
913+
$fetch = WorktreeStalenessProbe::fetch( $primary_path );
914+
$fetch_failed = ! $fetch['ok'];
915+
$fetch_error = $fetch['error'] ?? null;
916+
910917
// Does the branch already exist locally?
911918
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
912919
exec( sprintf( 'git -C %s show-ref --verify --quiet %s 2>&1', escapeshellarg( $primary_path ), escapeshellarg( 'refs/heads/' . $branch ) ), $_unused, $exists_local );
913920
$created_branch = false;
921+
$resolved_base = null;
914922

915923
if ( 0 === $exists_local ) {
916924
$cmd = sprintf( 'worktree add %s %s', escapeshellarg( $wt_path ), escapeshellarg( $branch ) );
917925
} else {
918-
$base = $from && '' !== trim( $from ) ? trim( $from ) : $this->resolve_default_base( $primary_path );
919-
// Fetch first to make sure remote refs are current.
920-
$this->run_git( $primary_path, 'fetch --quiet origin' );
921-
$cmd = sprintf( 'worktree add -b %s %s %s', escapeshellarg( $branch ), escapeshellarg( $wt_path ), escapeshellarg( $base ) );
926+
$base = $from && '' !== trim( $from ) ? trim( $from ) : $this->resolve_default_base( $primary_path );
927+
$resolved_base = $base;
928+
$cmd = sprintf( 'worktree add -b %s %s %s', escapeshellarg( $branch ), escapeshellarg( $wt_path ), escapeshellarg( $base ) );
922929
$created_branch = true;
923930
}
924931

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

947+
if ( $fetch_failed ) {
948+
$response['fetch_failed'] = true;
949+
if ( null !== $fetch_error && '' !== $fetch_error ) {
950+
$response['fetch_error'] = $fetch_error;
951+
}
952+
}
953+
954+
// Compute staleness. Only meaningful when fetch succeeded — otherwise the
955+
// upstream refs are potentially stale themselves and any behind-count we
956+
// produce would be misleading.
957+
if ( ! $fetch_failed ) {
958+
if ( ! $created_branch ) {
959+
// Existing local branch: compare against its configured upstream.
960+
$behind = WorktreeStalenessProbe::behind_count( $wt_path, $branch, '@{upstream}' );
961+
if ( is_int( $behind ) ) {
962+
$response['stale_commits_behind'] = $behind;
963+
// Derive a human-readable upstream label. Best-effort; silently
964+
// skipped when git's plumbing doesn't cooperate.
965+
$upstream_name = $this->run_git(
966+
$wt_path,
967+
sprintf( 'rev-parse --abbrev-ref --symbolic-full-name %s', escapeshellarg( $branch . '@{upstream}' ) )
968+
);
969+
if ( ! is_wp_error( $upstream_name ) ) {
970+
$label = trim( (string) ( $upstream_name['output'] ?? '' ) );
971+
if ( '' !== $label ) {
972+
$response['upstream'] = $label;
973+
}
974+
}
975+
}
976+
// null → no upstream configured; WP_Error → unexpected failure.
977+
// Both cases: silently omit staleness fields.
978+
} elseif ( null !== $resolved_base && ! $this->is_remote_tracking_ref( $resolved_base ) && 'HEAD' !== $resolved_base ) {
979+
// New branch cut from a local ref: compare that ref to its origin
980+
// counterpart so the agent sees when the base itself was stale.
981+
$base_upstream = 'origin/' . $resolved_base;
982+
$behind = WorktreeStalenessProbe::behind_count( $primary_path, $resolved_base, $base_upstream );
983+
if ( is_int( $behind ) ) {
984+
$response['base_stale_commits_behind'] = $behind;
985+
$response['base_upstream'] = $base_upstream;
986+
}
987+
}
988+
}
989+
940990
if ( ! $inject_context ) {
941991
$response['context_injected'] = false;
942992
$response['context_skip_reason'] = 'inject_context flag disabled';
@@ -1613,6 +1663,21 @@ private function resolve_default_base( string $repo_path ): string {
16131663
return 'HEAD';
16141664
}
16151665

1666+
/**
1667+
* Does a ref look like a remote-tracking ref?
1668+
*
1669+
* `resolve_default_base()` returns fully-qualified paths
1670+
* (`refs/remotes/origin/main`), but callers may pass short forms like
1671+
* `origin/main`. Both are "already at-tip post-fetch" and staleness
1672+
* comparisons against them would be nonsensical.
1673+
*
1674+
* @param string $ref Ref name to classify.
1675+
* @return bool
1676+
*/
1677+
private function is_remote_tracking_ref( string $ref ): bool {
1678+
return str_starts_with( $ref, 'refs/remotes/' ) || str_starts_with( $ref, 'origin/' );
1679+
}
1680+
16161681
/**
16171682
* Parse a `git worktree list --porcelain` block.
16181683
*
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
/**
3+
* Worktree Staleness Probe
4+
*
5+
* Helper for `Workspace::worktree_add()` to always fetch remote refs first
6+
* and compute how far behind upstream the new worktree's branch (or the
7+
* local base it was cut from) is. The result is surfaced verbatim in the
8+
* ability response and CLI output so agents can decide whether to rebase
9+
* before cooking more changes on a stale base.
10+
*
11+
* Design choices:
12+
*
13+
* - `fetch()` never aborts the caller. Network failures, missing `origin`
14+
* remote, or transient DNS hiccups are logged into the result so the
15+
* agent knows staleness data is untrustworthy, but the worktree is still
16+
* created.
17+
* - `behind_count()` returns null when no upstream is configured (fresh
18+
* local branch with no tracking). Callers MUST distinguish null from 0 —
19+
* 0 means "up to date", null means "cannot tell".
20+
* - All git invocations route through `GitRunner` so stderr handling and
21+
* exit-code semantics stay consistent with the rest of the plugin.
22+
*
23+
* @package DataMachineCode\Workspace
24+
* @since 0.7.x
25+
*/
26+
27+
namespace DataMachineCode\Workspace;
28+
29+
use DataMachineCode\Support\GitRunner;
30+
31+
defined( 'ABSPATH' ) || exit;
32+
33+
final class WorktreeStalenessProbe {
34+
35+
/**
36+
* Fetch `origin` in a repository. Returns structured result instead of
37+
* throwing so the caller can decide whether to continue on failure.
38+
*
39+
* @param string $repo_path Primary repo path (passed to `git -C`).
40+
* @return array{ok: bool, error?: string}
41+
*/
42+
public static function fetch( string $repo_path ): array {
43+
$result = GitRunner::run( $repo_path, 'fetch --quiet origin' );
44+
if ( is_wp_error( $result ) ) {
45+
$data = $result->get_error_data();
46+
$tail = is_array( $data ) && isset( $data['output'] ) ? trim( (string) $data['output'] ) : '';
47+
$error = '' !== $tail ? $tail : $result->get_error_message();
48+
return array(
49+
'ok' => false,
50+
'error' => $error,
51+
);
52+
}
53+
return array( 'ok' => true );
54+
}
55+
56+
/**
57+
* Count commits `$ref` is behind `$upstream` via `git rev-list --count`.
58+
*
59+
* Returns an integer ≥ 0 on success, null when `$upstream` is not
60+
* configured / does not exist, and a WP_Error on any other git failure
61+
* so the caller can surface it without conflating "no upstream" with
62+
* "command errored".
63+
*
64+
* @param string $repo_path Repository path (worktree path or primary).
65+
* @param string $ref Left-hand revision (e.g. current branch name).
66+
* @param string $upstream Right-hand revision (e.g. `@{upstream}` or `origin/main`).
67+
* @return int|null|\WP_Error
68+
*/
69+
public static function behind_count( string $repo_path, string $ref, string $upstream ): int|null|\WP_Error {
70+
$args = sprintf( 'rev-list --count %s..%s', escapeshellarg( $ref ), escapeshellarg( $upstream ) );
71+
$result = GitRunner::run( $repo_path, $args );
72+
if ( is_wp_error( $result ) ) {
73+
$data = $result->get_error_data();
74+
$out = is_array( $data ) && isset( $data['output'] ) ? (string) $data['output'] : '';
75+
76+
// Missing upstream configuration. Treated as "unknown", not an error.
77+
if ( self::is_missing_upstream( $out ) ) {
78+
return null;
79+
}
80+
return $result;
81+
}
82+
83+
$count = self::parse_count( (string) ( $result['output'] ?? '' ) );
84+
return $count;
85+
}
86+
87+
/**
88+
* Parse a `rev-list --count` stdout payload into an int. Tolerant of
89+
* trailing whitespace / empty output (returns 0 for empty, matching git).
90+
*
91+
* @param string $output Raw stdout.
92+
* @return int
93+
*/
94+
public static function parse_count( string $output ): int {
95+
$trimmed = trim( $output );
96+
if ( '' === $trimmed ) {
97+
return 0;
98+
}
99+
if ( ! preg_match( '/^\d+$/', $trimmed ) ) {
100+
return 0;
101+
}
102+
return (int) $trimmed;
103+
}
104+
105+
/**
106+
* Heuristic: does a git error blob signal "no upstream configured" rather
107+
* than a real failure? Matches the common phrasings git uses across
108+
* versions.
109+
*
110+
* @param string $output Git stderr/stdout.
111+
* @return bool
112+
*/
113+
public static function is_missing_upstream( string $output ): bool {
114+
$needles = array(
115+
'no upstream configured',
116+
'unknown revision',
117+
'bad revision',
118+
'ambiguous argument',
119+
'No such ref',
120+
'Needed a single revision',
121+
);
122+
foreach ( $needles as $needle ) {
123+
if ( false !== stripos( $output, $needle ) ) {
124+
return true;
125+
}
126+
}
127+
return false;
128+
}
129+
}

0 commit comments

Comments
 (0)