Skip to content

Commit cd2970a

Browse files
author
Chris Huber
committed
Materialize remote workspaces locally
1 parent 3dda9c3 commit cd2970a

5 files changed

Lines changed: 261 additions & 0 deletions

File tree

inc/Abilities/WorkspaceAbilities.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,45 @@ private function registerAbilities(): void {
456456
)
457457
);
458458

459+
AbilityRegistry::register(
460+
'datamachine-code/workspace-materialize',
461+
array(
462+
'label' => 'Materialize Remote Workspace',
463+
'description' => 'Materialize a registered GitHub API workspace primary or worktree as an authoritative local checkout. Existing local clone, duplicate-remote, freshness, task, and worktree safety policies remain enforced.',
464+
'category' => 'datamachine-code-workspace',
465+
'input_schema' => array(
466+
'type' => 'object',
467+
'properties' => array(
468+
'handle' => array( 'type' => 'string', 'description' => 'Registered remote primary or worktree handle.' ),
469+
'full' => array( 'type' => 'boolean', 'description' => 'Disable blobless partial clone when creating the local primary.' ),
470+
'allow_duplicate_remote' => array( 'type' => 'boolean', 'description' => 'Explicitly permit a second local primary for the same remote.' ),
471+
'inject_context' => array( 'type' => 'boolean', 'description' => 'Inject workspace context into a materialized worktree. Default true.' ),
472+
'bootstrap' => array( 'type' => 'boolean', 'description' => 'Run the normal worktree bootstrap after materialization. Default true.' ),
473+
'allow_stale' => array( 'type' => 'boolean', 'description' => 'Explicitly bypass worktree staleness gates.' ),
474+
'allow_unverified_freshness' => array( 'type' => 'boolean', 'description' => 'Explicitly permit materialization when freshness fetch cannot be verified.' ),
475+
'rebase_base' => array( 'type' => 'boolean', 'description' => 'Rebase the materialized worktree onto its upstream when needed.' ),
476+
'force' => array( 'type' => 'boolean', 'description' => 'Explicitly bypass the worktree disk-budget refusal.' ),
477+
'require_task_tracker' => array( 'type' => 'boolean', 'description' => 'Require task metadata from the registered remote worktree. Default true.' ),
478+
),
479+
'required' => array( 'handle' ),
480+
),
481+
'output_schema' => array(
482+
'type' => 'object',
483+
'properties' => array(
484+
'success' => array( 'type' => 'boolean' ),
485+
'backend' => array( 'type' => 'string' ),
486+
'handle' => array( 'type' => 'string' ),
487+
'path' => array( 'type' => 'string' ),
488+
'branch' => array( 'type' => 'string' ),
489+
'message' => array( 'type' => 'string' ),
490+
),
491+
),
492+
'execute_callback' => array( self::class, 'materializeRemoteWorkspace' ),
493+
'permission_callback' => fn() => PermissionHelper::can_manage(),
494+
'meta' => array( 'show_in_rest' => false ),
495+
)
496+
);
497+
459498
AbilityRegistry::register(
460499
'datamachine-code/workspace-context-repositories',
461500
array(
@@ -3191,6 +3230,23 @@ public static function cloneRepo( array $input ): array|\WP_Error {
31913230
return $result;
31923231
}
31933232

3233+
/**
3234+
* Materialize registered GitHub API workspace state using the local backend.
3235+
*
3236+
* @param array<string,mixed> $input Materialization options.
3237+
* @return array<string,mixed>|\WP_Error
3238+
*/
3239+
public static function materializeRemoteWorkspace( array $input ): array|\WP_Error {
3240+
$remote = new RemoteWorkspaceBackend();
3241+
$context = $remote->materialization_context((string) ( $input['handle'] ?? '' ));
3242+
if ( is_wp_error($context) ) {
3243+
return $context;
3244+
}
3245+
3246+
$workspace = new Workspace();
3247+
return $workspace->materialize_remote_workspace($context, $input);
3248+
}
3249+
31943250
/**
31953251
* Register read-only context repositories for workspace tools.
31963252
*

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,64 @@ public function clone_repo( array $args, array $assoc_args ): void {
553553
WP_CLI::log(sprintf('Path: %s', (string) ( $result['path'] ?? '' )));
554554
}
555555

556+
/**
557+
* Materialize a registered remote workspace as a local checkout.
558+
*
559+
* ## OPTIONS
560+
*
561+
* <handle>
562+
* : Registered remote primary or worktree handle.
563+
*
564+
* [--skip-bootstrap]
565+
* : Create a bare worktree without dependency installation.
566+
*
567+
* [--allow-stale]
568+
* : Explicitly bypass worktree staleness gates.
569+
*
570+
* [--allow-unverified-freshness]
571+
* : Explicitly permit materialization when freshness cannot be verified.
572+
*
573+
* ## EXAMPLES
574+
*
575+
* wp datamachine-code workspace materialize mcp-adapter@feat-255-successful-pre-execution-completion
576+
*
577+
* @subcommand materialize
578+
*/
579+
public function materialize( array $args, array $assoc_args ): void {
580+
if ( empty($args[0]) ) {
581+
WP_CLI::error('Remote workspace handle is required.');
582+
return;
583+
}
584+
585+
$ability = wp_get_ability('datamachine-code/workspace-materialize');
586+
if ( ! $ability ) {
587+
WP_CLI::error('Workspace materialize ability not available.');
588+
return;
589+
}
590+
591+
$result = $ability->execute(
592+
array(
593+
'handle' => (string) $args[0],
594+
'full' => ! empty($assoc_args['full']),
595+
'allow_duplicate_remote' => ! empty($assoc_args['allow-duplicate-remote']),
596+
'inject_context' => empty($assoc_args['skip-context-injection']),
597+
'bootstrap' => empty($assoc_args['skip-bootstrap']),
598+
'allow_stale' => ! empty($assoc_args['allow-stale']),
599+
'allow_unverified_freshness' => ! empty($assoc_args['allow-unverified-freshness']),
600+
'rebase_base' => ! empty($assoc_args['rebase-base']),
601+
'force' => ! empty($assoc_args['force']),
602+
'require_task_tracker' => ! isset($assoc_args['require-task-tracker']) || ! empty($assoc_args['require-task-tracker']),
603+
)
604+
);
605+
if ( is_wp_error($result) ) {
606+
WP_CLI::error($result->get_error_message());
607+
return;
608+
}
609+
610+
WP_CLI::success((string) ( $result['message'] ?? 'Remote workspace materialized.' ));
611+
WP_CLI::log(sprintf('Path: %s', (string) ( $result['path'] ?? '' )));
612+
}
613+
556614
/**
557615
* Adopt an existing primary checkout already under the workspace root.
558616
*

inc/Workspace/RemoteWorkspaceBackend.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,36 @@ public function show( string $handle ): array|\WP_Error {
618618
return $result;
619619
}
620620

621+
/**
622+
* Return registered remote state needed to materialize a local checkout.
623+
*
624+
* @return array<string,mixed>|\WP_Error
625+
*/
626+
public function materialization_context( string $handle ): array|\WP_Error {
627+
$context = $this->resolve_handle($handle);
628+
if ( is_wp_error($context) ) {
629+
return $context;
630+
}
631+
if ( ! empty($context['read_only_context']) ) {
632+
return new \WP_Error('remote_workspace_materialization_unsupported', 'Read-only context repositories cannot be materialized as editable workspaces.', array( 'status' => 400 ));
633+
}
634+
635+
$state = $this->state();
636+
$repo_name = (string) ( $context['repo_name'] ?? '' );
637+
$repo = (string) ( $context['repo'] ?? '' );
638+
$url = (string) ( $state['repos'][ $repo_name ]['url'] ?? GitHubRemote::cloneUrl($repo) );
639+
640+
return array(
641+
'handle' => (string) ( $context['handle'] ?? $handle ),
642+
'repo_name' => $repo_name,
643+
'repo' => $repo,
644+
'url' => $url,
645+
'branch' => (string) ( $context['branch'] ?? '' ),
646+
'base_ref' => (string) ( $context['base_ref'] ?? '' ),
647+
'task' => (array) ( $context['task'] ?? array() ),
648+
);
649+
}
650+
621651
/**
622652
* Return a diff of pending remote workspace changes.
623653
*

inc/Workspace/WorkspaceRepositoryLifecycle.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,96 @@ public function clone_repo( string $url, ?string $name = null, array $options =
274274
);
275275
}
276276

277+
/**
278+
* Materialize registered remote workspace state through the local lifecycle.
279+
*
280+
* @param array<string,mixed> $remote Registered remote workspace details.
281+
* @param array<string,mixed> $options Local clone/worktree options.
282+
* @return array<string,mixed>|\WP_Error
283+
*/
284+
public function materialize_remote_workspace( array $remote, array $options = array() ): array|\WP_Error {
285+
$repo_name = trim( (string) ( $remote['repo_name'] ?? '' ) );
286+
$url = trim( (string) ( $remote['url'] ?? '' ) );
287+
$branch = trim( (string) ( $remote['branch'] ?? '' ) );
288+
if ( '' === $repo_name || '' === $url ) {
289+
return new \WP_Error('remote_workspace_materialization_invalid', 'Registered remote workspace is missing its repository identity.', array( 'status' => 400 ));
290+
}
291+
292+
$primary = $this->show_repo($repo_name);
293+
if ( is_wp_error($primary) ) {
294+
$primary = $this->clone_repo(
295+
$url,
296+
$repo_name,
297+
array(
298+
'full' => ! empty($options['full']),
299+
'allow_duplicate_remote' => ! empty($options['allow_duplicate_remote']),
300+
)
301+
);
302+
if ( is_wp_error($primary) ) {
303+
return $primary;
304+
}
305+
} elseif ( ! empty($primary['is_worktree']) || $this->normalize_git_remote_url($url) !== $this->normalize_git_remote_url((string) ( $primary['remote'] ?? '' )) ) {
306+
return new \WP_Error('remote_workspace_materialization_primary_conflict', sprintf('Workspace primary "%s" does not match the registered remote %s.', $repo_name, $url), array( 'status' => 409 ));
307+
}
308+
309+
if ( '' === $branch ) {
310+
return array(
311+
'success' => true,
312+
'backend' => 'local_git',
313+
'handle' => $repo_name,
314+
'path' => (string) ( $primary['path'] ?? '' ),
315+
'materialized_primary' => true,
316+
'message' => sprintf('Materialized remote workspace primary "%s".', $repo_name),
317+
);
318+
}
319+
320+
$handle = $repo_name . '@' . $this->slugify_branch($branch);
321+
$existing = $this->show_repo($handle);
322+
if ( ! is_wp_error($existing) ) {
323+
if ( (string) ( $existing['branch'] ?? '' ) !== $branch ) {
324+
return new \WP_Error('remote_workspace_materialization_worktree_conflict', sprintf('Workspace handle "%s" is already checked out to branch "%s".', $handle, (string) ( $existing['branch'] ?? '' )), array( 'status' => 409 ));
325+
}
326+
return array(
327+
'success' => true,
328+
'backend' => 'local_git',
329+
'handle' => $handle,
330+
'path' => (string) ( $existing['path'] ?? '' ),
331+
'branch' => $branch,
332+
'already_materialized' => true,
333+
'message' => sprintf('Remote workspace "%s" is already materialized at %s.', $handle, (string) ( $existing['path'] ?? '' )),
334+
);
335+
}
336+
337+
$remote_branch = $this->run_git((string) ( $primary['path'] ?? '' ), 'ls-remote --heads origin ' . escapeshellarg($branch));
338+
if ( is_wp_error($remote_branch) ) {
339+
return $remote_branch;
340+
}
341+
$from = '' !== trim( (string) ( $remote_branch['output'] ?? '' ) )
342+
? 'origin/' . $branch
343+
: ( '' !== trim( (string) ( $remote['base_ref'] ?? '' ) ) ? (string) $remote['base_ref'] : null );
344+
345+
$result = $this->worktree_add(
346+
$repo_name,
347+
$branch,
348+
$from,
349+
array_key_exists('inject_context', $options) ? (bool) $options['inject_context'] : true,
350+
array_key_exists('bootstrap', $options) ? (bool) $options['bootstrap'] : true,
351+
! empty($options['allow_stale']),
352+
! empty($options['rebase_base']),
353+
! empty($options['force']),
354+
(array) ( $remote['task'] ?? array() ),
355+
! empty($options['allow_unverified_freshness']),
356+
array_key_exists('require_task_tracker', $options) ? (bool) $options['require_task_tracker'] : true
357+
);
358+
if ( is_wp_error($result) ) {
359+
return $result;
360+
}
361+
362+
$result['backend'] = 'local_git';
363+
$result['materialized'] = true;
364+
return $result;
365+
}
366+
277367
/**
278368
* Ensure the primary's currently checked-out default branch tracks origin.
279369
*

tests/worktree-add-lifecycle.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,33 @@ function create_primary_checkout( string $workspace_root ): void {
269269
assert_true('homeboy@address-darren-embedding-review' === ( $canonical_targeted['worktrees'][0]['handle'] ?? '' ), 'canonical worktree lookup returned the wrong handle');
270270
assert_true('issue/242-embedding-generation' === ( $canonical_targeted['worktrees'][0]['branch'] ?? '' ), 'canonical worktree lookup did not preserve the Git branch');
271271
assert_true(null !== ( $canonical_targeted['worktrees'][0]['dirty'] ?? null ), 'canonical worktree lookup did not run the requested status probe');
272+
273+
// A GitHub API workspace registers only this identity. Materialization must
274+
// use the normal local lifecycle so the resulting handle is resolver-ready.
275+
$materialized = $workspace->materialize_remote_workspace(
276+
array(
277+
'handle' => 'homeboy@feat-remote-materialization',
278+
'repo_name' => 'homeboy',
279+
'repo' => 'owner/homeboy',
280+
'url' => $workspace_root . '/origin.git',
281+
'branch' => 'feat/remote-materialization',
282+
'base_ref' => 'origin/main',
283+
'task' => array( 'task_url' => 'https://example.test/issues/255' ),
284+
),
285+
array(
286+
'inject_context' => false,
287+
'bootstrap' => false,
288+
'force' => true,
289+
'require_task_tracker' => true,
290+
)
291+
);
292+
assert_true(! is_wp_error($materialized), is_wp_error($materialized) ? $materialized->get_error_message() : 'remote workspace materialization failed');
293+
assert_true($workspace_root . '/homeboy@feat-remote-materialization' === ( $materialized['path'] ?? '' ), 'materialized workspace returned an unexpected path');
294+
assert_true(is_file($workspace_root . '/homeboy@feat-remote-materialization/.git'), 'materialized workspace did not create a local worktree');
295+
assert_true('https://example.test/issues/255' === ( $wpdb->rows['homeboy@feat-remote-materialization']['task_url'] ?? '' ), 'materialization did not preserve remote task metadata');
296+
$materialized_targeted = $workspace->worktree_list(null, null, array( 'handle' => 'homeboy@feat-remote-materialization', 'include_status' => false, 'include_disk' => false ));
297+
assert_true(1 === count($materialized_targeted['worktrees'] ?? array()), 'materialized workspace is not discoverable by targeted worktree lookup');
298+
assert_true($workspace_root . '/homeboy@feat-remote-materialization' === ( $materialized_targeted['worktrees'][0]['path'] ?? '' ), 'targeted lookup did not return the materialized local path');
272299
$GLOBALS['datamachine_code_test_filters']['datamachine_worktree_disk_budget_thresholds'] = static function ( array $thresholds ) use ( $workspace_root ): array {
273300
$free = disk_free_space($workspace_root);
274301
assert_true(false !== $free, 'fixture workspace free space is not measurable');

0 commit comments

Comments
 (0)