Skip to content

Commit e65ae18

Browse files
authored
Merge pull request #701 from Extra-Chill/fix-stale-marker-prune
Fix stale marker blockers in worktree prune
2 parents 2f20b96 + d2ad11c commit e65ae18

3 files changed

Lines changed: 269 additions & 24 deletions

File tree

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4328,12 +4328,25 @@ function ( $wt ) {
43284328
return;
43294329

43304330
case 'prune':
4331-
$pruned = $result['pruned'] ?? array();
4332-
if ( empty($pruned) ) {
4331+
$pruned = (array) ( $result['pruned'] ?? array() );
4332+
$stale_inventory = (array) ( $result['stale_inventory'] ?? array() );
4333+
$stale_marker_blockers = (array) ( $result['stale_marker_blockers'] ?? array() );
4334+
if ( empty($pruned) && empty($stale_inventory) && empty($stale_marker_blockers) ) {
43334335
WP_CLI::log('Nothing to prune.');
43344336
return;
43354337
}
4336-
WP_CLI::success(sprintf('Pruned worktree registry across: %s', implode(', ', $pruned)));
4338+
if ( ! empty($pruned) ) {
4339+
WP_CLI::success(sprintf('Pruned worktree registry across: %s', implode(', ', $pruned)));
4340+
}
4341+
if ( ! empty($stale_inventory) ) {
4342+
WP_CLI::success(sprintf('Removed %d stale worktree inventory artifact%s.', count($stale_inventory), 1 === count($stale_inventory) ? '' : 's'));
4343+
}
4344+
if ( ! empty($stale_marker_blockers) ) {
4345+
WP_CLI::warning(sprintf('Found %d path-present stale worktree marker blocker%s; left checkout paths in place for review.', count($stale_marker_blockers), 1 === count($stale_marker_blockers) ? '' : 's'));
4346+
foreach ( $stale_marker_blockers as $blocker ) {
4347+
WP_CLI::log(sprintf(' - %s at %s', (string) ( $blocker['handle'] ?? '' ), (string) ( $blocker['path'] ?? '' )));
4348+
}
4349+
}
43374350
return;
43384351

43394352
case 'cleanup':

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,12 +1111,14 @@ function () use ( $primary_path, $wt_path, $force, $wt_handle ) {
11111111
/**
11121112
* Prune stale worktree registry entries across all primaries.
11131113
*
1114-
* @return array{success: bool, pruned: array, skipped?: array, next_commands?: array, inventory?: array}|\WP_Error
1114+
* @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 {
11171117
$pruned = array();
11181118
$skipped = array();
11191119
$next_commands = array();
1120+
$stale_rows = array();
1121+
$marker_blocks = array();
11201122

11211123
if ( ! is_dir($this->workspace_path) ) {
11221124
return array(
@@ -1159,12 +1161,107 @@ public function worktree_prune(): array|\WP_Error {
11591161
return $refresh;
11601162
}
11611163

1164+
$inventory_diagnostics = $this->prune_stale_worktree_inventory_rows();
1165+
if ( $inventory_diagnostics instanceof \WP_Error ) {
1166+
return $inventory_diagnostics;
1167+
}
1168+
1169+
$stale_rows = (array) ( $inventory_diagnostics['stale_inventory'] ?? array() );
1170+
$marker_blocks = (array) ( $inventory_diagnostics['stale_marker_blockers'] ?? array() );
1171+
foreach ( (array) ( $inventory_diagnostics['next_commands'] ?? array() ) as $command ) {
1172+
$next_commands[] = (string) $command;
1173+
}
1174+
1175+
return array(
1176+
'success' => true,
1177+
'pruned' => $pruned,
1178+
'skipped' => $skipped,
1179+
'next_commands' => array_values(array_unique($next_commands)),
1180+
'inventory' => $refresh,
1181+
'stale_inventory' => $stale_rows,
1182+
'stale_marker_blockers' => $marker_blocks,
1183+
);
1184+
}
1185+
1186+
/**
1187+
* Repair safe stale inventory rows and report marker blockers that need review.
1188+
*
1189+
* `git worktree prune` only repairs Git's own metadata. DMC can also retain
1190+
* cleanup-eligible inventory rows for worktrees that no longer exist, and
1191+
* those rows can block bounded cleanup even after Git reports nothing to
1192+
* 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.
1194+
*
1195+
* @return array<string,mixed>|\WP_Error
1196+
*/
1197+
private function prune_stale_worktree_inventory_rows(): array|\WP_Error {
1198+
$repository = $this->worktree_inventory();
1199+
$stale_inventory = array();
1200+
$stale_marker_blockers = array();
1201+
$next_commands = array();
1202+
1203+
foreach ( $repository->list() as $row ) {
1204+
$handle = (string) ( $row['handle'] ?? '' );
1205+
$repo = (string) ( $row['repo'] ?? '' );
1206+
$path = (string) ( $row['path'] ?? '' );
1207+
$parsed = '' !== $handle ? $this->parse_handle($handle) : array( 'is_worktree' => false );
1208+
if ( '' === $handle || empty($parsed['is_worktree']) ) {
1209+
continue;
1210+
}
1211+
1212+
if ( ! empty($row['missing_path']) && ( '' === $path || ! is_dir($path) ) ) {
1213+
if ( $repository->delete($handle) ) {
1214+
WorktreeContextInjector::forget_metadata($handle);
1215+
$stale_inventory[] = array(
1216+
'handle' => $handle,
1217+
'repo' => $repo,
1218+
'path' => $path,
1219+
'reason_code' => 'registry_artifact',
1220+
'reason' => 'inventory row pointed at a missing worktree path and was removed from DMC metadata',
1221+
);
1222+
}
1223+
continue;
1224+
}
1225+
1226+
$marker = rtrim($path, '/') . '/.git';
1227+
if ( ! is_file($marker) ) {
1228+
continue;
1229+
}
1230+
1231+
$contents = file_get_contents($marker); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads a validated local .git marker, not a remote URL.
1232+
if ( false === $contents || ! preg_match('/^gitdir:\s*(.+)$/mi', $contents, $matches) ) {
1233+
continue;
1234+
}
1235+
1236+
$gitdir = trim($matches[1]);
1237+
if ( ! str_contains($gitdir, '/.git/worktrees/') && ! str_contains($gitdir, '\\.git\\worktrees\\') ) {
1238+
continue;
1239+
}
1240+
1241+
if ( file_exists($gitdir) ) {
1242+
continue;
1243+
}
1244+
1245+
$primary_path = '' !== $repo ? $this->get_primary_path($repo) : (string) ( $row['primary_path'] ?? '' );
1246+
$stale_marker_blockers[] = array(
1247+
'handle' => $handle,
1248+
'repo' => $repo,
1249+
'path' => $path,
1250+
'primary_path' => $primary_path,
1251+
'gitdir' => $gitdir,
1252+
'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.',
1255+
);
1256+
if ( '' !== $primary_path ) {
1257+
$next_commands[] = sprintf('git -C %s worktree prune -v', escapeshellarg($primary_path));
1258+
}
1259+
}
1260+
11621261
return array(
1163-
'success' => true,
1164-
'pruned' => $pruned,
1165-
'skipped' => $skipped,
1166-
'next_commands' => array_values(array_unique($next_commands)),
1167-
'inventory' => $refresh,
1262+
'stale_inventory' => $stale_inventory,
1263+
'stale_marker_blockers' => $stale_marker_blockers,
1264+
'next_commands' => array_values(array_unique($next_commands)),
11681265
);
11691266
}
11701267

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

Lines changed: 150 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,113 @@ public static function get(): ?self
2222
if (! defined('ABSPATH') ) {
2323
define('ABSPATH', $tmp . '/wp/');
2424
}
25-
if (! defined('DATAMACHINE_WORKSPACE_PATH') ) {
26-
define('DATAMACHINE_WORKSPACE_PATH', $tmp . '/workspace');
27-
}
25+
if (! defined('DATAMACHINE_WORKSPACE_PATH') ) {
26+
define('DATAMACHINE_WORKSPACE_PATH', $tmp . '/workspace');
27+
}
28+
if (! defined('ARRAY_A') ) {
29+
define('ARRAY_A', 'ARRAY_A');
30+
}
31+
32+
if (! function_exists('current_time') ) {
33+
function current_time( string $type, bool $gmt = false ): string // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
34+
{
35+
return '2026-06-14 00:00:00';
36+
}
37+
}
38+
if (! function_exists('wp_json_encode') ) {
39+
function wp_json_encode( mixed $value, int $flags = 0, int $depth = 512 ): string|false
40+
{
41+
return json_encode($value, $flags, $depth);
42+
}
43+
}
44+
45+
class DatamachineCodePruneFakeWpdb
46+
{
47+
public string $prefix = 'wp_';
48+
public int $insert_id = 0;
49+
public int $rows_affected = 0;
50+
public string $last_error = '';
51+
52+
/**
53+
* @var array<string,array<string,mixed>>
54+
*/
55+
public array $rows = array();
56+
public array $lock_rows = array();
57+
58+
public function get_var( string $query ): string|int|null
59+
{
60+
if ( str_contains($query, 'SHOW TABLES LIKE') ) {
61+
return $this->prefix . 'datamachine_code_locks';
62+
}
63+
64+
if ( str_contains($query, 'COUNT(*)') ) {
65+
return 0;
66+
}
67+
68+
return null;
69+
}
70+
71+
public function query( string $query ): int
72+
{
73+
$this->rows_affected = 0;
74+
return 0;
75+
}
76+
77+
public function replace( string $table, array $data ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
78+
{
79+
$this->rows[ (string) $data['handle'] ] = $data;
80+
return 1;
81+
}
82+
83+
public function insert( string $table, array $data, ?array $format = null ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
84+
{
85+
++$this->insert_id;
86+
$data['id'] = $this->insert_id;
87+
$this->lock_rows[ $this->insert_id ] = $data;
88+
return 1;
89+
}
90+
91+
public function delete( string $table, array $where ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
92+
{
93+
unset($this->rows[ (string) $where['handle'] ]);
94+
return 1;
95+
}
96+
97+
public function update( string $table, array $data, array $where, ?array $format = null, ?array $where_format = null ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
98+
{
99+
if ( str_contains($table, 'datamachine_code_locks') ) {
100+
$id = (int) ( $where['id'] ?? 0 );
101+
if ( isset($this->lock_rows[ $id ]) ) {
102+
$this->lock_rows[ $id ] = array_merge($this->lock_rows[ $id ], $data);
103+
return 1;
104+
}
105+
return 0;
106+
}
107+
108+
$handle = (string) ( $where['handle'] ?? '' );
109+
if ( ! isset($this->rows[ $handle ]) ) {
110+
return 0;
111+
}
112+
113+
$this->rows[ $handle ] = array_merge($this->rows[ $handle ], $data);
114+
return 1;
115+
}
116+
117+
public function get_results( string $sql, string $output ): array // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
118+
{
119+
$rows = array_values($this->rows);
120+
usort($rows, fn( array $a, array $b ): int => strcmp((string) $a['handle'], (string) $b['handle']));
121+
return $rows;
122+
}
123+
124+
public function prepare( string $query, mixed ...$args ): string
125+
{
126+
foreach ( $args as $arg ) {
127+
$query = preg_replace('/%s/', "'" . addslashes((string) $arg) . "'", $query, 1);
128+
}
129+
return $query;
130+
}
131+
}
28132

29133
if (! class_exists('WP_Error') ) {
30134
class WP_Error
@@ -70,15 +174,43 @@ function is_wp_error( $value ): bool
70174
echo " fail {$label}\n";
71175
};
72176

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';
177+
$old_path = getenv('PATH');
178+
putenv('PATH=/nonexistent-dmc-no-git');
179+
180+
mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo/.git', 0777, true);
181+
mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker', 0777, true);
182+
file_put_contents(DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker/.git', 'gitdir: ' . DATAMACHINE_WORKSPACE_PATH . '/demo/.git/worktrees/demo@stale-marker' . "\n");
183+
184+
$GLOBALS['wpdb'] = new DatamachineCodePruneFakeWpdb();
185+
$GLOBALS['wpdb']->rows['demo@missing-path'] = array(
186+
'handle' => 'demo@missing-path',
187+
'repo' => 'demo',
188+
'branch' => 'missing-path',
189+
'path' => DATAMACHINE_WORKSPACE_PATH . '/demo@missing-path',
190+
'primary_path' => DATAMACHINE_WORKSPACE_PATH . '/demo',
191+
'is_primary' => 0,
192+
'is_worktree' => 1,
193+
'missing_path' => 1,
194+
'metadata' => array(),
195+
'updated_at' => '2026-06-13 00:00:00',
196+
);
197+
$GLOBALS['wpdb']->rows['demo@stale-marker'] = array(
198+
'handle' => 'demo@stale-marker',
199+
'repo' => 'demo',
200+
'branch' => 'stale-marker',
201+
'path' => DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker',
202+
'primary_path' => DATAMACHINE_WORKSPACE_PATH . '/demo',
203+
'is_primary' => 0,
204+
'is_worktree' => 1,
205+
'missing_path' => 0,
206+
'metadata' => array(),
207+
'updated_at' => '2026-06-13 00:00:00',
208+
);
209+
210+
require __DIR__ . '/../inc/Support/RuntimeCapabilities.php';
211+
require __DIR__ . '/../inc/Support/ProcessRunner.php';
212+
require __DIR__ . '/../inc/Support/GitRunner.php';
213+
require __DIR__ . '/../inc/Support/PathSecurity.php';
82214
require __DIR__ . '/../inc/Workspace/WorktreeContextInjector.php';
83215
require __DIR__ . '/../inc/Workspace/WorkspaceMutationLock.php';
84216
require __DIR__ . '/../inc/Workspace/Workspace.php';
@@ -89,9 +221,12 @@ function is_wp_error( $value ): bool
89221
$result = $workspace->worktree_prune();
90222

91223
$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']));
224+
$assert('prune records skipped primary', ! is_wp_error($result) && 'demo' === ( $result['skipped'][0]['repo'] ?? '' ));
225+
$assert('prune returns host git command', ! is_wp_error($result) && str_contains((string) ( $result['next_commands'][0] ?? '' ), 'git -C'));
226+
$assert('inventory refresh still runs', ! is_wp_error($result) && isset($result['inventory']['summary']));
227+
$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']));
228+
$assert('prune reports path-present stale marker blocker', ! is_wp_error($result) && 'stale_worktree_marker' === ( $result['stale_marker_blockers'][0]['reason_code'] ?? '' ));
229+
$assert('prune leaves path-present stale marker row for review', isset($GLOBALS['wpdb']->rows['demo@stale-marker']));
95230

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

0 commit comments

Comments
 (0)