@@ -1114,11 +1114,12 @@ function () use ( $primary_path, $wt_path, $force, $wt_handle ) {
11141114 * @return array{success: bool, pruned: array, skipped?: array, next_commands?: array, inventory?: array, stale_inventory?: array, stale_marker_blockers?: array}|\WP_Error
11151115 */
11161116 public function worktree_prune (): array |\WP_Error {
1117- $ pruned = array ();
1118- $ skipped = array ();
1119- $ next_commands = array ();
1120- $ stale_rows = array ();
1121- $ marker_blocks = array ();
1117+ $ pruned = array ();
1118+ $ skipped = array ();
1119+ $ next_commands = array ();
1120+ $ stale_rows = array ();
1121+ $ marker_blocks = array ();
1122+ $ marker_repaired = array ();
11221123
11231124 if ( ! is_dir ($ this ->workspace_path ) ) {
11241125 return array (
@@ -1166,8 +1167,9 @@ public function worktree_prune(): array|\WP_Error {
11661167 return $ inventory_diagnostics ;
11671168 }
11681169
1169- $ stale_rows = (array ) ( $ inventory_diagnostics ['stale_inventory ' ] ?? array () );
1170- $ marker_blocks = (array ) ( $ inventory_diagnostics ['stale_marker_blockers ' ] ?? array () );
1170+ $ stale_rows = (array ) ( $ inventory_diagnostics ['stale_inventory ' ] ?? array () );
1171+ $ marker_blocks = (array ) ( $ inventory_diagnostics ['stale_marker_blockers ' ] ?? array () );
1172+ $ marker_repaired = (array ) ( $ inventory_diagnostics ['stale_marker_repaired ' ] ?? array () );
11711173 foreach ( (array ) ( $ inventory_diagnostics ['next_commands ' ] ?? array () ) as $ command ) {
11721174 $ next_commands [] = (string ) $ command ;
11731175 }
@@ -1180,6 +1182,7 @@ public function worktree_prune(): array|\WP_Error {
11801182 'inventory ' => $ refresh ,
11811183 'stale_inventory ' => $ stale_rows ,
11821184 'stale_marker_blockers ' => $ marker_blocks ,
1185+ 'stale_marker_repaired ' => $ marker_repaired ,
11831186 );
11841187 }
11851188
@@ -1190,14 +1193,16 @@ public function worktree_prune(): array|\WP_Error {
11901193 * cleanup-eligible inventory rows for worktrees that no longer exist, and
11911194 * those rows can block bounded cleanup even after Git reports nothing to
11921195 * prune. Missing-path rows are safe to forget because no checkout remains on
1193- * disk; path-present stale markers are reported but left in place.
1196+ * disk; path-present stale markers are removed only when the inventory row has
1197+ * a cleanup signal and exactly matches the expected workspace worktree path.
11941198 *
11951199 * @return array<string,mixed>|\WP_Error
11961200 */
11971201 private function prune_stale_worktree_inventory_rows (): array |\WP_Error {
11981202 $ repository = $ this ->worktree_inventory ();
11991203 $ stale_inventory = array ();
12001204 $ stale_marker_blockers = array ();
1205+ $ stale_marker_repaired = array ();
12011206 $ next_commands = array ();
12021207
12031208 foreach ( $ repository ->list () as $ row ) {
@@ -1242,29 +1247,103 @@ private function prune_stale_worktree_inventory_rows(): array|\WP_Error {
12421247 continue ;
12431248 }
12441249
1245- $ primary_path = '' !== $ repo ? $ this ->get_primary_path ($ repo ) : (string ) ( $ row ['primary_path ' ] ?? '' );
1250+ $ primary_path = '' !== $ repo ? $ this ->get_primary_path ($ repo ) : (string ) ( $ row ['primary_path ' ] ?? '' );
1251+ $ repair = $ this ->repair_cleanup_eligible_stale_worktree_marker ($ row , $ parsed , $ gitdir , $ primary_path );
1252+ if ( $ repair instanceof \WP_Error ) {
1253+ return $ repair ;
1254+ }
1255+
1256+ if ( null !== $ repair ) {
1257+ $ stale_marker_repaired [] = $ repair ;
1258+ continue ;
1259+ }
1260+
1261+ $ remove_command = sprintf ('studio wp datamachine-code workspace remove %s --yes ' , escapeshellarg ($ handle ));
12461262 $ stale_marker_blockers [] = array (
12471263 'handle ' => $ handle ,
12481264 'repo ' => $ repo ,
12491265 'path ' => $ path ,
12501266 'primary_path ' => $ primary_path ,
12511267 'gitdir ' => $ gitdir ,
12521268 'reason_code ' => 'stale_worktree_marker ' ,
1253- 'reason ' => 'worktree path still exists, but its .git marker points at a missing primary .git/worktrees entry; leaving checkout in place for operator review ' ,
1254- 'hint ' => 'Inspect the path before removal. If it is only a stale artifact, use a reviewed cleanup-artifacts apply-plan or remove the worktree through DMC once metadata is repaired. ' ,
1269+ 'reason ' => 'worktree path still exists, but its .git marker points at a missing primary .git/worktrees entry; leaving checkout in place because the row is not an exact cleanup-eligible stale marker candidate ' ,
1270+ 'hint ' => 'Inspect the path before removal. If it is safe to discard, run the DMC-owned remove command returned in next_command. ' ,
1271+ 'next_command ' => $ remove_command ,
12551272 );
1256- if ( '' !== $ primary_path ) {
1257- $ next_commands [] = sprintf ('git -C %s worktree prune -v ' , escapeshellarg ($ primary_path ));
1258- }
1273+ $ next_commands [] = $ remove_command ;
12591274 }
12601275
12611276 return array (
12621277 'stale_inventory ' => $ stale_inventory ,
12631278 'stale_marker_blockers ' => $ stale_marker_blockers ,
1279+ 'stale_marker_repaired ' => $ stale_marker_repaired ,
12641280 'next_commands ' => array_values (array_unique ($ next_commands )),
12651281 );
12661282 }
12671283
1284+ /**
1285+ * Remove an exact cleanup-eligible stale marker worktree path from DMC-owned state.
1286+ *
1287+ * @param array<string,mixed> $row Inventory row.
1288+ * @param array<string,mixed> $parsed Parsed worktree handle.
1289+ * @param string $gitdir Missing gitdir from the stale marker.
1290+ * @param string $primary_path Primary checkout path.
1291+ * @return array<string,mixed>|null|\WP_Error
1292+ */
1293+ private function repair_cleanup_eligible_stale_worktree_marker ( array $ row , array $ parsed , string $ gitdir , string $ primary_path ): array |null |\WP_Error {
1294+ $ handle = (string ) ( $ row ['handle ' ] ?? '' );
1295+ $ repo = (string ) ( $ row ['repo ' ] ?? '' );
1296+ $ path = rtrim ( (string ) ( $ row ['path ' ] ?? '' ), '/ ' );
1297+ $ metadata = is_array ($ row ['metadata ' ] ?? null ) ? $ row ['metadata ' ] : array ();
1298+ if ( empty ($ metadata ) && ! empty ($ row ['lifecycle_state ' ]) ) {
1299+ $ metadata ['lifecycle_state ' ] = (string ) $ row ['lifecycle_state ' ];
1300+ }
1301+ if ( empty ($ metadata ) && 'cleanup_eligible ' === (string ) ( $ row ['cleanup_signal ' ] ?? '' ) ) {
1302+ $ metadata ['lifecycle_state ' ] = 'cleanup_eligible ' ;
1303+ }
1304+
1305+ if ( '' === $ handle || '' === $ path || empty ($ parsed ['is_worktree ' ]) || ! WorktreeContextInjector::has_cleanup_signal ($ metadata ) ) {
1306+ return null ;
1307+ }
1308+
1309+ $ expected_path = rtrim ($ this ->workspace_path , '/ ' ) . '/ ' . (string ) ( $ parsed ['dir_name ' ] ?? $ handle );
1310+ if ( $ path !== $ expected_path ) {
1311+ return null ;
1312+ }
1313+
1314+ $ validation = $ this ->validate_containment ($ path , $ this ->workspace_path );
1315+ $ expected_real = realpath ($ expected_path );
1316+ if ( empty ($ validation ['valid ' ]) || false === $ expected_real || (string ) ( $ validation ['real_path ' ] ?? '' ) !== $ expected_real ) {
1317+ return null ;
1318+ }
1319+
1320+ $ removed_paths = $ this ->remove_directory_recursive ($ path , $ this ->workspace_path );
1321+ if ( $ removed_paths instanceof \WP_Error ) {
1322+ return $ removed_paths ;
1323+ }
1324+
1325+ WorktreeContextInjector::forget_metadata ($ handle );
1326+ $ this ->worktree_inventory ()->delete ($ handle );
1327+ if ( '' !== $ primary_path && is_dir ($ primary_path . '/.git ' ) ) {
1328+ WorkspaceMutationLock::with_repo (
1329+ $ this ->workspace_path ,
1330+ $ repo ,
1331+ fn () => $ this ->run_git ($ primary_path , 'worktree prune ' )
1332+ );
1333+ }
1334+
1335+ return array (
1336+ 'handle ' => $ handle ,
1337+ 'repo ' => $ repo ,
1338+ 'path ' => $ path ,
1339+ 'primary_path ' => $ primary_path ,
1340+ 'gitdir ' => $ gitdir ,
1341+ 'reason_code ' => 'stale_worktree_marker_repaired ' ,
1342+ 'reason ' => 'cleanup-eligible worktree path exactly matched a stale .git marker row and was removed from DMC workspace state ' ,
1343+ 'removed_paths ' => $ removed_paths ,
1344+ );
1345+ }
1346+
12681347 /**
12691348 * Attach host-shell remediation commands to local-git-unavailable worktree errors.
12701349 *
0 commit comments