Skip to content

Commit 4d8bf60

Browse files
fix: repair cleanup-eligible stale worktree markers (#740)
* fix: repair cleanup-eligible stale worktree markers * fix: align stale marker prune assignments --------- Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 2519ac3 commit 4d8bf60

2 files changed

Lines changed: 113 additions & 14 deletions

File tree

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*

tests/smoke-worktree-prune-no-git.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,9 @@ function is_wp_error( $value ): bool
179179

180180
mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo/.git', 0777, true);
181181
mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker', 0777, true);
182+
mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo@cleanup-stale-marker', 0777, true);
182183
file_put_contents(DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker/.git', 'gitdir: ' . DATAMACHINE_WORKSPACE_PATH . '/demo/.git/worktrees/demo@stale-marker' . "\n");
184+
file_put_contents(DATAMACHINE_WORKSPACE_PATH . '/demo@cleanup-stale-marker/.git', 'gitdir: ' . DATAMACHINE_WORKSPACE_PATH . '/demo/.git/worktrees/demo@cleanup-stale-marker' . "\n");
183185

184186
$GLOBALS['wpdb'] = new DatamachineCodePruneFakeWpdb();
185187
$GLOBALS['wpdb']->rows['demo@missing-path'] = array(
@@ -206,6 +208,22 @@ function is_wp_error( $value ): bool
206208
'metadata' => array(),
207209
'updated_at' => '2026-06-13 00:00:00',
208210
);
211+
$GLOBALS['wpdb']->rows['demo@cleanup-stale-marker'] = array(
212+
'handle' => 'demo@cleanup-stale-marker',
213+
'repo' => 'demo',
214+
'branch' => 'cleanup-stale-marker',
215+
'path' => DATAMACHINE_WORKSPACE_PATH . '/demo@cleanup-stale-marker',
216+
'primary_path' => DATAMACHINE_WORKSPACE_PATH . '/demo',
217+
'is_primary' => 0,
218+
'is_worktree' => 1,
219+
'missing_path' => 0,
220+
'lifecycle_state' => 'cleanup_eligible',
221+
'cleanup_signal' => 'cleanup_eligible',
222+
'metadata' => array(
223+
'lifecycle_state' => 'cleanup_eligible',
224+
),
225+
'updated_at' => '2026-06-13 00:00:00',
226+
);
209227

210228
require __DIR__ . '/../inc/Support/RuntimeCapabilities.php';
211229
require __DIR__ . '/../inc/Support/ProcessRunner.php';
@@ -225,7 +243,9 @@ function is_wp_error( $value ): bool
225243
$assert('prune returns host git command', ! is_wp_error($result) && str_contains((string) ( $result['next_commands'][0] ?? '' ), 'git -C'));
226244
$assert('inventory refresh still runs', ! is_wp_error($result) && isset($result['inventory']['summary']));
227245
$assert('prune removes missing-path inventory artifact', ! is_wp_error($result) && 'demo@missing-path' === ( $result['stale_inventory'][0]['handle'] ?? '' ) && ! isset($GLOBALS['wpdb']->rows['demo@missing-path']));
246+
$assert('prune repairs cleanup-eligible stale marker path', ! is_wp_error($result) && 'demo@cleanup-stale-marker' === ( $result['stale_marker_repaired'][0]['handle'] ?? '' ) && ! isset($GLOBALS['wpdb']->rows['demo@cleanup-stale-marker']) && ! is_dir(DATAMACHINE_WORKSPACE_PATH . '/demo@cleanup-stale-marker'));
228247
$assert('prune reports path-present stale marker blocker', ! is_wp_error($result) && 'stale_worktree_marker' === ( $result['stale_marker_blockers'][0]['reason_code'] ?? '' ));
248+
$assert('stale marker blocker returns DMC-owned removal command', ! is_wp_error($result) && str_contains((string) ( $result['stale_marker_blockers'][0]['next_command'] ?? '' ), 'workspace remove'));
229249
$assert('prune leaves path-present stale marker row for review', isset($GLOBALS['wpdb']->rows['demo@stale-marker']));
230250

231251
putenv(false === $old_path ? 'PATH' : 'PATH=' . $old_path);

0 commit comments

Comments
 (0)