Skip to content

Commit 712ee24

Browse files
authored
fix: handle no-git worktree cleanup (#609)
1 parent 7eafcb1 commit 712ee24

7 files changed

Lines changed: 274 additions & 6 deletions

inc/Abilities/WorkspaceAbilities.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3220,6 +3220,14 @@ private static function resolveCleanupAgentSlug( int $user_id = 0 ): string {
32203220
* @return array
32213221
*/
32223222
public static function worktreeRemove( array $input ): array|\WP_Error {
3223+
if ( RemoteWorkspaceBackend::has_registered_state() && RemoteWorkspaceBackend::should_handle() ) {
3224+
$result = ( new RemoteWorkspaceBackend() )->worktree_remove(
3225+
$input['repo'] ?? '',
3226+
$input['branch'] ?? ''
3227+
);
3228+
return self::decorate_remote_workspace_result('worktree_remove', $result);
3229+
}
3230+
32233231
$workspace = new Workspace();
32243232
return $workspace->worktree_remove(
32253233
$input['repo'] ?? '',
@@ -3235,6 +3243,11 @@ public static function worktreeRemove( array $input ): array|\WP_Error {
32353243
* @return array
32363244
*/
32373245
public static function worktreePrune( array $input ): array|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
3246+
if ( RemoteWorkspaceBackend::has_registered_state() && RemoteWorkspaceBackend::should_handle() ) {
3247+
$result = ( new RemoteWorkspaceBackend() )->worktree_prune();
3248+
return self::decorate_remote_workspace_result('worktree_prune', $result);
3249+
}
3250+
32383251
$workspace = new Workspace();
32393252
return $workspace->worktree_prune();
32403253
}

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3752,6 +3752,20 @@ static function ( array $lock ): array {
37523752

37533753
private function render_workspace_error( \WP_Error $error ): void {
37543754
$data = (array) $error->get_error_data();
3755+
if ( 'workspace_repo_busy' !== $error->get_error_code() && ! empty($data['next_commands']) && is_array($data['next_commands']) ) {
3756+
WP_CLI::warning($error->get_error_message());
3757+
WP_CLI::log('Next commands:');
3758+
foreach ( $data['next_commands'] as $command ) {
3759+
if ( is_scalar($command) && '' !== trim( (string) $command) ) {
3760+
WP_CLI::log(' ' . (string) $command);
3761+
}
3762+
}
3763+
if ( ! empty($data['hint']) ) {
3764+
WP_CLI::log('Hint: ' . (string) $data['hint']);
3765+
}
3766+
WP_CLI::error($error->get_error_message());
3767+
return;
3768+
}
37553769
if ( 'workspace_repo_busy' !== $error->get_error_code() ) {
37563770
WP_CLI::error($error->get_error_message());
37573771
return;

inc/Workspace/RemoteWorkspaceBackend.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,64 @@ public function worktree_add( string $repo_name, string $branch, ?string $from =
134134
);
135135
}
136136

137+
/**
138+
* Remove a registered remote worktree branch from local remote-workspace state.
139+
*
140+
* @return array<string,mixed>|\WP_Error
141+
*/
142+
public function worktree_remove( string $repo_name, string $branch ): array|\WP_Error {
143+
$repo_name = $this->resolve_alias($repo_name);
144+
$branch = trim($branch);
145+
if ( '' === $repo_name || '' === $branch ) {
146+
return new \WP_Error('remote_workspace_worktree_remove_missing_args', 'Repository and branch are required.', array( 'status' => 400 ));
147+
}
148+
149+
$handle = $repo_name . '@' . $this->branch_slug($branch);
150+
$state = $this->state();
151+
if ( ! isset($state['worktrees'][ $handle ]) ) {
152+
return new \WP_Error('remote_workspace_worktree_not_found', sprintf('Remote workspace worktree "%s" is not registered.', $handle), array( 'status' => 404 ));
153+
}
154+
155+
unset($state['worktrees'][ $handle ]);
156+
$this->save_state($state);
157+
158+
return array(
159+
'success' => true,
160+
'backend' => 'github_api',
161+
'handle' => $handle,
162+
'message' => sprintf('Remote workspace worktree "%s" removed from runtime state.', $handle),
163+
);
164+
}
165+
166+
/**
167+
* Prune remote worktree state whose primary repo registration disappeared.
168+
*
169+
* @return array<string,mixed>
170+
*/
171+
public function worktree_prune(): array {
172+
$state = $this->state();
173+
$pruned = array();
174+
foreach ( $state['worktrees'] as $handle => $worktree ) {
175+
$repo_name = is_array($worktree) ? (string) ( $worktree['repo_name'] ?? '' ) : '';
176+
if ( '' !== $repo_name && isset($state['repos'][ $repo_name ]) ) {
177+
continue;
178+
}
179+
180+
unset($state['worktrees'][ $handle ]);
181+
$pruned[] = (string) $handle;
182+
}
183+
184+
if ( array() !== $pruned ) {
185+
$this->save_state($state);
186+
}
187+
188+
return array(
189+
'success' => true,
190+
'backend' => 'github_api',
191+
'pruned' => $pruned,
192+
);
193+
}
194+
137195
/**
138196
* Read a file from GitHub or pending remote workspace state.
139197
*

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,7 +1058,13 @@ function () use ( $primary_path, $wt_path, $force, $wt_handle ) {
10581058
$result = $this->run_git($primary_path, $cmd);
10591059

10601060
if ( is_wp_error($result) ) {
1061-
return $result;
1061+
return $this->worktree_git_unavailable_with_host_commands(
1062+
$result,
1063+
'Remove workspace worktree',
1064+
array(
1065+
sprintf('git -C %s %s', escapeshellarg($primary_path), $cmd),
1066+
)
1067+
);
10621068
}
10631069

10641070
WorktreeContextInjector::forget_metadata($wt_handle);
@@ -1083,10 +1089,12 @@ function () use ( $primary_path, $wt_path, $force, $wt_handle ) {
10831089
/**
10841090
* Prune stale worktree registry entries across all primaries.
10851091
*
1086-
* @return array{success: bool, pruned: array}|\WP_Error
1092+
* @return array{success: bool, pruned: array, skipped?: array, next_commands?: array, inventory?: array}|\WP_Error
10871093
*/
10881094
public function worktree_prune(): array|\WP_Error {
1089-
$pruned = array();
1095+
$pruned = array();
1096+
$skipped = array();
1097+
$next_commands = array();
10901098

10911099
if ( ! is_dir($this->workspace_path) ) {
10921100
return array(
@@ -1110,6 +1118,15 @@ public function worktree_prune(): array|\WP_Error {
11101118
fn() => $this->run_git($primary_path, 'worktree prune -v')
11111119
);
11121120
if ( is_wp_error($result) ) {
1121+
if ( 'datamachine_workspace_git_unavailable' === $result->get_error_code() ) {
1122+
$skipped[] = array(
1123+
'repo' => $entry,
1124+
'primary_path' => $primary_path,
1125+
'reason' => $result->get_error_message(),
1126+
);
1127+
$next_commands[] = sprintf('git -C %s worktree prune -v', escapeshellarg($primary_path));
1128+
continue;
1129+
}
11131130
return $result;
11141131
}
11151132
$pruned[] = $entry;
@@ -1121,12 +1138,40 @@ public function worktree_prune(): array|\WP_Error {
11211138
}
11221139

11231140
return array(
1124-
'success' => true,
1125-
'pruned' => $pruned,
1126-
'inventory' => $refresh,
1141+
'success' => true,
1142+
'pruned' => $pruned,
1143+
'skipped' => $skipped,
1144+
'next_commands' => array_values(array_unique($next_commands)),
1145+
'inventory' => $refresh,
11271146
);
11281147
}
11291148

1149+
/**
1150+
* Attach host-shell remediation commands to local-git-unavailable worktree errors.
1151+
*
1152+
* @param \WP_Error $error Original git error.
1153+
* @param string $operation Human-readable operation.
1154+
* @param array<int,string> $next_commands Exact commands to run in a host shell.
1155+
* @return \WP_Error
1156+
*/
1157+
private function worktree_git_unavailable_with_host_commands( \WP_Error $error, string $operation, array $next_commands ): \WP_Error {
1158+
if ( 'datamachine_workspace_git_unavailable' !== $error->get_error_code() ) {
1159+
return $error;
1160+
}
1161+
1162+
$data = (array) $error->get_error_data();
1163+
$data['operation'] = $operation;
1164+
$data['next_commands'] = array_values(array_filter(array_map('strval', $next_commands)));
1165+
$data['hint'] = 'Run the listed command from a host shell with local git access, then rerun workspace worktree prune to refresh DMC inventory.';
1166+
1167+
$message = $error->get_error_message();
1168+
if ( ! empty($data['next_commands'][0]) ) {
1169+
$message .= ' Host command: ' . $data['next_commands'][0];
1170+
}
1171+
1172+
return new \WP_Error($error->get_error_code(), $message, $data);
1173+
}
1174+
11301175

11311176
/**
11321177
* Resolve a sensible default base for new branches.

tests/smoke-remote-workspace-backend-filter.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ class GitRunner
1212
{
1313
public static bool $available = true;
1414

15+
public static function diagnose(): array
16+
{
17+
return array(
18+
'git_available' => self::$available,
19+
'proc_open_available' => self::$available,
20+
);
21+
}
22+
1523
public static function is_available(): bool
1624
{
1725
return self::$available;

tests/smoke-remote-workspace-backend.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,24 @@ function update_option( string $key, mixed $value, bool $autoload = true ): bool
226226
$assert('push is successful compatibility no-op', ! is_wp_error($push) && 'fix/example' === $push['branch']);
227227
$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));
228228

229+
$second_worktree = $backend->worktree_add('example', 'fix/remove-me');
230+
$assert('second worktree add succeeds', ! is_wp_error($second_worktree) && 'example@fix-remove-me' === $second_worktree['handle']);
231+
232+
$remove = $backend->worktree_remove('example', 'fix/remove-me');
233+
$assert('worktree remove clears remote runtime state', ! is_wp_error($remove) && 'example@fix-remove-me' === $remove['handle']);
234+
$removed_status = $backend->git_status('example@fix-remove-me');
235+
$assert('removed worktree no longer resolves', is_wp_error($removed_status) && 'remote_workspace_repo_not_found' === $removed_status->get_error_code());
236+
237+
$state = $GLOBALS['dmc_remote_workspace_options']['datamachine_code_remote_workspace_state'];
238+
$state['worktrees']['missing@stale'] = array(
239+
'repo_name' => 'missing',
240+
'repo' => 'chubes4/missing',
241+
'branch' => 'stale',
242+
);
243+
$GLOBALS['dmc_remote_workspace_options']['datamachine_code_remote_workspace_state'] = $state;
244+
$prune = $backend->worktree_prune();
245+
$assert('worktree prune removes remote rows without primary repo state', ! is_wp_error($prune) && array( 'missing@stale' ) === $prune['pruned']);
246+
229247
if (! empty($failures) ) {
230248
echo "\nFAIL: " . count($failures) . " assertion(s) failed out of {$total}\n";
231249
foreach ( $failures as $failure ) {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
/**
3+
* Pure-PHP smoke for worktree prune when the workspace is visible but git is unavailable.
4+
*
5+
* Run: php tests/smoke-worktree-prune-no-git.php
6+
*/
7+
8+
declare( strict_types=1 );
9+
10+
namespace DataMachine\Core\FilesRepository {
11+
class FilesystemHelper
12+
{
13+
public static function get(): ?self
14+
{
15+
return null;
16+
}
17+
}
18+
}
19+
20+
namespace {
21+
$tmp = sys_get_temp_dir() . '/dmc-worktree-prune-no-git-' . getmypid();
22+
if (! defined('ABSPATH') ) {
23+
define('ABSPATH', $tmp . '/wp/');
24+
}
25+
if (! defined('DATAMACHINE_WORKSPACE_PATH') ) {
26+
define('DATAMACHINE_WORKSPACE_PATH', $tmp . '/workspace');
27+
}
28+
29+
if (! class_exists('WP_Error') ) {
30+
class WP_Error
31+
{
32+
public function __construct( private string $code, private string $message, private array $data = array() )
33+
{
34+
}
35+
36+
public function get_error_code(): string
37+
{
38+
return $this->code;
39+
}
40+
41+
public function get_error_message(): string
42+
{
43+
return $this->message;
44+
}
45+
46+
public function get_error_data(): array
47+
{
48+
return $this->data;
49+
}
50+
}
51+
}
52+
53+
if (! function_exists('is_wp_error') ) {
54+
function is_wp_error( $value ): bool
55+
{
56+
return $value instanceof WP_Error;
57+
}
58+
}
59+
60+
$failures = array();
61+
$total = 0;
62+
$assert = function ( string $label, bool $condition ) use ( &$failures, &$total ): void {
63+
++$total;
64+
if ($condition ) {
65+
echo " ok {$label}\n";
66+
return;
67+
}
68+
69+
$failures[] = $label;
70+
echo " fail {$label}\n";
71+
};
72+
73+
$old_path = getenv('PATH');
74+
putenv('PATH=/nonexistent-dmc-no-git');
75+
76+
mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo/.git', 0777, true);
77+
78+
require __DIR__ . '/../inc/Support/RuntimeCapabilities.php';
79+
require __DIR__ . '/../inc/Support/ProcessRunner.php';
80+
require __DIR__ . '/../inc/Support/GitRunner.php';
81+
require __DIR__ . '/../inc/Support/PathSecurity.php';
82+
require __DIR__ . '/../inc/Workspace/WorktreeContextInjector.php';
83+
require __DIR__ . '/../inc/Workspace/WorkspaceMutationLock.php';
84+
require __DIR__ . '/../inc/Workspace/Workspace.php';
85+
86+
echo "Worktree prune without git - smoke\n";
87+
88+
$workspace = new DataMachineCode\Workspace\Workspace();
89+
$result = $workspace->worktree_prune();
90+
91+
$assert('prune returns success instead of git-unavailable error', ! is_wp_error($result) && true === ( $result['success'] ?? false ));
92+
$assert('prune records skipped primary', ! is_wp_error($result) && 'demo' === ( $result['skipped'][0]['repo'] ?? '' ));
93+
$assert('prune returns host git command', ! is_wp_error($result) && str_contains((string) ( $result['next_commands'][0] ?? '' ), 'git -C'));
94+
$assert('inventory refresh still runs', ! is_wp_error($result) && isset($result['inventory']['summary']));
95+
96+
putenv(false === $old_path ? 'PATH' : 'PATH=' . $old_path);
97+
98+
if (is_dir($tmp) ) {
99+
exec('rm -rf ' . escapeshellarg($tmp));
100+
}
101+
102+
if (! empty($failures) ) {
103+
echo "\nFAIL: " . count($failures) . " assertion(s) failed out of {$total}\n";
104+
foreach ( $failures as $failure ) {
105+
echo " - {$failure}\n";
106+
}
107+
exit(1);
108+
}
109+
110+
echo "\nOK ({$total} assertions)\n";
111+
exit(0);
112+
}

0 commit comments

Comments
 (0)