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
56 changes: 56 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,45 @@ private function registerAbilities(): void {
)
);

AbilityRegistry::register(
'datamachine-code/workspace-materialize',
array(
'label' => 'Materialize Remote Workspace',
'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.',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
'properties' => array(
'handle' => array( 'type' => 'string', 'description' => 'Registered remote primary or worktree handle.' ),
'full' => array( 'type' => 'boolean', 'description' => 'Disable blobless partial clone when creating the local primary.' ),
'allow_duplicate_remote' => array( 'type' => 'boolean', 'description' => 'Explicitly permit a second local primary for the same remote.' ),
'inject_context' => array( 'type' => 'boolean', 'description' => 'Inject workspace context into a materialized worktree. Default true.' ),
'bootstrap' => array( 'type' => 'boolean', 'description' => 'Run the normal worktree bootstrap after materialization. Default true.' ),
'allow_stale' => array( 'type' => 'boolean', 'description' => 'Explicitly bypass worktree staleness gates.' ),
'allow_unverified_freshness' => array( 'type' => 'boolean', 'description' => 'Explicitly permit materialization when freshness fetch cannot be verified.' ),
'rebase_base' => array( 'type' => 'boolean', 'description' => 'Rebase the materialized worktree onto its upstream when needed.' ),
'force' => array( 'type' => 'boolean', 'description' => 'Explicitly bypass the worktree disk-budget refusal.' ),
'require_task_tracker' => array( 'type' => 'boolean', 'description' => 'Require task metadata from the registered remote worktree. Default true.' ),
),
'required' => array( 'handle' ),
),
'output_schema' => array(
'type' => 'object',
'properties' => array(
'success' => array( 'type' => 'boolean' ),
'backend' => array( 'type' => 'string' ),
'handle' => array( 'type' => 'string' ),
'path' => array( 'type' => 'string' ),
'branch' => array( 'type' => 'string' ),
'message' => array( 'type' => 'string' ),
),
),
'execute_callback' => array( self::class, 'materializeRemoteWorkspace' ),
'permission_callback' => fn() => PermissionHelper::can_manage(),
'meta' => array( 'show_in_rest' => false ),
)
);

