Skip to content

Commit 8fea762

Browse files
fix: fail closed on unverified worktree freshness (#756)
Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 59d2be8 commit 8fea762

5 files changed

Lines changed: 87 additions & 31 deletions

File tree

inc/Abilities/WorkspaceAbilities.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,7 +1283,7 @@ private function registerAbilities(): void {
12831283
'datamachine-code/workspace-worktree-add',
12841284
array(
12851285
'label' => 'Add Workspace Worktree',
1286-
'description' => 'Create a git worktree for a branch under `<repo>@<branch-slug>`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally. When `inject_context` is true (default), the originating site\'s composed AGENTS.md is made visible to OpenCode: symlinked into the worktree root when no repo-owned AGENTS.md exists, otherwise added via local OpenCode instructions so both files load. Site agent memory is snapshotted into `.claude/CLAUDE.local.md`, and injected paths are added to the worktree\'s per-checkout `info/exclude`. When `bootstrap` is true (default), submodule init plus root or one-level nested package-manager/composer installs run after creation so the worktree is immediately test/build-ready; set false to create a bare checkout.',
1286+
'description' => 'Create a git worktree for a branch under `<repo>@<branch-slug>`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally. Creation fails closed when remote freshness cannot be verified; set `allow_unverified_freshness=true` only for intentional offline work. When `inject_context` is true (default), the originating site\'s composed AGENTS.md is made visible to OpenCode: symlinked into the worktree root when no repo-owned AGENTS.md exists, otherwise added via local OpenCode instructions so both files load. Site agent memory is snapshotted into `.claude/CLAUDE.local.md`, and injected paths are added to the worktree\'s per-checkout `info/exclude`. When `bootstrap` is true (default), submodule init plus root or one-level nested package-manager/composer installs run after creation so the worktree is immediately test/build-ready; set false to create a bare checkout.',
12871287
'category' => 'datamachine-code-workspace',
12881288
'input_schema' => array(
12891289
'type' => 'object',
@@ -1312,6 +1312,10 @@ private function registerAbilities(): void {
13121312
'type' => 'boolean',
13131313
'description' => 'Bypass the staleness gate. When false (default), any branch/base behind the remote default branch is refused, and a new worktree more than `datamachine_worktree_stale_threshold` commits behind upstream is rolled back with a staleness error. Set true to opt in to a known-stale checkout.',
13141314
),
1315+
'allow_unverified_freshness' => array(
1316+
'type' => 'boolean',
1317+
'description' => 'Bypass the fetch-failure freshness gate. When false (default), worktree creation is refused if remote freshness cannot be verified. Set true only for intentional offline work with local refs.',
1318+
),
13151319
'rebase_base' => array(
13161320
'type' => 'boolean',
13171321
'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.',
@@ -1354,7 +1358,7 @@ private function registerAbilities(): void {
13541358
),
13551359
'fetch_failed' => array(
13561360
'type' => 'boolean',
1357-
'description' => 'Present only when the pre-create `git fetch origin` failed. Worktree creation continues either way; staleness fields are omitted when true.',
1361+
'description' => 'Present only when the pre-create `git fetch origin` failed and allow_unverified_freshness=true allowed creation to continue. Staleness fields are omitted when true.',
13581362
),
13591363
'fetch_error' => array(
13601364
'type' => 'string',
@@ -3535,6 +3539,8 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
35353539
$bootstrap = array_key_exists('bootstrap', $input) ? (bool) $input['bootstrap'] : true;
35363540
// Default allow_stale=false (gate enforced); only true when explicitly opted in.
35373541
$allow_stale = array_key_exists('allow_stale', $input) ? (bool) $input['allow_stale'] : false;
3542+
// Default allow_unverified_freshness=false (fetch-failure gate enforced).
3543+
$allow_unverified_freshness = array_key_exists('allow_unverified_freshness', $input) ? (bool) $input['allow_unverified_freshness'] : false;
35383544
// Default rebase_base=false; only true when explicitly requested.
35393545
$rebase_base = array_key_exists('rebase_base', $input) ? (bool) $input['rebase_base'] : false;
35403546
$force = ! empty($input['force']);
@@ -3557,7 +3563,8 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
35573563
$allow_stale,
35583564
$rebase_base,
35593565
$force,
3560-
$task
3566+
$task,
3567+
$allow_unverified_freshness
35613568
);
35623569
}
35633570

@@ -3581,7 +3588,8 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
35813588
$allow_stale,
35823589
$rebase_base,
35833590
$force,
3584-
$task
3591+
$task,
3592+
$allow_unverified_freshness
35853593
);
35863594
}
35873595

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3248,20 +3248,28 @@ private function renderGitOperationResult( string $operation, array $result, arr
32483248
* read use (faster, no deps installed). The ability-level input is
32493249
* `bootstrap=false`; this flag is the CLI shorthand (matches the
32503250
* existing `--skip-context-injection` convention).
3251-
*
3252-
* [--allow-stale]
3253-
* : Bypass the staleness gate (applies to `add` only). By default,
3254-
* `worktree add` refuses any branch/base that is behind the remote
3255-
* default branch after fetch, and refuses to return a worktree that
3256-
* would be more than
3257-
* `datamachine_worktree_stale_threshold` commits (default 50) behind
3258-
* upstream — the stale checkout is torn down and a `worktree_stale`
3259-
* error is returned with remediation options. Pass `--allow-stale` to
3260-
* opt in to a known-stale checkout. Default-branch freshness is
3261-
* zero-tolerance: one missing default-branch commit is stale. The
3262-
* ability-level input is
3263-
* `allow_stale=true`.
3264-
*
3251+
*
3252+
* [--allow-stale]
3253+
* : Bypass the staleness gate (applies to `add` only). By default,
3254+
* `worktree add` refuses any branch/base that is behind the remote
3255+
* default branch after fetch, fails closed when fetch cannot verify
3256+
* remote freshness, and refuses to return a worktree that
3257+
* would be more than
3258+
* `datamachine_worktree_stale_threshold` commits (default 50) behind
3259+
* upstream — the stale checkout is torn down and a `worktree_stale`
3260+
* error is returned with remediation options. Pass `--allow-stale` to
3261+
* opt in to a known-stale checkout. Default-branch freshness is
3262+
* zero-tolerance: one missing default-branch commit is stale. The
3263+
* ability-level input is
3264+
* `allow_stale=true`.
3265+
*
3266+
* [--allow-unverified-freshness]
3267+
* : Bypass the fetch-failure freshness gate (applies to `add` only).
3268+
* By default, `worktree add` refuses to create a checkout when `git fetch`
3269+
* fails because remote freshness cannot be verified. Use this only for
3270+
* intentional offline work with local refs. The ability-level input is
3271+
* `allow_unverified_freshness=true`.
3272+
*
32653273
* [--rebase-base]
32663274
* : After creating the worktree, rebase onto the upstream tip (applies
32673275
* to `add` only). For existing branches this is `@{upstream}`; for
@@ -3534,11 +3542,14 @@ private function renderGitOperationResult( string $operation, array $result, arr
35343542
*
35353543
* # Create a bare worktree (skip the default bootstrap pass)
35363544
* wp datamachine-code workspace worktree add data-machine fix/foo --skip-bootstrap
3537-
*
3538-
* # Proceed with a known-stale base/branch (bypass the staleness gate)
3539-
* wp datamachine-code workspace worktree add data-machine fix/foo --allow-stale
3540-
*
3541-
* # Auto-rebase onto upstream after creation
3545+
*
3546+
* # Proceed with a known-stale base/branch (bypass the staleness gate)
3547+
* wp datamachine-code workspace worktree add data-machine fix/foo --allow-stale
3548+
*
3549+
* # Proceed intentionally while offline when fetch cannot verify freshness
3550+
* wp datamachine-code workspace worktree add data-machine fix/foo --allow-unverified-freshness
3551+
*
3552+
* # Auto-rebase onto upstream after creation
35423553
* wp datamachine-code workspace worktree add data-machine fix/foo --rebase-base
35433554
*
35443555
* # Re-read the originating site's agent memory into an existing worktree
@@ -3653,7 +3664,7 @@ public function worktree( array $args, array $assoc_args ): void {
36533664
switch ( $operation ) {
36543665
case 'add':
36553666
if ( empty($args[1]) || empty($args[2]) ) {
3656-
WP_CLI::error('Usage: worktree add <repo> <branch> [--from=<ref>|--base=<ref>|--base-ref=<ref>|--base-branch=<branch>] [--skip-context-injection] [--skip-bootstrap] [--allow-stale] [--rebase-base] [--force]');
3667+
WP_CLI::error('Usage: worktree add <repo> <branch> [--from=<ref>|--base=<ref>|--base-ref=<ref>|--base-branch=<branch>] [--skip-context-injection] [--skip-bootstrap] [--allow-stale] [--allow-unverified-freshness] [--rebase-base] [--force]');
36573668
return;
36583669
}
36593670
$input['repo'] = $args[1];
@@ -3685,6 +3696,8 @@ public function worktree( array $args, array $assoc_args ): void {
36853696
$input['bootstrap'] = empty($assoc_args['skip-bootstrap']);
36863697
// --allow-stale opts in to a known-stale worktree (default: gate enforced).
36873698
$input['allow_stale'] = ! empty($assoc_args['allow-stale']);
3699+
// --allow-unverified-freshness opts in when fetch cannot verify remote refs.
3700+
$input['allow_unverified_freshness'] = ! empty($assoc_args['allow-unverified-freshness']);
36883701
// --rebase-base auto-rebases onto upstream after creation (default: off).
36893702
$input['rebase_base'] = ! empty($assoc_args['rebase-base']);
36903703
// --force is an explicit disk-budget override for add.

inc/Tools/WorkspaceTools.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ public function handleWorktreeAdd( array $parameters ): array
560560
}
561561
}
562562

563-
foreach ( array( 'inject_context', 'bootstrap', 'allow_stale', 'rebase_base', 'force' ) as $key ) {
563+
foreach ( array( 'inject_context', 'bootstrap', 'allow_stale', 'allow_unverified_freshness', 'rebase_base', 'force' ) as $key ) {
564564
if (array_key_exists($key, $parameters) ) {
565565
$input[ $key ] = (bool) $parameters[ $key ];
566566
}
@@ -1414,7 +1414,8 @@ public function getWorktreeAddDefinition(): array
14141414
'from' => array( 'type' => 'string', 'description' => 'Base ref when creating the branch. Defaults to origin/HEAD.' ),
14151415
'inject_context' => array( 'type' => 'boolean', 'description' => 'Inject originating agent context into the worktree. Default true.' ),
14161416
'bootstrap' => array( 'type' => 'boolean', 'description' => 'Run detected bootstrap steps after creation. Default true.' ),
1417-
'allow_stale' => array( 'type' => 'boolean', 'description' => 'Bypass the staleness gate. Default false.' ),
1417+
'allow_stale' => array( 'type' => 'boolean', 'description' => 'Bypass the verified staleness gate. Default false.' ),
1418+
'allow_unverified_freshness' => array( 'type' => 'boolean', 'description' => 'Bypass fetch-failure freshness verification for intentional offline work. Default false.' ),
14181419
'rebase_base' => array( 'type' => 'boolean', 'description' => 'Rebase the worktree onto the upstream tip after creation. Default false.' ),
14191420
'force' => array( 'type' => 'boolean', 'description' => 'Bypass disk-budget refusal threshold. Default false.' ),
14201421
'task_url' => array( 'type' => 'string', 'description' => 'Optional task or issue URL to record on the worktree.' ),

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ trait WorkspaceWorktreeLifecycle {
4141
* Pass `$bootstrap = false` (or `--no-bootstrap` on the CLI) for a bare
4242
* checkout when you only need to read code on that branch.
4343
*
44+
* When remote freshness cannot be verified, worktree creation is refused
45+
* unless `$allow_unverified_freshness` is set. This keeps default operation
46+
* fail-closed while preserving intentional offline workflows.
47+
*
4448
* When the branch/base is behind the remote default branch, worktree
4549
* creation is refused unless `$allow_stale` is set. This check is
4650
* zero-tolerance: any default-branch commits missing from the requested
@@ -64,9 +68,11 @@ trait WorkspaceWorktreeLifecycle {
6468
* @param bool $allow_stale Bypass the staleness gate (default false).
6569
* @param bool $rebase_base Rebase onto upstream after creation (default false).
6670
* @param bool $force Bypass the disk-budget refusal threshold (default false).
71+
* @param array $task Optional task metadata recorded on the worktree.
72+
* @param bool $allow_unverified_freshness Bypass fetch-failure freshness verification (default false).
6773
* @return array{success: bool, handle: string, path: string, branch: string, slug: string, created_branch: bool, message: string, disk_budget?: array, 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, default_branch_commits_behind?: int, default_branch_ref?: string, gate_threshold?: int, rebase_attempted?: bool, rebase_succeeded?: bool, rebase_error?: string, rebase_target?: string}|\WP_Error
6874
*/
69-
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, bool $force = false, array $task = array() ): array|\WP_Error {
75+
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, bool $force = false, array $task = array(), bool $allow_unverified_freshness = false ): array|\WP_Error {
7076
$visible = $this->require_workspace_visible();
7177
if ( null !== $visible ) {
7278
return $visible;
@@ -146,7 +152,8 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
146152
$wt_handle,
147153
$wt_path,
148154
$primary_path,
149-
$task
155+
$task,
156+
$allow_unverified_freshness
150157
)
151158
);
152159

@@ -207,6 +214,8 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
207214
* @param string $wt_handle Worktree handle.
208215
* @param string $wt_path Worktree path.
209216
* @param string $primary_path Primary checkout path.
217+
* @param array $task Optional task metadata recorded on the worktree.
218+
* @param bool $allow_unverified_freshness Bypass fetch-failure freshness verification.
210219
* @return array|\WP_Error
211220
*/
212221
private function worktree_add_locked(
@@ -220,18 +229,31 @@ private function worktree_add_locked(
220229
string $wt_handle,
221230
string $wt_path,
222231
string $primary_path,
223-
array $task = array()
232+
array $task = array(),
233+
bool $allow_unverified_freshness = false
224234
): array|\WP_Error {
225235
if ( is_dir($wt_path) ) {
226236
return new \WP_Error('worktree_exists', sprintf('Worktree handle "%s" already exists.', $wt_handle), array( 'status' => 400 ));
227237
}
228238

229239
// Always fetch first so staleness data (and the default base) reflects the
230-
// current remote. Failure is logged but never aborts — offline work should
231-
// still be possible, the agent just needs to know staleness is unknown.
240+
// current remote. If fetch fails, default to fail-closed unless the caller
241+
// explicitly opts into unverified/offline freshness.
232242
$fetch = WorktreeStalenessProbe::fetch($primary_path);
233243
$fetch_failed = ! $fetch['ok'];
234244
$fetch_error = $fetch['error'] ?? null;
245+
if ( $fetch_failed && ! $allow_unverified_freshness ) {
246+
return new \WP_Error(
247+
'worktree_freshness_unverified',
248+
'Refusing to create worktree because remote freshness could not be verified. Retry after connectivity is restored, or pass allow_unverified_freshness=true only when intentionally working offline with stale local refs.',
249+
array(
250+
'status' => 409,
251+
'fetch_failed' => true,
252+
'fetch_error' => $fetch_error,
253+
'allow_unverified_freshness' => false,
254+
)
255+
);
256+
}
235257

236258
// Does the branch already exist locally?
237259
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec

tests/worktree-add-lifecycle.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,18 @@ function create_primary_checkout( string $workspace_root ): void {
238238
assert_true('worktree_inventory_persist_failed' === $failed->get_error_code(), 'unexpected persistence failure error code');
239239
assert_true(! is_dir($workspace_root . '/homeboy@audit-primitives-persist-fails'), 'failed persistence left a worktree directory behind');
240240

241+
$GLOBALS['wpdb'] = new Datamachine_Code_Test_Wpdb();
242+
run_command('git remote set-url origin ' . escapeshellarg($workspace_root . '/missing-origin.git'), $workspace_root . '/homeboy');
243+
$fetch_failed_default = $workspace->worktree_add('homeboy', 'audit-primitives-fetch-fails', 'origin/main', false, false, false, false, true);
244+
assert_true(is_wp_error($fetch_failed_default), 'fetch failure reported success without explicit opt-in');
245+
assert_true('worktree_freshness_unverified' === $fetch_failed_default->get_error_code(), 'unexpected fetch failure error code');
246+
assert_true(! is_dir($workspace_root . '/homeboy@audit-primitives-fetch-fails'), 'fetch failure left a worktree directory behind');
247+
248+
$fetch_failed_allowed = $workspace->worktree_add('homeboy', 'audit-primitives-fetch-fails-allowed', 'origin/main', false, false, false, false, true, array(), true);
249+
assert_true(! is_wp_error($fetch_failed_allowed), is_wp_error($fetch_failed_allowed) ? $fetch_failed_allowed->get_error_message() : 'fetch failure opt-in failed');
250+
assert_true(! empty($fetch_failed_allowed['fetch_failed']), 'fetch failure opt-in did not surface fetch_failed');
251+
assert_true(is_dir($fetch_failed_allowed['path']), 'fetch failure opt-in worktree path is not accessible');
252+
241253
remove_tree($workspace_root);
242254
fwrite(STDOUT, "worktree-add-lifecycle ok\n");
243255
} catch (Throwable $e) {

0 commit comments

Comments
 (0)