diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 70218523..e5c9249a 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -1594,6 +1594,42 @@ private function registerAbilities(): void { ) ); + 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..1fac310e 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. * @@ -275,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), 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/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 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));