AbilityRegistry::register(
'datamachine-code/workspace-context-repositories',
array(
Expand Down Expand Up @@ -3191,6 +3230,23 @@ public static function cloneRepo( array $input ): array|\WP_Error {
return $result;
}

/**
* Materialize registered GitHub API workspace state using the local backend.
*
* @param array<string,mixed> $input Materialization options.
* @return array<string,mixed>|\WP_Error
*/
public static function materializeRemoteWorkspace( array $input ): array|\WP_Error {
$remote = new RemoteWorkspaceBackend();
$context = $remote->materialization_context((string) ( $input['handle'] ?? '' ));
if ( is_wp_error($context) ) {
return $context;
}

$workspace = new Workspace();
return $workspace->materialize_remote_workspace($context, $input);
}

/**
* Register read-only context repositories for workspace tools.
*
Expand Down
58 changes: 58 additions & 0 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,64 @@ public function clone_repo( array $args, array $assoc_args ): void {
WP_CLI::log(sprintf('Path: %s', (string) ( $result['path'] ?? '' )));
}

/**
* Materialize a registered remote workspace as a local checkout.
*
* ## OPTIONS
*
* <handle>
* : Registered remote primary or worktree handle.
*
* [--skip-bootstrap]
* : Create a bare worktree without dependency installation.
*
* [--allow-stale]
* : Explicitly bypass worktree staleness gates.
*
* [--allow-unverified-freshness]
* : Explicitly permit materialization when freshness cannot be verified.
*
* ## EXAMPLES
*
* wp datamachine-code workspace materialize mcp-adapter@feat-255-successful-pre-execution-completion
*
* @subcommand materialize
*/
public function materialize( array $args, array $assoc_args ): void {
if ( empty($args[0]) ) {
WP_CLI::error('Remote workspace handle is required.');
return;
}

$ability = wp_get_ability('datamachine-code/workspace-materialize');
if ( ! $ability ) {
WP_CLI::error('Workspace materialize ability not available.');
return;
}

$result = $ability->execute(
array(
'handle' => (string) $args[0],
'full' => ! empty($assoc_args['full']),
'allow_duplicate_remote' => ! empty($assoc_args['allow-duplicate-remote']),
'inject_context' => empty($assoc_args['skip-context-injection']),
'bootstrap' => empty($assoc_args['skip-bootstrap']),
'allow_stale' => ! empty($assoc_args['allow-stale']),
'allow_unverified_freshness' => ! empty($assoc_args['allow-unverified-freshness']),
'rebase_base' => ! empty($assoc_args['rebase-base']),
'force' => ! empty($assoc_args['force']),
'require_task_tracker' => ! isset($assoc_args['require-task-tracker']) || ! empty($assoc_args['require-task-tracker']),
)
);
if ( is_wp_error($result) ) {
WP_CLI::error($result->get_error_message());
return;
}

WP_CLI::success((string) ( $result['message'] ?? 'Remote workspace materialized.' ));
WP_CLI::log(sprintf('Path: %s', (string) ( $result['path'] ?? '' )));
}

/**
* Adopt an existing primary checkout already under the workspace root.
*
Expand Down
30 changes: 30 additions & 0 deletions inc/Workspace/RemoteWorkspaceBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,36 @@ public function show( string $handle ): array|\WP_Error {
return $result;
}

/**
* Return registered remote state needed to materialize a local checkout.
*
* @return array<string,mixed>|\WP_Error
*/
public function materialization_context( string $handle ): array|\WP_Error {
$context = $this->resolve_handle($handle);
if ( is_wp_error($context) ) {
return $context;
}
if ( ! empty($context['read_only_context']) ) {
return new \WP_Error('remote_workspace_materialization_unsupported', 'Read-only context repositories cannot be materialized as editable workspaces.', array( 'status' => 400 ));
}

$state = $this->state();
$repo_name = (string) ( $context['repo_name'] ?? '' );
$repo = (string) ( $context['repo'] ?? '' );
$url = (string) ( $state['repos'][ $repo_name ]['url'] ?? GitHubRemote::cloneUrl($repo) );

return array(
'handle' => (string) ( $context['handle'] ?? $handle ),
'repo_name' => $repo_name,
'repo' => $repo,
'url' => $url,
'branch' => (string) ( $context['branch'] ?? '' ),
'base_ref' => (string) ( $context['base_ref'] ?? '' ),
'task' => (array) ( $context['task'] ?? array() ),
);
}

/**
* Return a diff of pending remote workspace changes.
*
Expand Down
90 changes: 90 additions & 0 deletions inc/Workspace/WorkspaceRepositoryLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,96 @@ public function clone_repo( string $url, ?string $name = null, array $options =
);
}

/**
* Materialize registered remote workspace state through the local lifecycle.
*
* @param array<string,mixed> $remote Registered remote workspace details.
* @param array<string,mixed> $options Local clone/worktree options.
* @return array<string,mixed>|\WP_Error
*/
public function materialize_remote_workspace( array $remote, array $options = array() ): array|\WP_Error {
$repo_name = trim( (string) ( $remote['repo_name'] ?? '' ) );
$url = trim( (string) ( $remote['url'] ?? '' ) );
$branch = trim( (string) ( $remote['branch'] ?? '' ) );
if ( '' === $repo_name || '' === $url ) {
return new \WP_Error('remote_workspace_materialization_invalid', 'Registered remote workspace is missing its repository identity.', array( 'status' => 400 ));
}

$primary = $this->show_repo($repo_name);
if ( is_wp_error($primary) ) {
$primary = $this->clone_repo(
$url,
$repo_name,
array(
'full' => ! empty($options['full']),
'allow_duplicate_remote' => ! empty($options['allow_duplicate_remote']),
)
);
if ( is_wp_error($primary) ) {
return $primary;
}
} elseif ( ! empty($primary['is_worktree']) || $this->normalize_git_remote_url($url) !== $this->normalize_git_remote_url((string) ( $primary['remote'] ?? '' )) ) {
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 ));
}

if ( '' === $branch ) {
return array(
'success' => true,
'backend' => 'local_git',
'handle' => $repo_name,
'path' => (string) ( $primary['path'] ?? '' ),
'materialized_primary' => true,
'message' => sprintf('Materialized remote workspace primary "%s".', $repo_name),
);
}

$handle = $repo_name . '@' . $this->slugify_branch($branch);
$existing = $this->show_repo($handle);
if ( ! is_wp_error($existing) ) {
if ( (string) ( $existing['branch'] ?? '' ) !== $branch ) {
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 ));
}
return array(
'success' => true,
'backend' => 'local_git',
'handle' => $handle,
'path' => (string) ( $existing['path'] ?? '' ),
'branch' => $branch,
'already_materialized' => true,
'message' => sprintf('Remote workspace "%s" is already materialized at %s.', $handle, (string) ( $existing['path'] ?? '' )),
);
}

