diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 7e00729..cad8648 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -3220,6 +3220,14 @@ private static function resolveCleanupAgentSlug( int $user_id = 0 ): string { * @return array */ public static function worktreeRemove( array $input ): array|\WP_Error { + if ( RemoteWorkspaceBackend::has_registered_state() && RemoteWorkspaceBackend::should_handle() ) { + $result = ( new RemoteWorkspaceBackend() )->worktree_remove( + $input['repo'] ?? '', + $input['branch'] ?? '' + ); + return self::decorate_remote_workspace_result('worktree_remove', $result); + } + $workspace = new Workspace(); return $workspace->worktree_remove( $input['repo'] ?? '', @@ -3235,6 +3243,11 @@ public static function worktreeRemove( array $input ): array|\WP_Error { * @return array */ public static function worktreePrune( array $input ): array|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + if ( RemoteWorkspaceBackend::has_registered_state() && RemoteWorkspaceBackend::should_handle() ) { + $result = ( new RemoteWorkspaceBackend() )->worktree_prune(); + return self::decorate_remote_workspace_result('worktree_prune', $result); + } + $workspace = new Workspace(); return $workspace->worktree_prune(); } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index b006dd9..2504450 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -3752,6 +3752,20 @@ static function ( array $lock ): array { private function render_workspace_error( \WP_Error $error ): void { $data = (array) $error->get_error_data(); + if ( 'workspace_repo_busy' !== $error->get_error_code() && ! empty($data['next_commands']) && is_array($data['next_commands']) ) { + WP_CLI::warning($error->get_error_message()); + WP_CLI::log('Next commands:'); + foreach ( $data['next_commands'] as $command ) { + if ( is_scalar($command) && '' !== trim( (string) $command) ) { + WP_CLI::log(' ' . (string) $command); + } + } + if ( ! empty($data['hint']) ) { + WP_CLI::log('Hint: ' . (string) $data['hint']); + } + WP_CLI::error($error->get_error_message()); + return; + } if ( 'workspace_repo_busy' !== $error->get_error_code() ) { WP_CLI::error($error->get_error_message()); return; diff --git a/inc/Workspace/RemoteWorkspaceBackend.php b/inc/Workspace/RemoteWorkspaceBackend.php index 3c98424..7bdc025 100644 --- a/inc/Workspace/RemoteWorkspaceBackend.php +++ b/inc/Workspace/RemoteWorkspaceBackend.php @@ -134,6 +134,64 @@ public function worktree_add( string $repo_name, string $branch, ?string $from = ); } + /** + * Remove a registered remote worktree branch from local remote-workspace state. + * + * @return array|\WP_Error + */ + public function worktree_remove( string $repo_name, string $branch ): array|\WP_Error { + $repo_name = $this->resolve_alias($repo_name); + $branch = trim($branch); + if ( '' === $repo_name || '' === $branch ) { + return new \WP_Error('remote_workspace_worktree_remove_missing_args', 'Repository and branch are required.', array( 'status' => 400 )); + } + + $handle = $repo_name . '@' . $this->branch_slug($branch); + $state = $this->state(); + if ( ! isset($state['worktrees'][ $handle ]) ) { + return new \WP_Error('remote_workspace_worktree_not_found', sprintf('Remote workspace worktree "%s" is not registered.', $handle), array( 'status' => 404 )); + } + + unset($state['worktrees'][ $handle ]); + $this->save_state($state); + + return array( + 'success' => true, + 'backend' => 'github_api', + 'handle' => $handle, + 'message' => sprintf('Remote workspace worktree "%s" removed from runtime state.', $handle), + ); + } + + /** + * Prune remote worktree state whose primary repo registration disappeared. + * + * @return array + */ + public function worktree_prune(): array { + $state = $this->state(); + $pruned = array(); + foreach ( $state['worktrees'] as $handle => $worktree ) { + $repo_name = is_array($worktree) ? (string) ( $worktree['repo_name'] ?? '' ) : ''; + if ( '' !== $repo_name && isset($state['repos'][ $repo_name ]) ) { + continue; + } + + unset($state['worktrees'][ $handle ]); + $pruned[] = (string) $handle; + } + + if ( array() !== $pruned ) { + $this->save_state($state); + } + + return array( + 'success' => true, + 'backend' => 'github_api', + 'pruned' => $pruned, + ); + } + /** * Read a file from GitHub or pending remote workspace state. * diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 731b4d5..0814bbf 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -1058,7 +1058,13 @@ function () use ( $primary_path, $wt_path, $force, $wt_handle ) { $result = $this->run_git($primary_path, $cmd); if ( is_wp_error($result) ) { - return $result; + return $this->worktree_git_unavailable_with_host_commands( + $result, + 'Remove workspace worktree', + array( + sprintf('git -C %s %s', escapeshellarg($primary_path), $cmd), + ) + ); } WorktreeContextInjector::forget_metadata($wt_handle); @@ -1083,10 +1089,12 @@ function () use ( $primary_path, $wt_path, $force, $wt_handle ) { /** * Prune stale worktree registry entries across all primaries. * - * @return array{success: bool, pruned: array}|\WP_Error + * @return array{success: bool, pruned: array, skipped?: array, next_commands?: array, inventory?: array}|\WP_Error */ public function worktree_prune(): array|\WP_Error { - $pruned = array(); + $pruned = array(); + $skipped = array(); + $next_commands = array(); if ( ! is_dir($this->workspace_path) ) { return array( @@ -1110,6 +1118,15 @@ public function worktree_prune(): array|\WP_Error { fn() => $this->run_git($primary_path, 'worktree prune -v') ); if ( is_wp_error($result) ) { + if ( 'datamachine_workspace_git_unavailable' === $result->get_error_code() ) { + $skipped[] = array( + 'repo' => $entry, + 'primary_path' => $primary_path, + 'reason' => $result->get_error_message(), + ); + $next_commands[] = sprintf('git -C %s worktree prune -v', escapeshellarg($primary_path)); + continue; + } return $result; } $pruned[] = $entry; @@ -1121,12 +1138,40 @@ public function worktree_prune(): array|\WP_Error { } return array( - 'success' => true, - 'pruned' => $pruned, - 'inventory' => $refresh, + 'success' => true, + 'pruned' => $pruned, + 'skipped' => $skipped, + 'next_commands' => array_values(array_unique($next_commands)), + 'inventory' => $refresh, ); } + /** + * Attach host-shell remediation commands to local-git-unavailable worktree errors. + * + * @param \WP_Error $error Original git error. + * @param string $operation Human-readable operation. + * @param array $next_commands Exact commands to run in a host shell. + * @return \WP_Error + */ + private function worktree_git_unavailable_with_host_commands( \WP_Error $error, string $operation, array $next_commands ): \WP_Error { + if ( 'datamachine_workspace_git_unavailable' !== $error->get_error_code() ) { + return $error; + } + + $data = (array) $error->get_error_data(); + $data['operation'] = $operation; + $data['next_commands'] = array_values(array_filter(array_map('strval', $next_commands))); + $data['hint'] = 'Run the listed command from a host shell with local git access, then rerun workspace worktree prune to refresh DMC inventory.'; + + $message = $error->get_error_message(); + if ( ! empty($data['next_commands'][0]) ) { + $message .= ' Host command: ' . $data['next_commands'][0]; + } + + return new \WP_Error($error->get_error_code(), $message, $data); + } + /** * Resolve a sensible default base for new branches. diff --git a/tests/smoke-remote-workspace-backend-filter.php b/tests/smoke-remote-workspace-backend-filter.php index 68681e6..18eab48 100644 --- a/tests/smoke-remote-workspace-backend-filter.php +++ b/tests/smoke-remote-workspace-backend-filter.php @@ -12,6 +12,14 @@ class GitRunner { public static bool $available = true; + public static function diagnose(): array + { + return array( + 'git_available' => self::$available, + 'proc_open_available' => self::$available, + ); + } + public static function is_available(): bool { return self::$available; diff --git a/tests/smoke-remote-workspace-backend.php b/tests/smoke-remote-workspace-backend.php index cb741f7..f6c837d 100644 --- a/tests/smoke-remote-workspace-backend.php +++ b/tests/smoke-remote-workspace-backend.php @@ -226,6 +226,24 @@ function update_option( string $key, mixed $value, bool $autoload = true ): bool $assert('push is successful compatibility no-op', ! is_wp_error($push) && 'fix/example' === $push['branch']); $assert('push backend result omits model-facing guidance', ! is_wp_error($push) && ! array_key_exists('next_required_tool', $push) && ! array_key_exists('next_required_args', $push)); + $second_worktree = $backend->worktree_add('example', 'fix/remove-me'); + $assert('second worktree add succeeds', ! is_wp_error($second_worktree) && 'example@fix-remove-me' === $second_worktree['handle']); + + $remove = $backend->worktree_remove('example', 'fix/remove-me'); + $assert('worktree remove clears remote runtime state', ! is_wp_error($remove) && 'example@fix-remove-me' === $remove['handle']); + $removed_status = $backend->git_status('example@fix-remove-me'); + $assert('removed worktree no longer resolves', is_wp_error($removed_status) && 'remote_workspace_repo_not_found' === $removed_status->get_error_code()); + + $state = $GLOBALS['dmc_remote_workspace_options']['datamachine_code_remote_workspace_state']; + $state['worktrees']['missing@stale'] = array( + 'repo_name' => 'missing', + 'repo' => 'chubes4/missing', + 'branch' => 'stale', + ); + $GLOBALS['dmc_remote_workspace_options']['datamachine_code_remote_workspace_state'] = $state; + $prune = $backend->worktree_prune(); + $assert('worktree prune removes remote rows without primary repo state', ! is_wp_error($prune) && array( 'missing@stale' ) === $prune['pruned']); + if (! empty($failures) ) { echo "\nFAIL: " . count($failures) . " assertion(s) failed out of {$total}\n"; foreach ( $failures as $failure ) { diff --git a/tests/smoke-worktree-prune-no-git.php b/tests/smoke-worktree-prune-no-git.php new file mode 100644 index 0000000..9384694 --- /dev/null +++ b/tests/smoke-worktree-prune-no-git.php @@ -0,0 +1,112 @@ +code; + } + + public function get_error_message(): string + { + return $this->message; + } + + public function get_error_data(): array + { + return $this->data; + } + } + } + + if (! function_exists('is_wp_error') ) { + function is_wp_error( $value ): bool + { + return $value instanceof WP_Error; + } + } + + $failures = array(); + $total = 0; + $assert = function ( string $label, bool $condition ) use ( &$failures, &$total ): void { + ++$total; + if ($condition ) { + echo " ok {$label}\n"; + return; + } + + $failures[] = $label; + echo " fail {$label}\n"; + }; + + $old_path = getenv('PATH'); + putenv('PATH=/nonexistent-dmc-no-git'); + + mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo/.git', 0777, true); + + require __DIR__ . '/../inc/Support/RuntimeCapabilities.php'; + require __DIR__ . '/../inc/Support/ProcessRunner.php'; + require __DIR__ . '/../inc/Support/GitRunner.php'; + require __DIR__ . '/../inc/Support/PathSecurity.php'; + require __DIR__ . '/../inc/Workspace/WorktreeContextInjector.php'; + require __DIR__ . '/../inc/Workspace/WorkspaceMutationLock.php'; + require __DIR__ . '/../inc/Workspace/Workspace.php'; + + echo "Worktree prune without git - smoke\n"; + + $workspace = new DataMachineCode\Workspace\Workspace(); + $result = $workspace->worktree_prune(); + + $assert('prune returns success instead of git-unavailable error', ! is_wp_error($result) && true === ( $result['success'] ?? false )); + $assert('prune records skipped primary', ! is_wp_error($result) && 'demo' === ( $result['skipped'][0]['repo'] ?? '' )); + $assert('prune returns host git command', ! is_wp_error($result) && str_contains((string) ( $result['next_commands'][0] ?? '' ), 'git -C')); + $assert('inventory refresh still runs', ! is_wp_error($result) && isset($result['inventory']['summary'])); + + putenv(false === $old_path ? 'PATH' : 'PATH=' . $old_path); + + if (is_dir($tmp) ) { + exec('rm -rf ' . escapeshellarg($tmp)); + } + + if (! empty($failures) ) { + echo "\nFAIL: " . count($failures) . " assertion(s) failed out of {$total}\n"; + foreach ( $failures as $failure ) { + echo " - {$failure}\n"; + } + exit(1); + } + + echo "\nOK ({$total} assertions)\n"; + exit(0); +}