Skip to content

Commit 883b8df

Browse files
authored
feat: prune orphaned missing_path inventory rows (#870)
* feat: prune orphaned missing_path inventory rows Add `workspace inventory prune-missing` and the `datamachine-code/workspace-worktree-inventory-prune-missing` ability to reap ghost rows whose on-disk directory was deleted. Safety guards: - Re-probes each candidate's path; only deletes when STILL absent. - Refuses rows with unpushed_count > 0 or non-empty pr_url unless --force. - --dry-run previews candidates and skips without mutating. Closes #869. * style: fix phpcs alignment and cast spacing in inventory prune-missing * style: annotate $wpdb globals with @var to satisfy phpstan * Revert "style: annotate $wpdb globals with @var to satisfy phpstan" This reverts commit 9258593. * ci: scope phpstan method.nonObject ignore to inventory repository wpdb guards * refactor: drop dead null checks inside isset guards in inventory decode
1 parent 61b1e5b commit 883b8df

6 files changed

Lines changed: 587 additions & 5 deletions

File tree

inc/Abilities/WorkspaceAbilities.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,6 +1594,42 @@ private function registerAbilities(): void {
15941594
)
15951595
);
15961596

1597+
AbilityRegistry::register(
1598+
'datamachine-code/workspace-worktree-inventory-prune-missing',
1599+
array(
1600+
'label' => 'Prune Missing Worktree Inventory Rows',
1601+
'description' => 'Delete DB-backed worktree inventory rows flagged missing_path whose on-disk path is still absent. Re-probes each candidate before deletion and protects rows with unpushed commits or an open PR unless force is set.',
1602+
'category' => 'datamachine-code-workspace',
1603+
'input_schema' => array(
1604+
'type' => 'object',
1605+
'properties' => array(
1606+
'dry_run' => array(
1607+
'type' => 'boolean',
1608+
'description' => 'Preview candidates and skips without deleting any rows. Default false.',
1609+
),
1610+
'force' => array(
1611+
'type' => 'boolean',
1612+
'description' => 'Allow pruning rows with unpushed_count > 0 or a non-empty pr_url. Default false.',
1613+
),
1614+
),
1615+
),
1616+
'output_schema' => array(
1617+
'type' => 'object',
1618+
'properties' => array(
1619+
'success' => array( 'type' => 'boolean' ),
1620+
'pruned_at' => array( 'type' => 'string' ),
1621+
'dry_run' => array( 'type' => 'boolean' ),
1622+
'deleted' => array( 'type' => 'array' ),
1623+
'skipped' => array( 'type' => 'array' ),
1624+
'summary' => array( 'type' => 'object' ),
1625+
),
1626+
),
1627+
'execute_callback' => array( self::class, 'worktreeInventoryPruneMissing' ),
1628+
'permission_callback' => fn() => PermissionHelper::can_manage(),
1629+
'meta' => array( 'show_in_rest' => false ),
1630+
)
1631+
);
1632+
15971633
AbilityRegistry::register(
15981634
'datamachine-code/workspace-cleanup-run',
15991635
array(
@@ -4014,6 +4050,22 @@ public static function worktreeInventoryRefresh( array $input ): array|\WP_Error
40144050
return $workspace->worktree_inventory_refresh();
40154051
}
40164052

4053+
/**
4054+
* Prune missing_path inventory rows whose on-disk path is still absent.
4055+
*
4056+
* @param array $input Input parameters.
4057+
* @return array<string,mixed>|\WP_Error
4058+
*/
4059+
public static function worktreeInventoryPruneMissing( array $input ): array|\WP_Error {
4060+
$workspace = new Workspace();
4061+
return $workspace->worktree_inventory_prune_missing(
4062+
array(
4063+
'dry_run' => ! empty($input['dry_run']),
4064+
'force' => ! empty($input['force']),
4065+
)
4066+
);
4067+
}
4068+
40174069
/**
40184070
* Schedule a background workspace cleanup system task.
40194071
*

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 90 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2328,7 +2328,16 @@ public function hygiene( array $args, array $assoc_args ): void { // phpcs:ign
23282328
* ## OPTIONS
23292329
*
23302330
* <operation>
2331-
* : Inventory operation. Currently: refresh.
2331+
* : Inventory operation. One of: refresh, prune-missing.
2332+
*
2333+
* [--dry-run]
2334+
* : (prune-missing) Preview candidates and skips without deleting rows.
2335+
*
2336+
* [--yes]
2337+
* : (prune-missing) Skip the confirmation prompt before deleting rows.
2338+
*
2339+
* [--force]
2340+
* : (prune-missing) Allow pruning rows with unpushed commits or an open PR.
23322341
*
23332342
* [--format=<format>]
23342343
* : Output format.
@@ -2343,15 +2352,35 @@ public function hygiene( array $args, array $assoc_args ): void { // phpcs:ign
23432352
*
23442353
* wp datamachine-code workspace inventory refresh --format=json
23452354
*
2355+
* # Preview ghost missing_path rows before deleting
2356+
* wp datamachine-code workspace inventory prune-missing --dry-run
2357+
*
2358+
* # Delete confirmed-absent ghost rows
2359+
* wp datamachine-code workspace inventory prune-missing --yes
2360+
*
23462361
* @subcommand inventory
23472362
*/
23482363
public function inventory( array $args, array $assoc_args ): void {
23492364
$operation = $args[0] ?? '';
2350-
if ( 'refresh' !== $operation ) {
2351-
WP_CLI::error('Usage: wp datamachine-code workspace inventory refresh [--format=<format>]');
2365+
if ( 'refresh' === $operation ) {
2366+
$this->inventory_refresh( $assoc_args );
2367+
return;
2368+
}
2369+
2370+
if ( 'prune-missing' === $operation ) {
2371+
$this->inventory_prune_missing( $assoc_args );
23522372
return;
23532373
}
23542374

2375+
WP_CLI::error('Usage: wp datamachine-code workspace inventory <refresh|prune-missing> [--format=<format>]');
2376+
}
2377+
2378+
/**
2379+
* Refresh the DB-backed worktree inventory from the current filesystem view.
2380+
*
2381+
* @param array<string,mixed> $assoc_args Associative args.
2382+
*/
2383+
private function inventory_refresh( array $assoc_args ): void {
23552384
$ability = wp_get_ability('datamachine-code/workspace-worktree-inventory-refresh');
23562385
if ( ! $ability ) {
23572386
WP_CLI::error('Workspace inventory refresh ability not available.');
@@ -2373,6 +2402,64 @@ public function inventory( array $args, array $assoc_args ): void {
23732402
WP_CLI::success(sprintf('Inventory refreshed: %d upserted, %d marked missing.', (int) ( $summary['upserted'] ?? 0 ), (int) ( $summary['marked_missing'] ?? 0 )));
23742403
}
23752404

2405+
/**
2406+
* Prune DB-backed inventory rows flagged missing_path whose path is still absent.
2407+
*
2408+
* @param array<string,mixed> $assoc_args Associative args.
2409+
*/
2410+
private function inventory_prune_missing( array $assoc_args ): void {
2411+
$dry_run = ! empty($assoc_args['dry-run']);
2412+
$force = ! empty($assoc_args['force']);
2413+
2414+
$ability = wp_get_ability('datamachine-code/workspace-worktree-inventory-prune-missing');
2415+
if ( ! $ability ) {
2416+
WP_CLI::error('Workspace inventory prune-missing ability not available.');
2417+
return;
2418+
}
2419+
2420+
// A dry-run preview never mutates, so it does not need confirmation.
2421+
if ( ! $dry_run && empty($assoc_args['yes']) ) {
2422+
$preview = $ability->execute(array( 'dry_run' => true ));
2423+
if ( is_wp_error($preview) ) {
2424+
WP_CLI::error($preview->get_error_message());
2425+
return;
2426+
}
2427+
2428+
$preview_summary = (array) ( $preview['summary'] ?? array() );
2429+
$would_delete = (int) ( $preview_summary['deleted'] ?? 0 );
2430+
$would_skip = (int) ( $preview_summary['skipped'] ?? 0 );
2431+
if ( 0 === $would_delete ) {
2432+
WP_CLI::success(sprintf('Nothing to prune: 0 rows would be deleted, %d skipped.', $would_skip));
2433+
return;
2434+
}
2435+
2436+
WP_CLI::confirm(sprintf('Prune %d missing_path inventory row(s) (%d skipped)? Pass --yes to skip this prompt.', $would_delete, $would_skip));
2437+
}
2438+
2439+
$result = $ability->execute(
2440+
array(
2441+
'dry_run' => $dry_run,
2442+
'force' => $force,
2443+
)
2444+
);
2445+
if ( is_wp_error($result) ) {
2446+
WP_CLI::error($result->get_error_message());
2447+
return;
2448+
}
2449+
2450+
if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) {
2451+
$this->renderer()->json($result);
2452+
return;
2453+
}
2454+
2455+
$summary = (array) ( $result['summary'] ?? array() );
2456+
if ( $dry_run ) {
2457+
WP_CLI::success(sprintf('Inventory prune (dry-run): %d would be deleted, %d skipped.', (int) ( $summary['deleted'] ?? 0 ), (int) ( $summary['skipped'] ?? 0 )));
2458+
} else {
2459+
WP_CLI::success(sprintf('Inventory pruned: %d deleted, %d skipped.', (int) ( $summary['deleted'] ?? 0 ), (int) ( $summary['skipped'] ?? 0 )));
2460+
}
2461+
}
2462+
23762463
/**
23772464
* Show detailed info about a workspace repo.
23782465
*

inc/Storage/WorktreeInventoryRepository.php

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,126 @@ public function mark_missing( string $handle ): bool {
191191
return false !== $result;
192192
}
193193

194+
/**
195+
* Prune inventory rows flagged missing_path whose on-disk path is still absent.
196+
*
197+
* Safety guards:
198+
* - Re-probes each candidate's path on disk; only deletes when the path is
199+
* STILL absent (a stale missing_path flag alone is not trusted).
200+
* - Refuses to delete rows with unpushed_count > 0 or a non-empty pr_url
201+
* unless 'force' is true; such rows are reported as skipped.
202+
*
203+
* @param array{dry_run?: bool, force?: bool} $opts Options.
204+
* @return array<string,mixed> Result with deleted/skipped lists and summary.
205+
*/
206+
public function pruneMissing( array $opts = array() ): array {
207+
$dry_run = ! empty($opts['dry_run']);
208+
$force = ! empty($opts['force']);
209+
210+
$rows = $this->missing_path_rows();
211+
$deleted = array();
212+
$skipped = array();
213+
214+
foreach ( $rows as $row ) {
215+
$handle = (string) ( $row['handle'] ?? '' );
216+
$path = (string) ( $row['path'] ?? '' );
217+
218+
// Re-probe the disk: only reap when the path is STILL absent.
219+
if ( '' !== $path && is_dir($path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir
220+
$skipped[] = array(
221+
'handle' => $handle,
222+
'path' => $path,
223+
'reason' => 'path_present_on_disk',
224+
);
225+
continue;
226+
}
227+
228+
// Protect rows carrying unpushed work or an open PR unless forced.
229+
if ( ! $force ) {
230+
$unpushed = isset($row['unpushed_count']) ? (int) $row['unpushed_count'] : 0;
231+
$pr_url = isset($row['pr_url']) ? trim( (string) $row['pr_url'] ) : '';
232+
233+
if ( $unpushed > 0 ) {
234+
$skipped[] = array(
235+
'handle' => $handle,
236+
'path' => $path,
237+
'reason' => 'unpushed_count',
238+
'unpushed_count' => $unpushed,
239+
);
240+
continue;
241+
}
242+
243+
if ( '' !== $pr_url ) {
244+
$skipped[] = array(
245+
'handle' => $handle,
246+
'path' => $path,
247+
'reason' => 'pr_url',
248+
'pr_url' => $pr_url,
249+
);
250+
continue;
251+
}
252+
}
253+
254+
if ( $dry_run ) {
255+
$deleted[] = array(
256+
'handle' => $handle,
257+
'path' => $path,
258+
'repo' => (string) ( $row['repo'] ?? '' ),
259+
);
260+
continue;
261+
}
262+
263+
if ( $this->delete($handle) ) {
264+
$deleted[] = array(
265+
'handle' => $handle,
266+
'path' => $path,
267+
'repo' => (string) ( $row['repo'] ?? '' ),
268+
);
269+
} else {
270+
$skipped[] = array(
271+
'handle' => $handle,
272+
'path' => $path,
273+
'reason' => 'delete_failed',
274+
);
275+
}
276+
}
277+
278+
return array(
279+
'success' => true,
280+
'pruned_at' => gmdate('c'),
281+
'dry_run' => $dry_run,
282+
'deleted' => $deleted,
283+
'skipped' => $skipped,
284+
'summary' => array(
285+
'deleted' => count($deleted),
286+
'skipped' => count($skipped),
287+
'total' => count($rows),
288+
),
289+
);
290+
}
291+
292+
/**
293+
* Fetch all rows currently flagged missing_path.
294+
*
295+
* @return array<int,array<string,mixed>>
296+
*/
297+
private function missing_path_rows(): array {
298+
global $wpdb;
299+
300+
if ( ! isset($wpdb) || ! method_exists($wpdb, 'get_results') ) {
301+
return array();
302+
}
303+
304+
$table = self::table_name();
305+
// phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input.
306+
$sql = "SELECT * FROM {$table} WHERE missing_path = 1 ORDER BY handle ASC";
307+
// phpcs:enable WordPress.DB.PreparedSQL
308+
309+
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static string, no user input.
310+
$rows = $wpdb->get_results($sql, ARRAY_A);
311+
return is_array($rows) ? array_map(array( $this, 'decode_row' ), $rows) : array();
312+
}
313+
194314
/**
195315
* Fetch all rows, optionally filtered by repo.
196316
*
@@ -275,11 +395,11 @@ private function normalize_row( array $row ): array {
275395
'last_seen_at' => $this->datetime($row['last_seen_at'] ?? $metadata['last_seen_at'] ?? null),
276396
'last_probe_at' => current_time('mysql', true),
277397
'last_probe_status' => ! empty($row['missing_path']) ? 'missing_path' : 'present',
278-
'dirty_count' => isset($row['dirty']) ? ( null === $row['dirty'] ? null : (int) $row['dirty'] ) : ( isset($row['dirty_count']) ? (int) $row['dirty_count'] : null ),
398+
'dirty_count' => isset($row['dirty']) ? (int) $row['dirty'] : ( isset($row['dirty_count']) ? (int) $row['dirty_count'] : null ),
279399
'unpushed_count' => isset($row['unpushed_count']) ? (int) $row['unpushed_count'] : null,
280400
'artifact_count' => isset($row['artifacts']) && is_array($row['artifacts']) ? count($row['artifacts']) : ( isset($row['artifact_count']) ? (int) $row['artifact_count'] : null ),
281401
'artifact_size_bytes' => isset($row['artifact_size_bytes']) ? (int) $row['artifact_size_bytes'] : null,
282-
'size_bytes' => isset($row['size_bytes']) ? ( null === $row['size_bytes'] ? null : (int) $row['size_bytes'] ) : null,
402+
'size_bytes' => isset($row['size_bytes']) ? (int) $row['size_bytes'] : null,
283403
'cleanup_signal' => $this->cleanup_signal($row, $metadata),
284404
'missing_path' => ! empty($row['missing_path']) ? 1 : 0,
285405
'metadata' => JsonCodec::encode_or_default($metadata),

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,19 @@ public function worktree_inventory_refresh(): array|\WP_Error {
10621062
);
10631063
}
10641064

1065+
/**
1066+
* Prune DB-backed inventory rows flagged missing_path whose path is still absent.
1067+
*
1068+
* Re-probes each candidate on disk, protects rows with unpushed work or an
1069+
* open PR unless forced, and deletes the confirmed-absent survivors.
1070+
*
1071+
* @param array{dry_run?: bool, force?: bool} $opts Options.
1072+
* @return array<string,mixed>|\WP_Error
1073+
*/
1074+
public function worktree_inventory_prune_missing( array $opts = array() ): array|\WP_Error {
1075+
return $this->worktree_inventory()->pruneMissing($opts);
1076+
}
1077+
10651078
/**
10661079
* Build a single inventory row for a known workspace handle.
10671080
*

phpstan.neon

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,11 @@ parameters:
33
- stubs/phpstan/datamachine-base-command.php
44
- stubs/phpstan/datamachine-plugin-settings.php
55
- stubs/phpstan/datamachine-system-task.php
6+
ignoreErrors:
7+
# The inventory repository intentionally guards $wpdb with isset()/method_exists()
8+
# for multisite safety, which narrows the global to class-string|object and makes
9+
# PHPStan reject the subsequent $wpdb method calls. The runtime object is always a
10+
# \wpdb here; these calls are safe.
11+
-
12+
identifier: method.nonObject
13+
path: inc/Storage/WorktreeInventoryRepository.php

0 commit comments

Comments
 (0)