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
35 changes: 24 additions & 11 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -2494,7 +2494,10 @@ public static function getCapabilities( array $input ): array { // phpcs:ignor
*/
public static function showRepo( array $input ): array|\WP_Error {
if ( RemoteWorkspaceBackend::should_handle() ) {
return ( new RemoteWorkspaceBackend() )->show($input['name'] ?? '');
$result = ( new RemoteWorkspaceBackend() )->show($input['name'] ?? '');
if ( ! self::shouldFallbackToLocalWorkspace($result) ) {
return $result;
}
}

$workspace = new Workspace();
Expand Down Expand Up @@ -3398,16 +3401,6 @@ public static function prRebase( array $input ): array|\WP_Error {
* @return array
*/
public static function worktreeAdd( array $input ): array|\WP_Error {
if ( RemoteWorkspaceBackend::should_handle() ) {
$result = ( new RemoteWorkspaceBackend() )->worktree_add(
$input['repo'] ?? '',
$input['branch'] ?? '',
$input['from'] ?? null
);
return self::decorate_remote_workspace_result('worktree_add', $result);
}

$workspace = new Workspace();
// Default inject_context=true; only false when explicitly provided.
$inject_context = array_key_exists('inject_context', $input) ? (bool) $input['inject_context'] : true;
// Default bootstrap=true; only false when explicitly provided.
Expand All @@ -3424,6 +3417,19 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
if ( isset($input['task_ref']) && '' !== trim( (string) $input['task_ref']) ) {
$task['task_ref'] = (string) $input['task_ref'];
}

if ( RemoteWorkspaceBackend::should_handle() ) {
$result = ( new RemoteWorkspaceBackend() )->worktree_add(
$input['repo'] ?? '',
$input['branch'] ?? '',
$input['from'] ?? null
);
if ( ! self::shouldFallbackToLocalWorkspace($result) ) {
return self::decorate_remote_workspace_result('worktree_add', $result);
}
}

$workspace = new Workspace();
return $workspace->worktree_add(
$input['repo'] ?? '',
$input['branch'] ?? '',
Expand All @@ -3437,6 +3443,13 @@ public static function worktreeAdd( array $input ): array|\WP_Error {
);
}

/**
* Whether a remote-backend miss should be retried against local workspace discovery.
*/
private static function shouldFallbackToLocalWorkspace( mixed $result ): bool {
return is_wp_error($result) && in_array($result->get_error_code(), array( 'remote_workspace_repo_not_found', 'unsupported_remote_workspace_repo_argument' ), true);
}

/**
* Refresh a worktree's injected context from the originating site.
*
Expand Down
16 changes: 15 additions & 1 deletion inc/Workspace/RemoteWorkspaceBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -1317,13 +1317,27 @@ private function resolve_repo( string $repo_name ): string|\WP_Error {
return (string) $state['repos'][ $repo_name ]['repo'];
}

if ( str_contains($repo_name, '/') ) {
if ( $this->looks_like_url_or_path($repo_name) ) {
return new \WP_Error('unsupported_remote_workspace_repo_argument', sprintf('Remote workspace worktree add requires a registered workspace name or owner/repo slug, not URL/path argument "%s".', $repo_name), array( 'status' => 400 ));
}

if ( preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', $repo_name) ) {
return $repo_name;
}

return new \WP_Error('remote_workspace_repo_not_found', sprintf('Remote workspace repository "%s" is not registered. Call workspace_clone first.', $repo_name), array( 'status' => 404 ));
}

private function looks_like_url_or_path( string $value ): bool {
$value = trim($value);
return str_starts_with($value, '/')
|| str_starts_with($value, './')
|| str_starts_with($value, '../')
|| str_starts_with($value, '~/')
|| (bool) preg_match('#^(?:https?|ssh|git)://#i', $value)
|| (bool) preg_match('/^[^@\s]+@[^:\s]+:.+$/', $value);
}

private function resolve_alias( string $handle ): string {
if ( class_exists(WorkspaceAliasResolver::class) ) {
return WorkspaceAliasResolver::resolve($handle);
Expand Down
93 changes: 92 additions & 1 deletion inc/Workspace/WorkspaceCoreUtilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,98 @@ private function sanitize_slug( string $slug ): string {
* @return string
*/
public function get_primary_path( string $repo ): string {
return $this->workspace_path . '/' . $this->sanitize_name($repo);
$resolved = $this->resolve_primary_repo_name($repo);
if ( is_wp_error($resolved) ) {
return $this->workspace_path . '/' . $this->sanitize_name($repo);
}

return $this->workspace_path . '/' . $resolved;
}

/**
* Resolve a primary repo argument to the canonical workspace directory name.
*
* @param string $repo Primary handle, git URL, or local checkout path.
* @return string|\WP_Error Canonical primary handle or validation error.
*/
public function resolve_primary_repo_name( string $repo ): string|\WP_Error {
$repo = trim($repo);
if ( '' === $repo ) {
return new \WP_Error('invalid_repo', 'Repository name is required.', array( 'status' => 400 ));
}

if ( str_contains($repo, '@') && ! $this->looks_like_git_url($repo) ) {
return new \WP_Error('invalid_repo', 'Worktree handles cannot be used where a primary repository is required.', array( 'status' => 400 ));
}

if ( $this->looks_like_git_url($repo) ) {
$existing = $this->find_primary_by_remote($repo);
if ( null !== $existing ) {
return $existing['name'];
}

return new \WP_Error('unsupported_workspace_repo_argument', sprintf('Repository URL "%s" does not match an existing local primary checkout. Use a registered primary handle or run workspace clone first.', $repo), array( 'status' => 404 ));
}

if ( $this->looks_like_path_argument($repo) ) {
return $this->resolve_primary_repo_name_from_path($repo);
}

if ( str_contains($repo, '/') || str_contains($repo, '\\') ) {
return new \WP_Error('unsupported_workspace_repo_argument', sprintf('Repository argument "%s" is not a primary workspace handle. Use the local primary handle, or pass a URL/path that matches an existing local primary checkout.', $repo), array( 'status' => 400 ));
}

$sanitized = $this->sanitize_name($repo);
if ( '' === $sanitized ) {
return new \WP_Error('invalid_repo', sprintf('Repository argument "%s" did not produce a valid workspace handle.', $repo), array( 'status' => 400 ));
}

return $sanitized;
}

/**
* Resolve a local path argument to an existing primary handle.
*
* @param string $path Local checkout path.
* @return string|\WP_Error Canonical primary handle or validation error.
*/
private function resolve_primary_repo_name_from_path( string $path ): string|\WP_Error {
$path = rtrim($path, '/');
$expanded_path = str_starts_with($path, '~/') ? rtrim( (string) getenv('HOME'), '/') . substr($path, 1) : $path;
$real_path = realpath($expanded_path);
$workspace = realpath($this->workspace_path);
$resolved_path = false !== $real_path ? $real_path : $expanded_path;

if ( false !== $workspace && str_starts_with(rtrim($resolved_path, '/') . '/', rtrim($workspace, '/') . '/') ) {
$name = basename($resolved_path);
if ( '' !== $name && ! str_contains($name, '@') && is_dir($resolved_path) && ( is_dir($resolved_path . '/.git') || is_file($resolved_path . '/.git') ) ) {
return $name;
}
}

$remote = ( is_dir($resolved_path) && ( is_dir($resolved_path . '/.git') || is_file($resolved_path . '/.git') ) ) ? $this->git_get_remote($resolved_path) : null;
if ( null !== $remote ) {
$existing = $this->find_primary_by_remote($remote);
if ( null !== $existing ) {
return $existing['name'];
}
}

return new \WP_Error('unsupported_workspace_repo_argument', sprintf('Repository path "%s" does not resolve to an existing local primary checkout. Use a registered primary handle or run workspace clone/adopt first.', $path), array( 'status' => 404 ));
}

/**
* Whether a repo argument looks like a git URL.
*/
private function looks_like_git_url( string $value ): bool {
return (bool) preg_match('#^(?:https?|ssh|git)://#i', $value) || (bool) preg_match('/^[^@\s]+@[^:\s]+:.+$/', $value);
}

/**
* Whether a repo argument looks like a filesystem path.
*/
private function looks_like_path_argument( string $value ): bool {
return str_starts_with($value, '/') || str_starts_with($value, './') || str_starts_with($value, '../') || str_starts_with($value, '~/');
}

/**
Expand Down
5 changes: 5 additions & 0 deletions inc/Workspace/WorkspaceRepositoryLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,11 @@ public function show_repo( string $handle ): array|\WP_Error {
$handle = $target;
}

$resolved_handle = $this->resolve_primary_repo_name($handle);
if ( ! is_wp_error($resolved_handle) ) {
$handle = $resolved_handle;
}

$parsed = $this->parse_handle($handle);
$repo_path = $this->workspace_path . '/' . $parsed['dir_name'];

Expand Down
20 changes: 13 additions & 7 deletions inc/Workspace/WorkspaceWorktreeCleanupEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -2280,19 +2280,25 @@ private function remove_worktree_by_path( string $repo, string $branch, string $
}

$cmd = sprintf('worktree remove %s%s', $force ? '--force ' : '', escapeshellarg($real_path));
$result = $this->run_git($primary_path, $cmd);
$result = $this->run_git($primary_path, $cmd, self::CLEANUP_GIT_PROBE_TIMEOUT);

if ( is_wp_error($result) ) {
return $result;
}

// If the directory survived `git worktree remove` (can happen for
// locked worktrees, or when the worktree was already detached), prune
// the directory manually so cleanup is effective.
// `git worktree remove` is the destructive boundary. If Git reports
// success but the path survives, fail the row instead of falling back to
// an unbounded recursive delete that can wedge cleanup apply.
if ( is_dir($real_path) ) {
$escaped = escapeshellarg($real_path);
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
exec(sprintf('rm -rf %s 2>&1', $escaped));
return new \WP_Error(
'worktree_remove_incomplete',
sprintf('Git reported worktree removal success, but the directory still exists: %s', $real_path),
array(
'status' => 500,
'path' => $real_path,
'primary_path' => $primary_path,
)
);
}

WorktreeContextInjector::forget_metadata(basename($wt_path));
Expand Down
5 changes: 4 additions & 1 deletion inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,11 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
return $visible;
}

$repo = $this->sanitize_name($repo);
$repo = $this->resolve_primary_repo_name($repo);
$branch = trim($branch);
if ( is_wp_error($repo) ) {
return $repo;
}

if ( '' === $repo ) {
return new \WP_Error('invalid_repo', 'Repository name is required.', array( 'status' => 400 ));
Expand Down
6 changes: 6 additions & 0 deletions tests/smoke-remote-workspace-backend.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ function update_option( string $key, mixed $value, bool $autoload = true ): bool
$assert('worktree add returns DMC handle', ! is_wp_error($worktree) && 'example@fix-example' === $worktree['handle']);
$assert('worktree add backend result omits model-facing guidance', ! is_wp_error($worktree) && ! array_key_exists('next_required_tool', $worktree) && ! array_key_exists('next_required_args', $worktree));

$url_worktree = $backend->worktree_add('https://github.com/chubes4/example.git', 'fix/url-argument');
$assert('worktree add rejects URL repo arguments in remote backend', is_wp_error($url_worktree) && 'unsupported_remote_workspace_repo_argument' === $url_worktree->get_error_code());

$path_worktree = $backend->worktree_add('/Users/chubes/Developer/example', 'fix/path-argument');
$assert('worktree add rejects path repo arguments in remote backend', is_wp_error($path_worktree) && 'unsupported_remote_workspace_repo_argument' === $path_worktree->get_error_code());

update_option(
'datamachine_code_workspace_aliases',
array(
Expand Down
Loading
Loading