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
13 changes: 13 additions & 0 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? '',
Expand All @@ -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();
}
Expand Down
14 changes: 14 additions & 0 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
58 changes: 58 additions & 0 deletions inc/Workspace/RemoteWorkspaceBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string,mixed>|\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<string,mixed>
*/
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.
*
Expand Down
57 changes: 51 additions & 6 deletions inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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;
Expand All @@ -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<int,string> $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.
Expand Down
8 changes: 8 additions & 0 deletions tests/smoke-remote-workspace-backend-filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions tests/smoke-remote-workspace-backend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down
112 changes: 112 additions & 0 deletions tests/smoke-worktree-prune-no-git.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php
/**
* Pure-PHP smoke for worktree prune when the workspace is visible but git is unavailable.
*
* Run: php tests/smoke-worktree-prune-no-git.php
*/

declare( strict_types=1 );

namespace DataMachine\Core\FilesRepository {
class FilesystemHelper
{
public static function get(): ?self
{
return null;
}
}
}

namespace {
$tmp = sys_get_temp_dir() . '/dmc-worktree-prune-no-git-' . getmypid();
if (! defined('ABSPATH') ) {
define('ABSPATH', $tmp . '/wp/');
}
if (! defined('DATAMACHINE_WORKSPACE_PATH') ) {
define('DATAMACHINE_WORKSPACE_PATH', $tmp . '/workspace');
}

if (! class_exists('WP_Error') ) {
class WP_Error
{
public function __construct( private string $code, private string $message, private array $data = array() )
{
}

public function get_error_code(): string
{
return $this->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);
}
Loading