$remote_branch = $this->run_git((string) ( $primary['path'] ?? '' ), 'ls-remote --heads origin ' . escapeshellarg($branch));
if ( is_wp_error($remote_branch) ) {
return $remote_branch;
}
$from = '' !== trim( (string) ( $remote_branch['output'] ?? '' ) )
? 'origin/' . $branch
: ( '' !== trim( (string) ( $remote['base_ref'] ?? '' ) ) ? (string) $remote['base_ref'] : null );

$result = $this->worktree_add(
$repo_name,
$branch,
$from,
array_key_exists('inject_context', $options) ? (bool) $options['inject_context'] : true,
array_key_exists('bootstrap', $options) ? (bool) $options['bootstrap'] : true,
! empty($options['allow_stale']),
! empty($options['rebase_base']),
! empty($options['force']),
(array) ( $remote['task'] ?? array() ),
! empty($options['allow_unverified_freshness']),
array_key_exists('require_task_tracker', $options) ? (bool) $options['require_task_tracker'] : true
);
if ( is_wp_error($result) ) {
return $result;
}

$result['backend'] = 'local_git';
$result['materialized'] = true;
return $result;
}

/**
* Ensure the primary's currently checked-out default branch tracks origin.
*
Expand Down
27 changes: 27 additions & 0 deletions tests/worktree-add-lifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,33 @@ function create_primary_checkout( string $workspace_root ): void {
assert_true('homeboy@address-darren-embedding-review' === ( $canonical_targeted['worktrees'][0]['handle'] ?? '' ), 'canonical worktree lookup returned the wrong handle');
assert_true('issue/242-embedding-generation' === ( $canonical_targeted['worktrees'][0]['branch'] ?? '' ), 'canonical worktree lookup did not preserve the Git branch');
assert_true(null !== ( $canonical_targeted['worktrees'][0]['dirty'] ?? null ), 'canonical worktree lookup did not run the requested status probe');

// A GitHub API workspace registers only this identity. Materialization must
// use the normal local lifecycle so the resulting handle is resolver-ready.
$materialized = $workspace->materialize_remote_workspace(
array(
'handle' => 'homeboy@feat-remote-materialization',
'repo_name' => 'homeboy',
'repo' => 'owner/homeboy',
'url' => $workspace_root . '/origin.git',
'branch' => 'feat/remote-materialization',
'base_ref' => 'origin/main',
'task' => array( 'task_url' => 'https://example.test/issues/255' ),
),
array(
'inject_context' => false,
'bootstrap' => false,
'force' => true,
'require_task_tracker' => true,
)
);
assert_true(! is_wp_error($materialized), is_wp_error($materialized) ? $materialized->get_error_message() : 'remote workspace materialization failed');
assert_true($workspace_root . '/homeboy@feat-remote-materialization' === ( $materialized['path'] ?? '' ), 'materialized workspace returned an unexpected path');
assert_true(is_file($workspace_root . '/homeboy@feat-remote-materialization/.git'), 'materialized workspace did not create a local worktree');
assert_true('https://example.test/issues/255' === ( $wpdb->rows['homeboy@feat-remote-materialization']['task_url'] ?? '' ), 'materialization did not preserve remote task metadata');
$materialized_targeted = $workspace->worktree_list(null, null, array( 'handle' => 'homeboy@feat-remote-materialization', 'include_status' => false, 'include_disk' => false ));
assert_true(1 === count($materialized_targeted['worktrees'] ?? array()), 'materialized workspace is not discoverable by targeted worktree lookup');
assert_true($workspace_root . '/homeboy@feat-remote-materialization' === ( $materialized_targeted['worktrees'][0]['path'] ?? '' ), 'targeted lookup did not return the materialized local path');
$GLOBALS['datamachine_code_test_filters']['datamachine_worktree_disk_budget_thresholds'] = static function ( array $thresholds ) use ( $workspace_root ): array {
$free = disk_free_space($workspace_root);
assert_true(false !== $free, 'fixture workspace free space is not measurable');
Expand Down