From 265b72402361e0d301d29b7e7b8b20b7697db4fe Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 5 Jul 2026 19:51:23 +0000 Subject: [PATCH 1/6] 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. --- inc/Abilities/WorkspaceAbilities.php | 54 +++- inc/Cli/Commands/WorkspaceCommand.php | 93 +++++- inc/Storage/WorktreeInventoryRepository.php | 120 ++++++++ inc/Workspace/WorkspaceWorktreeLifecycle.php | 13 + tests/worktree-inventory-prune-missing.php | 302 +++++++++++++++++++ 5 files changed, 578 insertions(+), 4 deletions(-) create mode 100644 tests/worktree-inventory-prune-missing.php diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 70218523..fdd8f17a 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1586,14 +1586,50 @@ private function registerAbilities(): void { 'upserted' => array( 'type' => 'array' ), 'marked_missing' => array( 'type' => 'array' ), 'summary' => array( 'type' => 'object' ), - ), ), + ), 'execute_callback' => array( self::class, 'worktreeInventoryRefresh' ), 'permission_callback' => fn() => PermissionHelper::can_manage(), 'meta' => array( 'show_in_rest' => false ), ) ); + AbilityRegistry::register( + 'datamachine-code/workspace-worktree-inventory-prune-missing', + array( + 'label' => 'Prune Missing Worktree Inventory Rows', + '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.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'dry_run' => array( + 'type' => 'boolean', + 'description' => 'Preview candidates and skips without deleting any rows. Default false.', + ), + 'force' => array( + 'type' => 'boolean', + 'description' => 'Allow pruning rows with unpushed_count > 0 or a non-empty pr_url. Default false.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'pruned_at' => array( 'type' => 'string' ), + 'dry_run' => array( 'type' => 'boolean' ), + 'deleted' => array( 'type' => 'array' ), + 'skipped' => array( 'type' => 'array' ), + 'summary' => array( 'type' => 'object' ), + ), + ), + 'execute_callback' => array( self::class, 'worktreeInventoryPruneMissing' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + AbilityRegistry::register( 'datamachine-code/workspace-cleanup-run', array( @@ -3978,6 +4014,22 @@ public static function worktreeInventoryRefresh( array $input ): array|\WP_Error return $workspace->worktree_inventory_refresh(); } + /** + * Prune missing_path inventory rows whose on-disk path is still absent. + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function worktreeInventoryPruneMissing( array $input ): array|\WP_Error { + $workspace = new Workspace(); + return $workspace->worktree_inventory_prune_missing( + array( + 'dry_run' => ! empty($input['dry_run']), + 'force' => ! empty($input['force']), + ) + ); + } + /** * Schedule a background workspace cleanup system task. * diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 1ca6c9f9..2212fb12 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -2328,7 +2328,16 @@ public function hygiene( array $args, array $assoc_args ): void { // phpcs:ign * ## OPTIONS * * - * : Inventory operation. Currently: refresh. + * : Inventory operation. One of: refresh, prune-missing. + * + * [--dry-run] + * : (prune-missing) Preview candidates and skips without deleting rows. + * + * [--yes] + * : (prune-missing) Skip the confirmation prompt before deleting rows. + * + * [--force] + * : (prune-missing) Allow pruning rows with unpushed commits or an open PR. * * [--format=] * : Output format. @@ -2343,15 +2352,35 @@ public function hygiene( array $args, array $assoc_args ): void { // phpcs:ign * * wp datamachine-code workspace inventory refresh --format=json * + * # Preview ghost missing_path rows before deleting + * wp datamachine-code workspace inventory prune-missing --dry-run + * + * # Delete confirmed-absent ghost rows + * wp datamachine-code workspace inventory prune-missing --yes + * * @subcommand inventory */ public function inventory( array $args, array $assoc_args ): void { $operation = $args[0] ?? ''; - if ( 'refresh' !== $operation ) { - WP_CLI::error('Usage: wp datamachine-code workspace inventory refresh [--format=]'); + if ( 'refresh' === $operation ) { + $this->inventory_refresh( $assoc_args ); + return; + } + + if ( 'prune-missing' === $operation ) { + $this->inventory_prune_missing( $assoc_args ); return; } + WP_CLI::error('Usage: wp datamachine-code workspace inventory [--format=]'); + } + + /** + * Refresh the DB-backed worktree inventory from the current filesystem view. + * + * @param array $assoc_args Associative args. + */ + private function inventory_refresh( array $assoc_args ): void { $ability = wp_get_ability('datamachine-code/workspace-worktree-inventory-refresh'); if ( ! $ability ) { WP_CLI::error('Workspace inventory refresh ability not available.'); @@ -2373,6 +2402,64 @@ public function inventory( array $args, array $assoc_args ): void { WP_CLI::success(sprintf('Inventory refreshed: %d upserted, %d marked missing.', (int) ( $summary['upserted'] ?? 0 ), (int) ( $summary['marked_missing'] ?? 0 ))); } + /** + * Prune DB-backed inventory rows flagged missing_path whose path is still absent. + * + * @param array $assoc_args Associative args. + */ + private function inventory_prune_missing( array $assoc_args ): void { + $dry_run = ! empty($assoc_args['dry-run']); + $force = ! empty($assoc_args['force']); + + $ability = wp_get_ability('datamachine-code/workspace-worktree-inventory-prune-missing'); + if ( ! $ability ) { + WP_CLI::error('Workspace inventory prune-missing ability not available.'); + return; + } + + // A dry-run preview never mutates, so it does not need confirmation. + if ( ! $dry_run && empty($assoc_args['yes']) ) { + $preview = $ability->execute(array( 'dry_run' => true )); + if ( is_wp_error($preview) ) { + WP_CLI::error($preview->get_error_message()); + return; + } + + $preview_summary = (array) ( $preview['summary'] ?? array() ); + $would_delete = (int) ( $preview_summary['deleted'] ?? 0 ); + $would_skip = (int) ( $preview_summary['skipped'] ?? 0 ); + if ( 0 === $would_delete ) { + WP_CLI::success(sprintf('Nothing to prune: 0 rows would be deleted, %d skipped.', $would_skip)); + return; + } + + WP_CLI::confirm(sprintf('Prune %d missing_path inventory row(s) (%d skipped)? Pass --yes to skip this prompt.', $would_delete, $would_skip)); + } + + $result = $ability->execute( + array( + 'dry_run' => $dry_run, + 'force' => $force, + ) + ); + if ( is_wp_error($result) ) { + WP_CLI::error($result->get_error_message()); + return; + } + + if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) { + $this->renderer()->json($result); + return; + } + + $summary = (array) ( $result['summary'] ?? array() ); + if ( $dry_run ) { + WP_CLI::success(sprintf('Inventory prune (dry-run): %d would be deleted, %d skipped.', (int) ( $summary['deleted'] ?? 0 ), (int) ( $summary['skipped'] ?? 0 ))); + } else { + WP_CLI::success(sprintf('Inventory pruned: %d deleted, %d skipped.', (int) ( $summary['deleted'] ?? 0 ), (int) ( $summary['skipped'] ?? 0 ))); + } + } + /** * Show detailed info about a workspace repo. * diff --git a/inc/Storage/WorktreeInventoryRepository.php b/inc/Storage/WorktreeInventoryRepository.php index 398ef216..d8ac9f33 100644 --- a/inc/Storage/WorktreeInventoryRepository.php +++ b/inc/Storage/WorktreeInventoryRepository.php @@ -191,6 +191,126 @@ public function mark_missing( string $handle ): bool { return false !== $result; } + /** + * Prune inventory rows flagged missing_path whose on-disk path is still absent. + * + * Safety guards: + * - Re-probes each candidate's path on disk; only deletes when the path is + * STILL absent (a stale missing_path flag alone is not trusted). + * - Refuses to delete rows with unpushed_count > 0 or a non-empty pr_url + * unless 'force' is true; such rows are reported as skipped. + * + * @param array{dry_run?: bool, force?: bool} $opts Options. + * @return array Result with deleted/skipped lists and summary. + */ + public function pruneMissing( array $opts = array() ): array { + $dry_run = ! empty($opts['dry_run']); + $force = ! empty($opts['force']); + + $rows = $this->missing_path_rows(); + $deleted = array(); + $skipped = array(); + + foreach ( $rows as $row ) { + $handle = (string) ( $row['handle'] ?? '' ); + $path = (string) ( $row['path'] ?? '' ); + + // Re-probe the disk: only reap when the path is STILL absent. + if ( '' !== $path && is_dir($path) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_dir + $skipped[] = array( + 'handle' => $handle, + 'path' => $path, + 'reason' => 'path_present_on_disk', + ); + continue; + } + + // Protect rows carrying unpushed work or an open PR unless forced. + if ( ! $force ) { + $unpushed = isset($row['unpushed_count']) ? (int) $row['unpushed_count'] : 0; + $pr_url = isset($row['pr_url']) ? trim((string) $row['pr_url']) : ''; + + if ( $unpushed > 0 ) { + $skipped[] = array( + 'handle' => $handle, + 'path' => $path, + 'reason' => 'unpushed_count', + 'unpushed_count' => $unpushed, + ); + continue; + } + + if ( '' !== $pr_url ) { + $skipped[] = array( + 'handle' => $handle, + 'path' => $path, + 'reason' => 'pr_url', + 'pr_url' => $pr_url, + ); + continue; + } + } + + if ( $dry_run ) { + $deleted[] = array( + 'handle' => $handle, + 'path' => $path, + 'repo' => (string) ( $row['repo'] ?? '' ), + ); + continue; + } + + if ( $this->delete($handle) ) { + $deleted[] = array( + 'handle' => $handle, + 'path' => $path, + 'repo' => (string) ( $row['repo'] ?? '' ), + ); + } else { + $skipped[] = array( + 'handle' => $handle, + 'path' => $path, + 'reason' => 'delete_failed', + ); + } + } + + return array( + 'success' => true, + 'pruned_at' => gmdate('c'), + 'dry_run' => $dry_run, + 'deleted' => $deleted, + 'skipped' => $skipped, + 'summary' => array( + 'deleted' => count($deleted), + 'skipped' => count($skipped), + 'total' => count($rows), + ), + ); + } + + /** + * Fetch all rows currently flagged missing_path. + * + * @return array> + */ + private function missing_path_rows(): array { + global $wpdb; + + if ( ! isset($wpdb) || ! method_exists($wpdb, 'get_results') ) { + return array(); + } + + $table = self::table_name(); + // phpcs:disable WordPress.DB.PreparedSQL -- Table name from $wpdb->prefix, not user input. + $sql = "SELECT * FROM {$table} WHERE missing_path = 1 ORDER BY handle ASC"; + // phpcs:enable WordPress.DB.PreparedSQL + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static string, no user input. + $rows = $wpdb->get_results($sql, ARRAY_A); + return is_array($rows) ? array_map(array( $this, 'decode_row' ), $rows) : array(); + } + /** * Fetch all rows, optionally filtered by repo. * diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 2d77cf51..d2bd65a6 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -1062,6 +1062,19 @@ public function worktree_inventory_refresh(): array|\WP_Error { ); } + /** + * Prune DB-backed inventory rows flagged missing_path whose path is still absent. + * + * Re-probes each candidate on disk, protects rows with unpushed work or an + * open PR unless forced, and deletes the confirmed-absent survivors. + * + * @param array{dry_run?: bool, force?: bool} $opts Options. + * @return array|\WP_Error + */ + public function worktree_inventory_prune_missing( array $opts = array() ): array|\WP_Error { + return $this->worktree_inventory()->pruneMissing($opts); + } + /** * Build a single inventory row for a known workspace handle. * diff --git a/tests/worktree-inventory-prune-missing.php b/tests/worktree-inventory-prune-missing.php new file mode 100644 index 00000000..3832d9ff --- /dev/null +++ b/tests/worktree-inventory-prune-missing.php @@ -0,0 +1,302 @@ + 0 and non-empty pr_url are skipped unless force is set + * - a real run deletes only confirmed-absent, unprotected rows + * + * Standalone: no WordPress, no PHPUnit. Uses an in-memory $wpdb stub whose + * get_results() honors the `WHERE missing_path = 1` filter so the production + * SQL path is exercised faithfully. + * + * @package DataMachineCode\Storage + */ + +declare(strict_types=1); + +const ARRAY_A = 'ARRAY_A'; + +if ( ! defined('ABSPATH') ) { + define('ABSPATH', __DIR__ . '/fixtures/'); +} + +require_once dirname(__DIR__) . '/inc/Support/JsonCodec.php'; +require_once dirname(__DIR__) . '/inc/Storage/WorktreeInventoryRepository.php'; + +use DataMachineCode\Storage\WorktreeInventoryRepository; + +/** + * In-memory $wpdb stub. + * + * get_results() filters rows by the missing_path = 1 predicate when present, + * mirroring the production SQL query path. + */ +final class Prune_Test_Wpdb { + public string $prefix = 'wp_'; + + /** @var array> handle => row */ + public array $rows = array(); + + public function get_charset_collate(): string { + return ''; + } + + public function get_results( string $sql, string $output = ARRAY_A ): array { + $out = array(); + foreach ( $this->rows as $row ) { + if ( str_contains($sql, 'missing_path = 1') && empty($row['missing_path']) ) { + continue; + } + $out[] = $row; + } + return $out; + } + + public function delete( string $table, array $where ): int|false { + $handle = (string) ( $where['handle'] ?? '' ); + if ( ! isset($this->rows[ $handle ]) ) { + return false; + } + unset($this->rows[ $handle ]); + return 1; + } + + public function prepare( string $query, mixed ...$args ): string { + foreach ( $args as $arg ) { + $query = preg_replace('/%s/', addslashes((string) $arg), $query, 1) ?? $query; + } + return $query; + } +} + +function current_time( string $type, bool $gmt = false ): string { + return gmdate('Y-m-d H:i:s'); +} + +/** + * Build an inventory row with sensible defaults. + * + * @return array + */ +function make_row( array $overrides = array() ): array { + return array_merge( + array( + 'id' => 0, + 'handle' => '', + 'repo' => '', + 'branch' => null, + 'path' => '', + 'primary_path' => null, + 'is_primary' => 0, + 'lifecycle_state' => null, + 'pr_url' => null, + 'unpushed_count' => null, + 'dirty_count' => null, + 'missing_path' => 1, + 'last_probe_at' => null, + 'last_probe_status' => 'missing_path', + 'metadata' => null, + ), + $overrides + ); +} + +function assert_true( bool $condition, string $message ): void { + if ( ! $condition ) { + throw new RuntimeException($message); + } +} + +function assert_same( mixed $expected, mixed $actual, string $message ): void { + if ( $expected !== $actual ) { + throw new RuntimeException( + sprintf("%s\nExpected: %s\nActual: %s", $message, var_export($expected, true), var_export($actual, true)) + ); + } +} + +$passed = 0; + +/** + * Seed the stub from a list of row definitions. + * + * @param Prune_Test_Wpdb $wpdb + * @param array> $rows + */ +function seed( Prune_Test_Wpdb $wpdb, array $rows ): void { + $wpdb->rows = array(); + foreach ( $rows as $row ) { + $handle = (string) ( $row['handle'] ?? '' ); + if ( '' === $handle ) { + throw new RuntimeException('seed row missing handle'); + } + $wpdb->rows[ $handle ] = $row; + } +} + +$tests = array(); + +/* + * Test 1: dry-run returns candidates without deleting anything. + */ +$tests[] = static function (): void { + $wpdb = new Prune_Test_Wpdb(); + // Two ghost rows: paths point at nonexistent temp locations. + $ghost_a = sys_get_temp_dir() . '/dmc-prune-smoke-absent-a-' . getmypid(); + $ghost_b = sys_get_temp_dir() . '/dmc-prune-smoke-absent-b-' . getmypid(); + @unlink($ghost_a); + @unlink($ghost_b); + assert_true(! is_dir($ghost_a), 'fixture ghost_a path must be absent'); + assert_true(! is_dir($ghost_b), 'fixture ghost_b path must be absent'); + + seed($wpdb, array( + make_row(array( 'handle' => 'homeboy', 'repo' => 'homeboy', 'path' => $ghost_a )), + make_row(array( 'handle' => 'homeboy-action', 'repo' => 'homeboy-action', 'path' => $ghost_b )), + make_row(array( 'handle' => 'data-machine', 'repo' => 'data-machine', 'path' => $ghost_a, 'missing_path' => 0 )), + )); + $GLOBALS['wpdb'] = $wpdb; + + $repo = new WorktreeInventoryRepository(); + $result = $repo->pruneMissing(array( 'dry_run' => true )); + + assert_true(! empty($result['success']), 'dry-run returns success'); + assert_true(! empty($result['dry_run']), 'dry-run result flags dry_run'); + assert_same(2, $result['summary']['deleted'], 'dry-run reports 2 would-delete candidates'); + // Rows must still be present (no mutation on dry-run). + assert_true(isset($wpdb->rows['homeboy']), 'dry-run did not delete homeboy'); + assert_true(isset($wpdb->rows['homeboy-action']), 'dry-run did not delete homeboy-action'); + ++$GLOBALS['passed']; +}; + +/* + * Test 2: re-probe guard skips rows whose path is present on disk + * (stale missing_path flag must not be trusted). + */ +$tests[] = static function (): void { + $wpdb = new Prune_Test_Wpdb(); + // A real directory on disk — flagged missing_path=1 but actually present. + $present = sys_get_temp_dir() . '/dmc-prune-smoke-present-' . getmypid(); + @mkdir($present, 0777, true); + assert_true(is_dir($present), 'fixture present path must exist'); + + $absent = sys_get_temp_dir() . '/dmc-prune-smoke-absent-c-' . getmypid(); + assert_true(! is_dir($absent), 'fixture absent path must be missing'); + + seed($wpdb, array( + make_row(array( 'handle' => 'stale-flag', 'repo' => 'r', 'path' => $present )), + make_row(array( 'handle' => 'real-ghost', 'repo' => 'r', 'path' => $absent )), + )); + $GLOBALS['wpdb'] = $wpdb; + + $repo = new WorktreeInventoryRepository(); + $result = $repo->pruneMissing(array()); + + assert_same(1, $result['summary']['deleted'], 'only the truly-absent row is deleted'); + assert_same(1, $result['summary']['skipped'], 'the present-on-disk row is skipped'); + + $skipped_handles = array_map(fn( $s ) => $s['handle'], $result['skipped']); + assert_true(in_array('stale-flag', $skipped_handles, true), 'stale-flag skipped'); + assert_true(! isset($wpdb->rows['real-ghost']), 'real-ghost deleted from store'); + assert_true(isset($wpdb->rows['stale-flag']), 'stale-flag preserved in store'); + + rmdir($present); + ++$GLOBALS['passed']; +}; + +/* + * Test 3: unpushed_count > 0 and non-empty pr_url are skipped without --force. + */ +$tests[] = static function (): void { + $wpdb = new Prune_Test_Wpdb(); + $absent = sys_get_temp_dir() . '/dmc-prune-smoke-absent-d-' . getmypid(); + assert_true(! is_dir($absent), 'fixture absent path must be missing'); + + seed($wpdb, array( + make_row(array( 'handle' => 'unpushed', 'path' => $absent, 'unpushed_count' => 3 )), + make_row(array( 'handle' => 'has-pr', 'path' => $absent, 'pr_url' => 'https://github.com/o/r/pull/1' )), + make_row(array( 'handle' => 'clean-ghost', 'path' => $absent )), + )); + $GLOBALS['wpdb'] = $wpdb; + + $repo = new WorktreeInventoryRepository(); + $result = $repo->pruneMissing(array()); + + assert_same(1, $result['summary']['deleted'], 'only the clean ghost is deleted'); + assert_same(2, $result['summary']['skipped'], 'unpushed + PR rows are skipped'); + + $reasons = array(); + foreach ( $result['skipped'] as $skip ) { + $reasons[ $skip['handle'] ] = $skip['reason']; + } + assert_same('unpushed_count', $reasons['unpushed'] ?? '', 'unpushed row skipped for unpushed_count'); + assert_same('pr_url', $reasons['has-pr'] ?? '', 'PR row skipped for pr_url'); + assert_true(! isset($wpdb->rows['clean-ghost']), 'clean ghost deleted'); + assert_true(isset($wpdb->rows['unpushed']), 'unpushed row preserved'); + assert_true(isset($wpdb->rows['has-pr']), 'PR row preserved'); + ++$GLOBALS['passed']; +}; + +/* + * Test 4: --force overrides the unpushed_count / pr_url guards. + */ +$tests[] = static function (): void { + $wpdb = new Prune_Test_Wpdb(); + $absent = sys_get_temp_dir() . '/dmc-prune-smoke-absent-e-' . getmypid(); + assert_true(! is_dir($absent), 'fixture absent path must be missing'); + + seed($wpdb, array( + make_row(array( 'handle' => 'unpushed', 'path' => $absent, 'unpushed_count' => 3 )), + make_row(array( 'handle' => 'has-pr', 'path' => $absent, 'pr_url' => 'https://github.com/o/r/pull/2' )), + )); + $GLOBALS['wpdb'] = $wpdb; + + $repo = new WorktreeInventoryRepository(); + $result = $repo->pruneMissing(array( 'force' => true )); + + assert_same(2, $result['summary']['deleted'], 'force deletes the protected rows'); + assert_same(0, $result['summary']['skipped'], 'force leaves nothing skipped'); + assert_true(! isset($wpdb->rows['unpushed']), 'unpushed row deleted under force'); + assert_true(! isset($wpdb->rows['has-pr']), 'PR row deleted under force'); + ++$GLOBALS['passed']; +}; + +/* + * Test 5: rows flagged missing_path=0 are never even considered. + */ +$tests[] = static function (): void { + $wpdb = new Prune_Test_Wpdb(); + $absent = sys_get_temp_dir() . '/dmc-prune-smoke-absent-f-' . getmypid(); + assert_true(! is_dir($absent), 'fixture absent path must be missing'); + + seed($wpdb, array( + // Present-on-disk row, not flagged missing — must be invisible to prune. + make_row(array( 'handle' => 'healthy', 'path' => $absent, 'missing_path' => 0 )), + make_row(array( 'handle' => 'ghost', 'path' => $absent, 'missing_path' => 1 )), + )); + $GLOBALS['wpdb'] = $wpdb; + + $repo = new WorktreeInventoryRepository(); + $result = $repo->pruneMissing(array()); + + assert_same(1, $result['summary']['total'], 'only missing_path=1 rows are candidates'); + assert_same(1, $result['summary']['deleted'], 'only the ghost is deleted'); + assert_true(isset($wpdb->rows['healthy']), 'healthy row untouched'); + assert_true(! isset($wpdb->rows['ghost']), 'ghost row deleted'); + ++$GLOBALS['passed']; +}; + +$GLOBALS['passed'] = 0; +foreach ( $tests as $i => $test ) { + try { + $test(); + } catch ( Throwable $e ) { + fwrite(STDERR, sprintf("Test %d FAILED: %s\n", $i + 1, $e->getMessage())); + exit(1); + } +} + +printf("worktree-inventory-prune-missing OK — %d/%d passed\n", $GLOBALS['passed'], count($tests)); From ef14f361af06bcf19706a2f86a6cb60b90f518df Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 6 Jul 2026 02:36:02 +0000 Subject: [PATCH 2/6] style: fix phpcs alignment and cast spacing in inventory prune-missing --- inc/Abilities/WorkspaceAbilities.php | 2 +- inc/Storage/WorktreeInventoryRepository.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index fdd8f17a..e5c9249a 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1586,8 +1586,8 @@ private function registerAbilities(): void { 'upserted' => array( 'type' => 'array' ), 'marked_missing' => array( 'type' => 'array' ), 'summary' => array( 'type' => 'object' ), + ), ), - ), 'execute_callback' => array( self::class, 'worktreeInventoryRefresh' ), 'permission_callback' => fn() => PermissionHelper::can_manage(), 'meta' => array( 'show_in_rest' => false ), diff --git a/inc/Storage/WorktreeInventoryRepository.php b/inc/Storage/WorktreeInventoryRepository.php index d8ac9f33..f7ebc15f 100644 --- a/inc/Storage/WorktreeInventoryRepository.php +++ b/inc/Storage/WorktreeInventoryRepository.php @@ -228,13 +228,13 @@ public function pruneMissing( array $opts = array() ): array { // Protect rows carrying unpushed work or an open PR unless forced. if ( ! $force ) { $unpushed = isset($row['unpushed_count']) ? (int) $row['unpushed_count'] : 0; - $pr_url = isset($row['pr_url']) ? trim((string) $row['pr_url']) : ''; + $pr_url = isset($row['pr_url']) ? trim( (string) $row['pr_url'] ) : ''; if ( $unpushed > 0 ) { $skipped[] = array( - 'handle' => $handle, - 'path' => $path, - 'reason' => 'unpushed_count', + 'handle' => $handle, + 'path' => $path, + 'reason' => 'unpushed_count', 'unpushed_count' => $unpushed, ); continue; From 925859371f63c4ed027e553a6d463a4aabba17de Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 6 Jul 2026 02:39:46 +0000 Subject: [PATCH 3/6] style: annotate $wpdb globals with @var to satisfy phpstan --- inc/Storage/WorktreeInventoryRepository.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/inc/Storage/WorktreeInventoryRepository.php b/inc/Storage/WorktreeInventoryRepository.php index f7ebc15f..9e769119 100644 --- a/inc/Storage/WorktreeInventoryRepository.php +++ b/inc/Storage/WorktreeInventoryRepository.php @@ -26,6 +26,7 @@ class WorktreeInventoryRepository { */ public static function install_schema(): void { global $wpdb; + /** @var \wpdb $wpdb */ if ( ! isset($wpdb) ) { return; @@ -90,6 +91,7 @@ public static function install_schema(): void { */ public static function table_name(): string { global $wpdb; + /** @var \wpdb $wpdb */ $prefix = isset($wpdb->prefix) ? (string) $wpdb->prefix : ''; return $prefix . self::TABLE; } @@ -102,6 +104,7 @@ public static function table_name(): string { */ public function upsert( array $row ): bool { global $wpdb; + /** @var \wpdb $wpdb */ if ( ! isset($wpdb) ) { return false; @@ -125,6 +128,7 @@ public function upsert( array $row ): bool { */ public function delete( string $handle ): bool { global $wpdb; + /** @var \wpdb $wpdb */ $handle = trim($handle); if ( '' === $handle || ! isset($wpdb) || ! method_exists($wpdb, 'delete') ) { @@ -142,6 +146,7 @@ public function delete( string $handle ): bool { */ public function get( string $handle ): ?array { global $wpdb; + /** @var \wpdb $wpdb */ $handle = trim($handle); if ( '' === $handle || ! isset($wpdb) ) { @@ -171,6 +176,7 @@ public function get( string $handle ): ?array { */ public function mark_missing( string $handle ): bool { global $wpdb; + /** @var \wpdb $wpdb */ $handle = trim($handle); if ( '' === $handle || ! isset($wpdb) || ! method_exists($wpdb, 'update') ) { @@ -296,6 +302,7 @@ public function pruneMissing( array $opts = array() ): array { */ private function missing_path_rows(): array { global $wpdb; + /** @var \wpdb $wpdb */ if ( ! isset($wpdb) || ! method_exists($wpdb, 'get_results') ) { return array(); @@ -318,6 +325,7 @@ private function missing_path_rows(): array { */ public function list( ?string $repo = null ): array { global $wpdb; + /** @var \wpdb $wpdb */ if ( ! isset($wpdb) || ! method_exists($wpdb, 'get_results') ) { return array(); From 93902a4e5fe5ea772376aa5eac8b44be48f296e5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 6 Jul 2026 02:42:40 +0000 Subject: [PATCH 4/6] Revert "style: annotate $wpdb globals with @var to satisfy phpstan" This reverts commit 925859371f63c4ed027e553a6d463a4aabba17de. --- inc/Storage/WorktreeInventoryRepository.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/inc/Storage/WorktreeInventoryRepository.php b/inc/Storage/WorktreeInventoryRepository.php index 9e769119..f7ebc15f 100644 --- a/inc/Storage/WorktreeInventoryRepository.php +++ b/inc/Storage/WorktreeInventoryRepository.php @@ -26,7 +26,6 @@ class WorktreeInventoryRepository { */ public static function install_schema(): void { global $wpdb; - /** @var \wpdb $wpdb */ if ( ! isset($wpdb) ) { return; @@ -91,7 +90,6 @@ public static function install_schema(): void { */ public static function table_name(): string { global $wpdb; - /** @var \wpdb $wpdb */ $prefix = isset($wpdb->prefix) ? (string) $wpdb->prefix : ''; return $prefix . self::TABLE; } @@ -104,7 +102,6 @@ public static function table_name(): string { */ public function upsert( array $row ): bool { global $wpdb; - /** @var \wpdb $wpdb */ if ( ! isset($wpdb) ) { return false; @@ -128,7 +125,6 @@ public function upsert( array $row ): bool { */ public function delete( string $handle ): bool { global $wpdb; - /** @var \wpdb $wpdb */ $handle = trim($handle); if ( '' === $handle || ! isset($wpdb) || ! method_exists($wpdb, 'delete') ) { @@ -146,7 +142,6 @@ public function delete( string $handle ): bool { */ public function get( string $handle ): ?array { global $wpdb; - /** @var \wpdb $wpdb */ $handle = trim($handle); if ( '' === $handle || ! isset($wpdb) ) { @@ -176,7 +171,6 @@ public function get( string $handle ): ?array { */ public function mark_missing( string $handle ): bool { global $wpdb; - /** @var \wpdb $wpdb */ $handle = trim($handle); if ( '' === $handle || ! isset($wpdb) || ! method_exists($wpdb, 'update') ) { @@ -302,7 +296,6 @@ public function pruneMissing( array $opts = array() ): array { */ private function missing_path_rows(): array { global $wpdb; - /** @var \wpdb $wpdb */ if ( ! isset($wpdb) || ! method_exists($wpdb, 'get_results') ) { return array(); @@ -325,7 +318,6 @@ private function missing_path_rows(): array { */ public function list( ?string $repo = null ): array { global $wpdb; - /** @var \wpdb $wpdb */ if ( ! isset($wpdb) || ! method_exists($wpdb, 'get_results') ) { return array(); From c2165aa24c611bbad97c9e5720fdc9d46ced8ca2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 6 Jul 2026 02:43:38 +0000 Subject: [PATCH 5/6] ci: scope phpstan method.nonObject ignore to inventory repository wpdb guards --- phpstan.neon | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/phpstan.neon b/phpstan.neon index cdd9f64d..9298004a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,3 +3,11 @@ parameters: - stubs/phpstan/datamachine-base-command.php - stubs/phpstan/datamachine-plugin-settings.php - stubs/phpstan/datamachine-system-task.php + ignoreErrors: + # The inventory repository intentionally guards $wpdb with isset()/method_exists() + # for multisite safety, which narrows the global to class-string|object and makes + # PHPStan reject the subsequent $wpdb method calls. The runtime object is always a + # \wpdb here; these calls are safe. + - + identifier: method.nonObject + path: inc/Storage/WorktreeInventoryRepository.php From c84b18c3f64da221e2d7a1d08ebe1c93e7eaadee Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 6 Jul 2026 02:47:17 +0000 Subject: [PATCH 6/6] refactor: drop dead null checks inside isset guards in inventory decode --- inc/Storage/WorktreeInventoryRepository.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/Storage/WorktreeInventoryRepository.php b/inc/Storage/WorktreeInventoryRepository.php index f7ebc15f..1fac310e 100644 --- a/inc/Storage/WorktreeInventoryRepository.php +++ b/inc/Storage/WorktreeInventoryRepository.php @@ -395,11 +395,11 @@ private function normalize_row( array $row ): array { 'last_seen_at' => $this->datetime($row['last_seen_at'] ?? $metadata['last_seen_at'] ?? null), 'last_probe_at' => current_time('mysql', true), 'last_probe_status' => ! empty($row['missing_path']) ? 'missing_path' : 'present', - 'dirty_count' => isset($row['dirty']) ? ( null === $row['dirty'] ? null : (int) $row['dirty'] ) : ( isset($row['dirty_count']) ? (int) $row['dirty_count'] : null ), + 'dirty_count' => isset($row['dirty']) ? (int) $row['dirty'] : ( isset($row['dirty_count']) ? (int) $row['dirty_count'] : null ), 'unpushed_count' => isset($row['unpushed_count']) ? (int) $row['unpushed_count'] : null, 'artifact_count' => isset($row['artifacts']) && is_array($row['artifacts']) ? count($row['artifacts']) : ( isset($row['artifact_count']) ? (int) $row['artifact_count'] : null ), 'artifact_size_bytes' => isset($row['artifact_size_bytes']) ? (int) $row['artifact_size_bytes'] : null, - 'size_bytes' => isset($row['size_bytes']) ? ( null === $row['size_bytes'] ? null : (int) $row['size_bytes'] ) : null, + 'size_bytes' => isset($row['size_bytes']) ? (int) $row['size_bytes'] : null, 'cleanup_signal' => $this->cleanup_signal($row, $metadata), 'missing_path' => ! empty($row['missing_path']) ? 1 : 0, 'metadata' => JsonCodec::encode_or_default($metadata),