This URL is private - only people with the link can access it.
Use critique unpublish <url> to delete.
This page will expire in 7 days. Get unlimited links: https://critique.work/buy
├── inc
│ ├── Abilities
│ │ └── WorkspaceAbilities.php (+13)
│ ├── Cli/Commands
│ │ └── WorkspaceCommand.php (+14)
│ └── Workspace
│ ├── RemoteWorkspaceBackend.php (+58)
│ └── WorkspaceWorktreeLifecycle.php (+51,-6)
└── tests
├── smoke-remote-workspace-backend-filter.php (+8)
├── smoke-remote-workspace-backend.php (+18)
└── smoke-worktree-prune-no-git.php (+112)
inc/Abilities/WorkspaceAbilities.php +13-0
3217 * Remove a worktree.
3218 *
3219 * @param array $input Input parameters with 'repo', 'branch', optional 'force'.
3220 * @return array
3221 */
3222 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 +
3231 $workspace = new Workspace();
3232 return $workspace->worktree_remove(
3233 $input['repo'] ?? '',
3234 $input['branch'] ?? '',
3235 ! empty($input['force'])
3236 );
3240 * Prune stale worktree registry entries.
3241 *
3242 * @param array $input Unused.
3243 * @return array
3244 */
3245 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 +
3251 $workspace = new Workspace();
3252 return $workspace->worktree_prune();
3253 }
3254
3255 /**
3256 * Remove merged worktrees across all primary checkouts. */
inc/Cli/Commands/WorkspaceCommand.php +14-0
3749 WP_CLI::log( (string) ( $guidance['safety'] ?? 'Active filesystem flocks are not pruned.' ) );
3750 }
3751 }
3752
3753 private function render_workspace_error( \WP_Error $error ): void {
3754 $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 + }
3769 if ( 'workspace_repo_busy' !== $error->get_error_code() ) {
3770 WP_CLI::error($error->get_error_message());
3771 return;
3772 }
3773
3774 $lock = is_array($data['active_lock'] ?? null) ? (array) $data['active_lock'] : array();
inc/Workspace/RemoteWorkspaceBackend.php +58-0
131 'slug' => $slug,
132 'created_branch' => true,
133 'message' => sprintf('Registered remote workspace %s for %s.', $handle, $repo),
134 );
135 }
136
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 +
195 /**
196 * Read a file from GitHub or pending remote workspace state.
197 *
198 * @return array<string,mixed>|\WP_Error
199 */
200 public function read_file( string $handle, string $path, int $max_size, ?int $offset = null, ?int $limit = null ): array|\WP_Error {
inc/Workspace/WorkspaceWorktreeLifecycle.php +51-6
1055 $repo, 1055 $repo,
1056 function () use ( $primary_path, $wt_path, $force, $wt_handle ) { 1056 function () use ( $primary_path, $wt_path, $force, $wt_handle ) {
1057 $cmd = sprintf('worktree remove %s%s', $force ? '--force ' : '', escapeshellarg($wt_path)); 1057 $cmd = sprintf('worktree remove %s%s', $force ? '--force ' : '', escapeshellarg($wt_path));
1058 $result = $this->run_git($primary_path, $cmd); 1058 $result = $this->run_git($primary_path, $cmd);
1059 1059
1060 if ( is_wp_error($result) ) { 1060 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 + );
1062 } 1068 }
1063 1069
1064 WorktreeContextInjector::forget_metadata($wt_handle); 1070 WorktreeContextInjector::forget_metadata($wt_handle);
1065 $this->worktree_inventory()->delete($wt_handle); 1071 $this->worktree_inventory()->delete($wt_handle);
1066 return $result; 1072 return $result;
1067 } 1073 }
1080 ); 1086 );
1081 } 1087 }
1082 1088
1083 /** 1089 /**
1084 * Prune stale worktree registry entries across all primaries. 1090 * Prune stale worktree registry entries across all primaries.
1085 * 1091 *
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
1087 */ 1093 */
1088 public function worktree_prune(): array|\WP_Error { 1094 public function worktree_prune(): array|\WP_Error {
1089 - $pruned = array(); 1095 + $pruned = array();
1096 + $skipped = array();
1097 + $next_commands = array();
1090 1098
1091 if ( ! is_dir($this->workspace_path) ) { 1099 if ( ! is_dir($this->workspace_path) ) {
1092 return array( 1100 return array(
1093 'success' => true, 1101 'success' => true,
1094 'pruned' => $pruned, 1102 'pruned' => $pruned,
1095 ); 1103 );
1107 $result = WorkspaceMutationLock::with_repo( 1115 $result = WorkspaceMutationLock::with_repo(
1108 $this->workspace_path, 1116 $this->workspace_path,
1109 $entry, 1117 $entry,
1110 fn() => $this->run_git($primary_path, 'worktree prune -v') 1118 fn() => $this->run_git($primary_path, 'worktree prune -v')
1111 ); 1119 );
1112 if ( is_wp_error($result) ) { 1120 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 + }
1113 return $result; 1130 return $result;
1114 } 1131 }
1115 $pruned[] = $entry; 1132 $pruned[] = $entry;
1116 } 1133 }
1117 1134
1118 $refresh = $this->worktree_inventory_refresh(); 1135 $refresh = $this->worktree_inventory_refresh();
1119 if ( $refresh instanceof \WP_Error ) { 1136 if ( $refresh instanceof \WP_Error ) {
1120 return $refresh; 1137 return $refresh;
1121 } 1138 }
1122 1139
1123 return array( 1140 return array(
1124 - 'success' => true, 1141 + 'success' => true,
1125 - 'pruned' => $pruned, 1142 + 'pruned' => $pruned,
1126 - 'inventory' => $refresh, 1143 + 'skipped' => $skipped,
1144 + 'next_commands' => array_values(array_unique($next_commands)),
1145 + 'inventory' => $refresh,
1127 ); 1146 );
1128 } 1147 }
1129 1148
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 +
1130 1175
1131 /** 1176 /**
1132 * Resolve a sensible default base for new branches. 1177 * Resolve a sensible default base for new branches.
1133 * 1178 *
1134 * Prefers `origin/HEAD` (typically `origin/main` or `origin/trunk`); falls 1179 * Prefers `origin/HEAD` (typically `origin/main` or `origin/trunk`); falls
1135 * back to plain `HEAD` if no remote default is configured. */ 1180 * back to plain `HEAD` if no remote default is configured. */
tests/smoke-remote-workspace-backend-filter.php +8-0
9
10 namespace DataMachineCode\Support {
11 class GitRunner
12 {
13 public static bool $available = true;
14
15 + public static function diagnose(): array
16 + {
17 + return array(
18 + 'git_available' => self::$available,
19 + 'proc_open_available' => self::$available,
20 + );
21 + }
22 +
23 public static function is_available(): bool
24 {
25 return self::$available;
26 }
27 }
28 }
tests/smoke-remote-workspace-backend.php +18-0
223 $assert('commit supplies current file sha', 'file-sha-main-example' === GitHubAbilities::$commits[0]['input_sha']);
224
225 $push = $backend->git_push('example@fix-example');
226 $assert('push is successful compatibility no-op', ! is_wp_error($push) && 'fix/example' === $push['branch']);
227 $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));
228
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 +
247 if (! empty($failures) ) {
248 echo "\nFAIL: " . count($failures) . " assertion(s) failed out of {$total}\n";
249 foreach ( $failures as $failure ) {
250 echo " - {$failure}\n";
251 }
252 exit(1);
tests/smoke-worktree-prune-no-git.php +112-0
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 + }