Skip to content

Commit c0cc6dd

Browse files
authored
feat(workspace): gate stale worktree creation + opt-in rebase (#54)
Closes #52 Builds on #53's non-breaking staleness signal. `worktree add` now refuses to materialize a worktree that would be more than `datamachine_worktree_stale_threshold` commits (default 50) behind its upstream, tearing the half-cooked checkout down and returning a `worktree_stale` WP_Error with remediation options. Pass `--allow-stale` to opt in, or `--rebase-base` to auto-rebase onto the upstream tip before returning. On rebase conflicts the rebase is aborted — `--rebase-base` is not a silent bypass. ## Behavior change Previously: `worktree add` happily cooked on top of any local branch, no matter how stale. Agents discovered multi-hundred-commit drift at PR-review time when GitHub flagged conflicts. Now: by default, a worktree that would land >50 commits behind upstream is rolled back at create-time with a WP_Error pointing at four concrete remediation paths: Worktree base is 51 commits behind origin/main (threshold: 50). Options: - workspace git-pull data-machine-code --allow-primary-mutation - worktree add … --from=origin/main - worktree add … --rebase-base - worktree add … --allow-stale Threshold is filterable per-site/per-repo via `datamachine_worktree_stale_threshold`. ## Rebase semantics `--rebase-base` picks the right target per path: - Existing-local-branch: rebase onto `@{upstream}` - New-branch-off-local-base: rebase onto `origin/<base>` Success clears the behind-count (worktree passes the gate cleanly). Failure aborts the rebase, the worktree stays at its pre-rebase HEAD, and `rebase_succeeded: false` + `rebase_error: <tail>` are surfaced. Critically, the staleness gate STILL fires after a failed rebase — `--rebase-base` alone on a conflicting rebase isn't a silent `--allow-stale` bypass. ## Changes - `Workspace::worktree_add()` — new `$allow_stale` + `$rebase_base` params. Rebase runs before the gate (success nullifies gate). Gate runs after staleness probe, computes filterable threshold, tears the worktree down + returns WP_Error on violation. - `effective_behind_count()` helper — picks the one behind-count that matters for gating (existing-branch or new-branch-off-local-base, whichever is present). - `try_rebase_worktree()` helper — selects upstream target, runs rebase, aborts on failure, zeroes the relevant behind-count on success. - `WorkspaceAbilities` — input schema gains `allow_stale` + `rebase_base` (both default false). Output schema gains `gate_threshold`, `rebase_attempted`, `rebase_target`, `rebase_succeeded`, `rebase_error`. - `WorkspaceCommand` — new `--allow-stale` + `--rebase-base` flags with full help blocks + examples. `render_worktree_freshness()` gains a rebase block that renders BEFORE staleness (success: "rebased onto <target>", failure: "⚠ rebase onto <target> failed" + error tail). - `tests/smoke-worktree-staleness.php` — 7 new real-git-fixture assertions for rebase success (branch 2 behind → 0 behind after rebase, consumer commit preserved) and rebase conflict handling (non-zero exit, abort restores pre-rebase SHA, behind-count preserved).
1 parent 6a5ba52 commit c0cc6dd

4 files changed

Lines changed: 369 additions & 5 deletions

File tree

inc/Abilities/WorkspaceAbilities.php

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,14 @@ private function registerAbilities(): void {
731731
'type' => 'boolean',
732732
'description' => 'Run detected bootstrap steps (submodule init, package-manager install, composer install) after creating the worktree. Default true. Steps are skipped gracefully when their trigger file or tool is missing. Set false for a bare checkout (e.g. when only reading code).',
733733
),
734+
'allow_stale' => array(
735+
'type' => 'boolean',
736+
'description' => 'Bypass the staleness gate. When false (default) and the new worktree would be more than `datamachine_worktree_stale_threshold` commits behind upstream, worktree creation is rolled back and a `worktree_stale` error is returned. Set true to opt in to a known-stale checkout.',
737+
),
738+
'rebase_base' => array(
739+
'type' => 'boolean',
740+
'description' => 'After creating the worktree, rebase onto the upstream tip (the branch\'s @{upstream} for existing branches, origin/<base> for new branches off a local base). Default false. On rebase conflicts the rebase is aborted; the worktree stays at its pre-rebase state and `rebase_succeeded: false` is surfaced.',
741+
),
734742
),
735743
'required' => array( 'repo', 'branch' ),
736744
),
@@ -779,6 +787,26 @@ private function registerAbilities(): void {
779787
'type' => 'string',
780788
'description' => 'Paired with base_stale_commits_behind: the origin ref the local base was compared against (e.g. `origin/main`).',
781789
),
790+
'gate_threshold' => array(
791+
'type' => 'integer',
792+
'description' => 'Echo of the staleness threshold (in commits) that was evaluated. Present whenever the gate ran (i.e. `allow_stale` was false and fetch succeeded).',
793+
),
794+
'rebase_attempted' => array(
795+
'type' => 'boolean',
796+
'description' => 'Set when `rebase_base=true` AND there was meaningful staleness to rebase over. Absent when rebase was not requested or there was nothing to do.',
797+
),
798+
'rebase_target' => array(
799+
'type' => 'string',
800+
'description' => 'Paired with `rebase_attempted`: the ref the worktree was rebased onto (e.g. `@{upstream}` or `origin/main`).',
801+
),
802+
'rebase_succeeded' => array(
803+
'type' => 'boolean',
804+
'description' => 'Paired with `rebase_attempted`: true when the rebase landed cleanly, false when it hit conflicts and was aborted.',
805+
),
806+
'rebase_error' => array(
807+
'type' => 'string',
808+
'description' => 'Present only when `rebase_succeeded=false`. Trimmed error output from the failing rebase.',
809+
),
782810
),
783811
),
784812
'execute_callback' => array( self::class, 'worktreeAdd' ),
@@ -1215,12 +1243,18 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
12151243
$inject_context = array_key_exists( 'inject_context', $input ) ? (bool) $input['inject_context'] : true;
12161244
// Default bootstrap=true; only false when explicitly provided.
12171245
$bootstrap = array_key_exists( 'bootstrap', $input ) ? (bool) $input['bootstrap'] : true;
1246+
// Default allow_stale=false (gate enforced); only true when explicitly opted in.
1247+
$allow_stale = array_key_exists( 'allow_stale', $input ) ? (bool) $input['allow_stale'] : false;
1248+
// Default rebase_base=false; only true when explicitly requested.
1249+
$rebase_base = array_key_exists( 'rebase_base', $input ) ? (bool) $input['rebase_base'] : false;
12181250
return $workspace->worktree_add(
12191251
$input['repo'] ?? '',
12201252
$input['branch'] ?? '',
12211253
$input['from'] ?? null,
12221254
$inject_context,
1223-
$bootstrap
1255+
$bootstrap,
1256+
$allow_stale,
1257+
$rebase_base
12241258
);
12251259
}
12261260

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,24 @@ private function renderGitOperationResult( string $operation, array $result, arr
920920
* `bootstrap=false`; this flag is the CLI shorthand (matches the
921921
* existing `--skip-context-injection` convention).
922922
*
923+
* [--allow-stale]
924+
* : Bypass the staleness gate (applies to `add` only). By default,
925+
* `worktree add` refuses to return a worktree that would be more than
926+
* `datamachine_worktree_stale_threshold` commits (default 50) behind
927+
* upstream — the stale checkout is torn down and a `worktree_stale`
928+
* error is returned with remediation options. Pass `--allow-stale` to
929+
* opt in to a known-stale checkout. The ability-level input is
930+
* `allow_stale=true`.
931+
*
932+
* [--rebase-base]
933+
* : After creating the worktree, rebase onto the upstream tip (applies
934+
* to `add` only). For existing branches this is `@{upstream}`; for
935+
* new branches cut off a local base this is `origin/<base>`. On
936+
* rebase conflicts the rebase is aborted and the worktree stays at
937+
* its pre-rebase state — `--rebase-base` is not a silent
938+
* `--allow-stale` bypass. The ability-level input is
939+
* `rebase_base=true`.
940+
*
923941
* [--force]
924942
* : Force-remove a worktree even if it is dirty (applies to `remove` and
925943
* `cleanup`). Does NOT override the unpushed-commits safety in cleanup.
@@ -976,6 +994,12 @@ private function renderGitOperationResult( string $operation, array $result, arr
976994
* # Create a bare worktree (skip the default bootstrap pass)
977995
* wp datamachine workspace worktree add data-machine fix/foo --skip-bootstrap
978996
*
997+
* # Proceed with a known-stale base (bypass the staleness gate)
998+
* wp datamachine workspace worktree add data-machine fix/foo --allow-stale
999+
*
1000+
* # Auto-rebase onto upstream after creation
1001+
* wp datamachine workspace worktree add data-machine fix/foo --rebase-base
1002+
*
9791003
* # Re-read the originating site's agent memory into an existing worktree
9801004
* wp datamachine workspace worktree refresh-context data-machine@fix-foo
9811005
*
@@ -1015,7 +1039,7 @@ public function worktree( array $args, array $assoc_args ): void {
10151039
switch ( $operation ) {
10161040
case 'add':
10171041
if ( empty( $args[1] ) || empty( $args[2] ) ) {
1018-
WP_CLI::error( 'Usage: worktree add <repo> <branch> [--from=<ref>] [--skip-context-injection] [--skip-bootstrap]' );
1042+
WP_CLI::error( 'Usage: worktree add <repo> <branch> [--from=<ref>] [--skip-context-injection] [--skip-bootstrap] [--allow-stale] [--rebase-base]' );
10191043
return;
10201044
}
10211045
$input['repo'] = $args[1];
@@ -1027,6 +1051,10 @@ public function worktree( array $args, array $assoc_args ): void {
10271051
$input['inject_context'] = empty( $assoc_args['skip-context-injection'] );
10281052
// --skip-bootstrap disables the default-on bootstrap step.
10291053
$input['bootstrap'] = empty( $assoc_args['skip-bootstrap'] );
1054+
// --allow-stale opts in to a known-stale worktree (default: gate enforced).
1055+
$input['allow_stale'] = ! empty( $assoc_args['allow-stale'] );
1056+
// --rebase-base auto-rebases onto upstream after creation (default: off).
1057+
$input['rebase_base'] = ! empty( $assoc_args['rebase-base'] );
10301058
break;
10311059

10321060
case 'refresh-context':
@@ -1218,6 +1246,7 @@ private function renderWorktreeResult( string $operation, array $result, array $
12181246
*
12191247
* States, in priority order:
12201248
* - fetch_failed=true → `⚠ fetch failed — staleness unknown` (warning)
1249+
* - rebase_attempted=true → success or conflict status (log or warning)
12211250
* - stale_commits_behind>0 → `⚠ <N> commits behind <upstream>` (warning + rebase hint)
12221251
* - base_stale_commits_behind>0 → `⚠ base was <N> commits behind <base_upstream>` (warning + rebase hint)
12231252
* - otherwise → `Freshness: up to date` (log)
@@ -1239,6 +1268,24 @@ private function render_worktree_freshness( array $result ): void {
12391268
return;
12401269
}
12411270

1271+
if ( ! empty( $result['rebase_attempted'] ) ) {
1272+
$target = isset( $result['rebase_target'] ) ? (string) $result['rebase_target'] : 'upstream';
1273+
if ( ! empty( $result['rebase_succeeded'] ) ) {
1274+
WP_CLI::log( sprintf( 'Freshness: rebased onto %s', $target ) );
1275+
// Fall through in case either behind-count is still set (e.g. the
1276+
// "other" path's metadata is present but zeroed). Renderer below
1277+
// handles the 0-case correctly.
1278+
} else {
1279+
$msg = sprintf( 'Freshness: ⚠ rebase onto %s failed — worktree stayed at pre-rebase HEAD', $target );
1280+
if ( ! empty( $result['rebase_error'] ) ) {
1281+
$msg .= "\n " . $result['rebase_error'];
1282+
}
1283+
WP_CLI::warning( $msg );
1284+
// Staleness block below will still fire with the pre-rebase
1285+
// behind-count so the agent sees exactly how stale it is.
1286+
}
1287+
}
1288+
12421289
if ( isset( $result['stale_commits_behind'] ) ) {
12431290
$behind = (int) $result['stale_commits_behind'];
12441291
$upstream = isset( $result['upstream'] ) ? (string) $result['upstream'] : 'upstream';

inc/Workspace/Workspace.php

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -871,14 +871,26 @@ public function git_diff( string $name, ?string $from = null, ?string $to = null
871871
* Pass `$bootstrap = false` (or `--no-bootstrap` on the CLI) for a bare
872872
* checkout when you only need to read code on that branch.
873873
*
874+
* When the materialized branch (or its local base) is more than
875+
* `datamachine_worktree_stale_threshold` commits behind upstream and
876+
* neither `$allow_stale` nor `$rebase_base` is set, the worktree is
877+
* torn down and the call returns a `worktree_stale` WP_Error with
878+
* remediation guidance. Pass `$allow_stale = true` to proceed anyway,
879+
* or `$rebase_base = true` to auto-rebase onto the upstream tip before
880+
* returning. On rebase conflicts the rebase is aborted (worktree stays
881+
* at its pre-rebase state) and `rebase_failed: true` is surfaced in
882+
* the response so the agent can resolve manually.
883+
*
874884
* @param string $repo Primary repo name (no @-suffix).
875885
* @param string $branch Branch to check out (e.g. "fix/foo-bar").
876886
* @param string|null $from Base ref when creating the branch.
877887
* @param bool $inject_context Whether to inject site-agent context (default true).
878888
* @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, fetch_failed?: bool, fetch_error?: string, stale_commits_behind?: int, upstream?: string, base_stale_commits_behind?: int, base_upstream?: string}|\WP_Error
889+
* @param bool $allow_stale Bypass the staleness gate (default false).
890+
* @param bool $rebase_base Rebase onto upstream after creation (default false).
891+
* @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, gate_threshold?: int, rebase_attempted?: bool, rebase_succeeded?: bool, rebase_error?: string, rebase_target?: string}|\WP_Error
880892
*/
881-
public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true ): array|\WP_Error {
893+
public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true, bool $bootstrap = true, bool $allow_stale = false, bool $rebase_base = false ): array|\WP_Error {
882894
$repo = $this->sanitize_name( $repo );
883895
$branch = trim( $branch );
884896

@@ -987,6 +999,69 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
987999
}
9881000
}
9891001

1002+
// Rebase BEFORE gating: if the agent explicitly asked to rebase, try
1003+
// that first. Success cancels the gate trigger entirely. Failure leaves
1004+
// the worktree at its pre-rebase state AND still trips the gate, so
1005+
// --rebase-base alone on a conflicting rebase isn't a silent bypass.
1006+
if ( $rebase_base && ! $fetch_failed ) {
1007+
$rebase_result = $this->try_rebase_worktree( $wt_path, $response, $created_branch );
1008+
if ( null !== $rebase_result ) {
1009+
$response = array_merge( $response, $rebase_result );
1010+
}
1011+
}
1012+
1013+
// Staleness gate. Threshold filterable per-site / per-repo. Only fires
1014+
// when fetch succeeded (otherwise behind-counts are unreliable) and
1015+
// rebase didn't already zero out the staleness.
1016+
if ( ! $allow_stale && ! $fetch_failed ) {
1017+
/**
1018+
* Filters the staleness threshold above which `worktree_add` refuses
1019+
* to return a stale worktree without explicit `--allow-stale` opt-in.
1020+
*
1021+
* @param int $threshold Default 50 commits behind upstream.
1022+
* @param string $repo Repository name.
1023+
* @param string $branch Branch being materialized.
1024+
*/
1025+
$threshold = (int) apply_filters( 'datamachine_worktree_stale_threshold', 50, $repo, $branch );
1026+
$response['gate_threshold'] = $threshold;
1027+
$effective_behind = $this->effective_behind_count( $response );
1028+
1029+
if ( null !== $effective_behind && $effective_behind > $threshold ) {
1030+
// Tear the worktree down so we don't leak a half-cooked
1031+
// checkout on the user's disk.
1032+
$this->run_git( $primary_path, sprintf( 'worktree remove --force %s', escapeshellarg( $wt_path ) ) );
1033+
1034+
$label = $response['upstream'] ?? ( $response['base_upstream'] ?? 'upstream' );
1035+
$guidance = sprintf(
1036+
"Worktree base is %d commits behind %s (threshold: %d).\n"
1037+
. "Options:\n"
1038+
. " - workspace git-pull %s --allow-primary-mutation (refresh primary first)\n"
1039+
. " - worktree add … --from=origin/%s (cut from remote ref directly)\n"
1040+
. " - worktree add … --rebase-base (auto-rebase onto upstream)\n"
1041+
. " - worktree add … --allow-stale (proceed with known-stale base)",
1042+
$effective_behind,
1043+
$label,
1044+
$threshold,
1045+
$repo,
1046+
ltrim( (string) ( $response['upstream'] ?? $resolved_base ?? 'main' ), 'origin/' )
1047+
);
1048+
1049+
return new \WP_Error(
1050+
'worktree_stale',
1051+
$guidance,
1052+
array(
1053+
'status' => 409,
1054+
'stale_commits_behind' => $response['stale_commits_behind'] ?? null,
1055+
'base_stale_commits_behind' => $response['base_stale_commits_behind'] ?? null,
1056+
'upstream' => $response['upstream'] ?? null,
1057+
'base_upstream' => $response['base_upstream'] ?? null,
1058+
'gate_threshold' => $threshold,
1059+
'fetch_failed' => false,
1060+
)
1061+
);
1062+
}
1063+
}
1064+
9901065
if ( ! $inject_context ) {
9911066
$response['context_injected'] = false;
9921067
$response['context_skip_reason'] = 'inject_context flag disabled';
@@ -1678,6 +1753,107 @@ private function is_remote_tracking_ref( string $ref ): bool {
16781753
return str_starts_with( $ref, 'refs/remotes/' ) || str_starts_with( $ref, 'origin/' );
16791754
}
16801755

1756+
/**
1757+
* Pull the single behind-count that matters for gate decisions.
1758+
*
1759+
* The staleness probe records up to two behind-counts depending on
1760+
* the path: `stale_commits_behind` for an existing branch vs its
1761+
* upstream, or `base_stale_commits_behind` for a new branch cut off a
1762+
* stale local base. At most one of these is present in practice;
1763+
* whichever exists is the one we gate on.
1764+
*
1765+
* @param array $response Accumulated response payload.
1766+
* @return int|null Behind-count, or null if no staleness data was collected.
1767+
*/
1768+
private function effective_behind_count( array $response ): ?int {
1769+
if ( isset( $response['stale_commits_behind'] ) ) {
1770+
return (int) $response['stale_commits_behind'];
1771+
}
1772+
if ( isset( $response['base_stale_commits_behind'] ) ) {
1773+
return (int) $response['base_stale_commits_behind'];
1774+
}
1775+
return null;
1776+
}
1777+
1778+
/**
1779+
* Attempt to rebase the worktree onto its upstream.
1780+
*
1781+
* Target selection:
1782+
* - Existing-local-branch path → rebase onto `@{upstream}` if one is
1783+
* configured AND we observed stale_commits_behind > 0.
1784+
* - New-branch-off-local-base path → rebase onto `<base_upstream>` if
1785+
* we observed base_stale_commits_behind > 0.
1786+
*
1787+
* Returns an associative array to merge into the response:
1788+
* rebase_attempted, rebase_target, rebase_succeeded [, rebase_error]
1789+
*
1790+
* On success, clears the relevant staleness field (behind-count zeroes
1791+
* out and the gate will not trip). On conflict the rebase is aborted
1792+
* so the worktree stays at its pre-rebase state, and the gate may
1793+
* still trip — `--rebase-base` is not a silent `--allow-stale`.
1794+
*
1795+
* Returns null when there's nothing meaningful to rebase (up to date,
1796+
* no upstream, or staleness couldn't be computed).
1797+
*
1798+
* @param string $wt_path Worktree path.
1799+
* @param array $response Accumulated response payload.
1800+
* @param bool $created_branch Whether this was a freshly-created branch.
1801+
* @return array|null
1802+
*/
1803+
private function try_rebase_worktree( string $wt_path, array &$response, bool $created_branch ): ?array {
1804+
$target = null;
1805+
$clear = null;
1806+
1807+
if ( ! $created_branch
1808+
&& isset( $response['stale_commits_behind'] )
1809+
&& (int) $response['stale_commits_behind'] > 0
1810+
) {
1811+
$target = '@{upstream}';
1812+
$clear = 'stale_commits_behind';
1813+
} elseif ( $created_branch
1814+
&& isset( $response['base_stale_commits_behind'] )
1815+
&& (int) $response['base_stale_commits_behind'] > 0
1816+
&& ! empty( $response['base_upstream'] )
1817+
) {
1818+
$target = (string) $response['base_upstream'];
1819+
$clear = 'base_stale_commits_behind';
1820+
}
1821+
1822+
if ( null === $target ) {
1823+
return null;
1824+
}
1825+
1826+
$result = $this->run_git( $wt_path, sprintf( 'rebase %s', escapeshellarg( $target ) ) );
1827+
1828+
if ( is_wp_error( $result ) ) {
1829+
// Abort so the worktree stays at its pre-rebase HEAD. Agent can
1830+
// retry manually after resolving conflicts.
1831+
$this->run_git( $wt_path, 'rebase --abort' );
1832+
1833+
$data = $result->get_error_data();
1834+
$tail = is_array( $data ) && isset( $data['output'] ) ? trim( (string) $data['output'] ) : '';
1835+
$error = '' !== $tail ? $tail : $result->get_error_message();
1836+
1837+
return array(
1838+
'rebase_attempted' => true,
1839+
'rebase_target' => $target,
1840+
'rebase_succeeded' => false,
1841+
'rebase_error' => $error,
1842+
);
1843+
}
1844+
1845+
// Success: zero out the behind-count so the gate sees a fresh worktree.
1846+
if ( null !== $clear ) {
1847+
unset( $response[ $clear ] );
1848+
}
1849+
1850+
return array(
1851+
'rebase_attempted' => true,
1852+
'rebase_target' => $target,
1853+
'rebase_succeeded' => true,
1854+
);
1855+
}
1856+
16811857
/**
16821858
* Parse a `git worktree list --porcelain` block.
16831859
*

0 commit comments

Comments
 (0)