From 14d41accd9a2454d5a8d9320e90edd272202c154 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:24:09 -0400 Subject: [PATCH 1/2] fix: report stale marker blockers during worktree prune --- inc/Cli/Commands/WorkspaceCommand.php | 19 ++- inc/Workspace/WorkspaceWorktreeLifecycle.php | 109 +++++++++++- tests/smoke-worktree-prune-no-git.php | 165 +++++++++++++++++-- 3 files changed, 269 insertions(+), 24 deletions(-) diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index dd13e9f7..2999740b 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -4320,12 +4320,25 @@ function ( $wt ) { return; case 'prune': - $pruned = $result['pruned'] ?? array(); - if ( empty($pruned) ) { + $pruned = (array) ( $result['pruned'] ?? array() ); + $stale_inventory = (array) ( $result['stale_inventory'] ?? array() ); + $stale_marker_blockers = (array) ( $result['stale_marker_blockers'] ?? array() ); + if ( empty($pruned) && empty($stale_inventory) && empty($stale_marker_blockers) ) { WP_CLI::log('Nothing to prune.'); return; } - WP_CLI::success(sprintf('Pruned worktree registry across: %s', implode(', ', $pruned))); + if ( ! empty($pruned) ) { + WP_CLI::success(sprintf('Pruned worktree registry across: %s', implode(', ', $pruned))); + } + if ( ! empty($stale_inventory) ) { + WP_CLI::success(sprintf('Removed %d stale worktree inventory artifact%s.', count($stale_inventory), 1 === count($stale_inventory) ? '' : 's')); + } + if ( ! empty($stale_marker_blockers) ) { + 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')); + foreach ( $stale_marker_blockers as $blocker ) { + WP_CLI::log(sprintf(' - %s at %s', (string) ( $blocker['handle'] ?? '' ), (string) ( $blocker['path'] ?? '' ))); + } + } return; case 'cleanup': diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 2e9f6418..fd89affc 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -1111,12 +1111,14 @@ function () use ( $primary_path, $wt_path, $force, $wt_handle ) { /** * Prune stale worktree registry entries across all primaries. * - * @return array{success: bool, pruned: array, skipped?: array, next_commands?: array, inventory?: array}|\WP_Error + * @return array{success: bool, pruned: array, skipped?: array, next_commands?: array, inventory?: array, stale_inventory?: array, stale_marker_blockers?: array}|\WP_Error */ public function worktree_prune(): array|\WP_Error { $pruned = array(); $skipped = array(); $next_commands = array(); + $stale_rows = array(); + $marker_blocks = array(); if ( ! is_dir($this->workspace_path) ) { return array( @@ -1159,12 +1161,107 @@ public function worktree_prune(): array|\WP_Error { return $refresh; } + $inventory_diagnostics = $this->prune_stale_worktree_inventory_rows(); + if ( $inventory_diagnostics instanceof \WP_Error ) { + return $inventory_diagnostics; + } + + $stale_rows = (array) ( $inventory_diagnostics['stale_inventory'] ?? array() ); + $marker_blocks = (array) ( $inventory_diagnostics['stale_marker_blockers'] ?? array() ); + foreach ( (array) ( $inventory_diagnostics['next_commands'] ?? array() ) as $command ) { + $next_commands[] = (string) $command; + } + + return array( + 'success' => true, + 'pruned' => $pruned, + 'skipped' => $skipped, + 'next_commands' => array_values(array_unique($next_commands)), + 'inventory' => $refresh, + 'stale_inventory' => $stale_rows, + 'stale_marker_blockers' => $marker_blocks, + ); + } + + /** + * Repair safe stale inventory rows and report marker blockers that need review. + * + * `git worktree prune` only repairs Git's own metadata. DMC can also retain + * cleanup-eligible inventory rows for worktrees that no longer exist, and + * those rows can block bounded cleanup even after Git reports nothing to + * prune. Missing-path rows are safe to forget because no checkout remains on + * disk; path-present stale markers are reported but left in place. + * + * @return array|\WP_Error + */ + private function prune_stale_worktree_inventory_rows(): array|\WP_Error { + $repository = $this->worktree_inventory(); + $stale_inventory = array(); + $stale_marker_blockers = array(); + $next_commands = array(); + + foreach ( $repository->list() as $row ) { + $handle = (string) ( $row['handle'] ?? '' ); + $repo = (string) ( $row['repo'] ?? '' ); + $path = (string) ( $row['path'] ?? '' ); + $parsed = '' !== $handle ? $this->parse_handle($handle) : array( 'is_worktree' => false ); + if ( '' === $handle || empty($parsed['is_worktree']) ) { + continue; + } + + if ( ! empty($row['missing_path']) && ( '' === $path || ! is_dir($path) ) ) { + if ( $repository->delete($handle) ) { + WorktreeContextInjector::forget_metadata($handle); + $stale_inventory[] = array( + 'handle' => $handle, + 'repo' => $repo, + 'path' => $path, + 'reason_code' => 'registry_artifact', + 'reason' => 'inventory row pointed at a missing worktree path and was removed from DMC metadata', + ); + } + continue; + } + + $marker = rtrim($path, '/') . '/.git'; + if ( ! is_file($marker) ) { + continue; + } + + $contents = file_get_contents($marker); + if ( false === $contents || ! preg_match('/^gitdir:\s*(.+)$/mi', $contents, $matches) ) { + continue; + } + + $gitdir = trim($matches[1]); + if ( ! str_contains($gitdir, '/.git/worktrees/') && ! str_contains($gitdir, '\\.git\\worktrees\\') ) { + continue; + } + + if ( file_exists($gitdir) ) { + continue; + } + + $primary_path = '' !== $repo ? $this->get_primary_path($repo) : (string) ( $row['primary_path'] ?? '' ); + $stale_marker_blockers[] = array( + 'handle' => $handle, + 'repo' => $repo, + 'path' => $path, + 'primary_path' => $primary_path, + 'gitdir' => $gitdir, + 'reason_code' => 'stale_worktree_marker', + 'reason' => 'worktree path still exists, but its .git marker points at a missing primary .git/worktrees entry; leaving checkout in place for operator review', + '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.', + ); + if ( '' !== $primary_path ) { + $next_commands[] = sprintf('git -C %s worktree prune -v', escapeshellarg($primary_path)); + } + } + return array( - 'success' => true, - 'pruned' => $pruned, - 'skipped' => $skipped, - 'next_commands' => array_values(array_unique($next_commands)), - 'inventory' => $refresh, + 'stale_inventory' => $stale_inventory, + 'stale_marker_blockers' => $stale_marker_blockers, + 'next_commands' => array_values(array_unique($next_commands)), ); } diff --git a/tests/smoke-worktree-prune-no-git.php b/tests/smoke-worktree-prune-no-git.php index 93846941..df709621 100644 --- a/tests/smoke-worktree-prune-no-git.php +++ b/tests/smoke-worktree-prune-no-git.php @@ -22,9 +22,113 @@ public static function get(): ?self if (! defined('ABSPATH') ) { define('ABSPATH', $tmp . '/wp/'); } - if (! defined('DATAMACHINE_WORKSPACE_PATH') ) { - define('DATAMACHINE_WORKSPACE_PATH', $tmp . '/workspace'); - } + if (! defined('DATAMACHINE_WORKSPACE_PATH') ) { + define('DATAMACHINE_WORKSPACE_PATH', $tmp . '/workspace'); + } + if (! defined('ARRAY_A') ) { + define('ARRAY_A', 'ARRAY_A'); + } + + if (! function_exists('current_time') ) { + function current_time( string $type, bool $gmt = false ): string // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + { + return '2026-06-14 00:00:00'; + } + } + if (! function_exists('wp_json_encode') ) { + function wp_json_encode( mixed $value, int $flags = 0, int $depth = 512 ): string|false + { + return json_encode($value, $flags, $depth); + } + } + + class DatamachineCodePruneFakeWpdb + { + public string $prefix = 'wp_'; + public int $insert_id = 0; + public int $rows_affected = 0; + public string $last_error = ''; + + /** + * @var array> + */ + public array $rows = array(); + public array $lock_rows = array(); + + public function get_var( string $query ): string|int|null + { + if ( str_contains($query, 'SHOW TABLES LIKE') ) { + return $this->prefix . 'datamachine_code_locks'; + } + + if ( str_contains($query, 'COUNT(*)') ) { + return 0; + } + + return null; + } + + public function query( string $query ): int + { + $this->rows_affected = 0; + return 0; + } + + public function replace( string $table, array $data ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + { + $this->rows[ (string) $data['handle'] ] = $data; + return 1; + } + + public function insert( string $table, array $data, ?array $format = null ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + { + ++$this->insert_id; + $data['id'] = $this->insert_id; + $this->lock_rows[ $this->insert_id ] = $data; + return 1; + } + + public function delete( string $table, array $where ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + { + unset($this->rows[ (string) $where['handle'] ]); + return 1; + } + + public function update( string $table, array $data, array $where, ?array $format = null, ?array $where_format = null ): int // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + { + if ( str_contains($table, 'datamachine_code_locks') ) { + $id = (int) ( $where['id'] ?? 0 ); + if ( isset($this->lock_rows[ $id ]) ) { + $this->lock_rows[ $id ] = array_merge($this->lock_rows[ $id ], $data); + return 1; + } + return 0; + } + + $handle = (string) ( $where['handle'] ?? '' ); + if ( ! isset($this->rows[ $handle ]) ) { + return 0; + } + + $this->rows[ $handle ] = array_merge($this->rows[ $handle ], $data); + return 1; + } + + public function get_results( string $sql, string $output ): array // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed + { + $rows = array_values($this->rows); + usort($rows, fn( array $a, array $b ): int => strcmp((string) $a['handle'], (string) $b['handle'])); + return $rows; + } + + public function prepare( string $query, mixed ...$args ): string + { + foreach ( $args as $arg ) { + $query = preg_replace('/%s/', "'" . addslashes((string) $arg) . "'", $query, 1); + } + return $query; + } + } if (! class_exists('WP_Error') ) { class WP_Error @@ -70,15 +174,43 @@ function is_wp_error( $value ): bool echo " fail {$label}\n"; }; - $old_path = getenv('PATH'); - putenv('PATH=/nonexistent-dmc-no-git'); - - mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo/.git', 0777, true); - - require __DIR__ . '/../inc/Support/RuntimeCapabilities.php'; - require __DIR__ . '/../inc/Support/ProcessRunner.php'; - require __DIR__ . '/../inc/Support/GitRunner.php'; - require __DIR__ . '/../inc/Support/PathSecurity.php'; + $old_path = getenv('PATH'); + putenv('PATH=/nonexistent-dmc-no-git'); + + mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo/.git', 0777, true); + mkdir(DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker', 0777, true); + file_put_contents(DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker/.git', 'gitdir: ' . DATAMACHINE_WORKSPACE_PATH . '/demo/.git/worktrees/demo@stale-marker' . "\n"); + + $GLOBALS['wpdb'] = new DatamachineCodePruneFakeWpdb(); + $GLOBALS['wpdb']->rows['demo@missing-path'] = array( + 'handle' => 'demo@missing-path', + 'repo' => 'demo', + 'branch' => 'missing-path', + 'path' => DATAMACHINE_WORKSPACE_PATH . '/demo@missing-path', + 'primary_path' => DATAMACHINE_WORKSPACE_PATH . '/demo', + 'is_primary' => 0, + 'is_worktree' => 1, + 'missing_path' => 1, + 'metadata' => array(), + 'updated_at' => '2026-06-13 00:00:00', + ); + $GLOBALS['wpdb']->rows['demo@stale-marker'] = array( + 'handle' => 'demo@stale-marker', + 'repo' => 'demo', + 'branch' => 'stale-marker', + 'path' => DATAMACHINE_WORKSPACE_PATH . '/demo@stale-marker', + 'primary_path' => DATAMACHINE_WORKSPACE_PATH . '/demo', + 'is_primary' => 0, + 'is_worktree' => 1, + 'missing_path' => 0, + 'metadata' => array(), + 'updated_at' => '2026-06-13 00:00:00', + ); + + require __DIR__ . '/../inc/Support/RuntimeCapabilities.php'; + require __DIR__ . '/../inc/Support/ProcessRunner.php'; + require __DIR__ . '/../inc/Support/GitRunner.php'; + require __DIR__ . '/../inc/Support/PathSecurity.php'; require __DIR__ . '/../inc/Workspace/WorktreeContextInjector.php'; require __DIR__ . '/../inc/Workspace/WorkspaceMutationLock.php'; require __DIR__ . '/../inc/Workspace/Workspace.php'; @@ -89,9 +221,12 @@ function is_wp_error( $value ): bool $result = $workspace->worktree_prune(); $assert('prune returns success instead of git-unavailable error', ! is_wp_error($result) && true === ( $result['success'] ?? false )); - $assert('prune records skipped primary', ! is_wp_error($result) && 'demo' === ( $result['skipped'][0]['repo'] ?? '' )); - $assert('prune returns host git command', ! is_wp_error($result) && str_contains((string) ( $result['next_commands'][0] ?? '' ), 'git -C')); - $assert('inventory refresh still runs', ! is_wp_error($result) && isset($result['inventory']['summary'])); + $assert('prune records skipped primary', ! is_wp_error($result) && 'demo' === ( $result['skipped'][0]['repo'] ?? '' )); + $assert('prune returns host git command', ! is_wp_error($result) && str_contains((string) ( $result['next_commands'][0] ?? '' ), 'git -C')); + $assert('inventory refresh still runs', ! is_wp_error($result) && isset($result['inventory']['summary'])); + $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'])); + $assert('prune reports path-present stale marker blocker', ! is_wp_error($result) && 'stale_worktree_marker' === ( $result['stale_marker_blockers'][0]['reason_code'] ?? '' )); + $assert('prune leaves path-present stale marker row for review', isset($GLOBALS['wpdb']->rows['demo@stale-marker'])); putenv(false === $old_path ? 'PATH' : 'PATH=' . $old_path); From d2ad11c837597ccd165ae35d8d00c143a26b25e7 Mon Sep 17 00:00:00 2001 From: "homeboy-ci[bot]" <266378653+homeboy-ci[bot]@users.noreply.github.com> Date: Sun, 14 Jun 2026 08:10:15 -0400 Subject: [PATCH 2/2] fix: satisfy worktree prune lint --- inc/Workspace/WorkspaceWorktreeLifecycle.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index fd89affc..d80389a0 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -1228,7 +1228,7 @@ private function prune_stale_worktree_inventory_rows(): array|\WP_Error { continue; } - $contents = file_get_contents($marker); + $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. if ( false === $contents || ! preg_match('/^gitdir:\s*(.+)$/mi', $contents, $matches) ) { continue; } @@ -1242,7 +1242,7 @@ private function prune_stale_worktree_inventory_rows(): array|\WP_Error { continue; } - $primary_path = '' !== $repo ? $this->get_primary_path($repo) : (string) ( $row['primary_path'] ?? '' ); + $primary_path = '' !== $repo ? $this->get_primary_path($repo) : (string) ( $row['primary_path'] ?? '' ); $stale_marker_blockers[] = array( 'handle' => $handle, 'repo' => $repo,