diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 3ad726c9..d500524e 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2588,20 +2588,51 @@ private function registerAbilities(): void { 'datamachine-code/workspace-worktree-prune', array( 'label' => 'Prune Workspace Worktrees', - 'description' => 'Run git worktree prune across all primary checkouts to drop stale registry entries.', + 'description' => 'Preview or prune stale Git worktree registry entries across managed primaries. Defaults to a bounded dry-run. Pass dry_run=false to apply. Does not delete live worktrees.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', - 'properties' => array(), + 'properties' => array( + 'dry_run' => array( + 'type' => 'boolean', + 'description' => 'If true or omitted, preview stale registrations without pruning. Pass false to apply.', + ), + 'limit' => array( + 'type' => 'integer', + 'description' => 'Maximum primaries to inspect. Defaults to 25, maximum 200.', + ), + 'after_repo' => array( + 'type' => 'string', + 'description' => 'Last inspected primary for bounded keyset continuation.', + ), + 'until_budget' => array( + 'type' => 'string', + 'description' => 'Optional compact wall-clock budget such as 30s.', + ), + ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'pruned' => array( + 'success' => array( 'type' => 'boolean' ), + 'dry_run' => array( 'type' => 'boolean' ), + 'pruned' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'would_prune' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), + 'candidates' => array( 'type' => 'array' ), + 'scanned_primary_count' => array( 'type' => 'integer' ), + 'next_commands' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'apply_command' => array( 'type' => array( 'string', 'null' ) ), + 'partial' => array( 'type' => 'boolean' ), + 'continuation' => array( 'type' => 'object' ), ), ), 'execute_callback' => array( self::class, 'worktreePrune' ), @@ -5415,17 +5446,25 @@ public static function worktreeRemove( array $input ): array|\WP_Error { /** * Prune stale worktree registry entries. * - * @param array $input Unused. + * @param array $input Prune options. * @return array */ - public static function worktreePrune( array $input ): array|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + public static function worktreePrune( array $input ): array|\WP_Error { + $opts = array( + 'dry_run' => ! array_key_exists( 'dry_run', $input ) || ! empty( $input['dry_run'] ), + 'limit' => isset( $input['limit'] ) ? (int) $input['limit'] : 25, + 'until_budget' => isset( $input['until_budget'] ) && '' !== trim( (string) $input['until_budget'] ) ? trim( (string) $input['until_budget'] ) : '30s', + ); + if ( isset( $input['after_repo'] ) && '' !== trim( (string) $input['after_repo'] ) ) { + $opts['after_repo'] = trim( (string) $input['after_repo'] ); + } if ( RemoteWorkspaceBackend::has_registered_state() && RemoteWorkspaceBackend::should_handle() ) { - $result = ( new RemoteWorkspaceBackend() )->worktree_prune(); + $result = ( new RemoteWorkspaceBackend() )->worktree_prune( $opts ); return self::decorate_remote_workspace_result( 'worktree_prune', $result ); } $workspace = new Workspace(); - return $workspace->worktree_prune(); + return $workspace->worktree_prune( $opts ); } /** diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 1b5f0728..4f909285 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -432,8 +432,15 @@ public static function worktree_command_definitions(): array { ), 'prune' => array( 'shortdesc' => 'Prune stale Git worktree metadata.', - 'longdesc' => "Prunes stale Git worktree registry entries across managed primaries.\n\n## EXAMPLES\n\n wp datamachine-code workspace worktree prune --format=json", - 'synopsis' => array( $format ), + 'longdesc' => "Previews stale Git worktree registry entries across managed primaries. Apply with --yes. Existing checkouts are never deleted.\n\n## EXAMPLES\n\n wp datamachine-code workspace worktree prune --dry-run --format=json\n wp datamachine-code workspace worktree prune --yes --format=json", + 'synopsis' => array( + $flag( 'dry-run', 'Preview stale Git registrations without pruning. Default when --yes is omitted.' ), + $flag( 'yes', 'Apply the reviewed prune.' ), + $option( 'limit', 'Maximum primaries to inspect. Default 25, maximum 200.' ), + $option( 'after-repo', 'Last inspected primary for bounded keyset continuation.' ), + $option( 'until-budget', 'Optional compact wall-clock budget such as 30s.' ), + $format, + ), ), 'refresh-context' => array( 'shortdesc' => 'Refresh a worktree\'s injected site context.', @@ -4489,7 +4496,7 @@ private function renderGitOperationResult( string $operation, array $result, arr * : Lifecycle state to record when finalizing a worktree. * * [--dry-run] - * : Preview cleanup candidates without removing anything (cleanup and locks only). + * : Preview without mutation (prune, cleanup, and locks). Prune defaults to dry-run unless --yes is passed. * * [--prune-stale] * : For `locks`, prune expired DB lock rows and old unlocked filesystem lock @@ -4669,8 +4676,9 @@ private function renderGitOperationResult( string $operation, array $result, arr * wp datamachine-code workspace worktree remove data-machine fix/foo --force * wp datamachine-code workspace worktree remove data-machine@fix-foo --force * - * # Prune stale worktree registry entries across all primaries - * wp datamachine-code workspace worktree prune + * # Preview stale Git worktree registry entries, then apply the reviewed prune + * wp datamachine-code workspace worktree prune --dry-run --format=json + * wp datamachine-code workspace worktree prune --yes --format=json * * # Inspect and safely prune stale workspace mutation locks * wp datamachine-code workspace worktree locks --format=json @@ -5139,6 +5147,19 @@ private function worktree( array $args, array $assoc_args ): void { $input['include_disk'] = false; break; + case 'prune': + $input['dry_run'] = ! empty( $assoc_args['dry-run'] ) || empty( $assoc_args['yes'] ); + if ( isset( $assoc_args['limit'] ) ) { + $input['limit'] = (int) $assoc_args['limit']; + } + if ( isset( $assoc_args['after-repo'] ) && '' !== trim( (string) $assoc_args['after-repo'] ) ) { + $input['after_repo'] = trim( (string) $assoc_args['after-repo'] ); + } + if ( isset( $assoc_args['until-budget'] ) && '' !== trim( (string) $assoc_args['until-budget'] ) ) { + $input['until_budget'] = trim( (string) $assoc_args['until-budget'] ); + } + break; + case 'remove': // Accept either the two-arg ` ` form or a single // `@` handle (as printed by `list`/`path`/cleanup @@ -5858,14 +5879,33 @@ function ( $wt ) { return; case 'prune': + if ( 'json' === (string) ( $assoc_args['format'] ?? '' ) ) { + $this->renderer()->json( $result ); + return; + } + $dry_run = ! empty( $result['dry_run'] ); $pruned = (array) ( $result['pruned'] ?? array() ); + $would_prune = (array) ( $result['would_prune'] ?? array() ); + $candidates = (array) ( $result['candidates'] ?? 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 ) ) { + if ( empty( $pruned ) && empty( $would_prune ) && empty( $candidates ) && empty( $stale_inventory ) && empty( $stale_marker_blockers ) ) { WP_CLI::log( 'Nothing to prune.' ); + if ( ! empty( $result['continuation']['available'] ) && ! empty( $result['continuation']['after_repo'] ) ) { + WP_CLI::log( 'More primaries: rerun with --after-repo=' . (string) $result['continuation']['after_repo'] . ' --dry-run --format=json' ); + } return; } - if ( ! empty( $pruned ) ) { + if ( $dry_run ) { + $repos = array() !== $would_prune ? $would_prune : array_values( array_unique( array_map( static fn( array $row ): string => (string) ( $row['repo'] ?? '' ), $candidates ) ) ); + WP_CLI::log( sprintf( 'Would prune %d stale Git registration%s across: %s', count( $candidates ), 1 === count( $candidates ) ? '' : 's', implode( ', ', $repos ) ) ); + foreach ( $candidates as $candidate ) { + WP_CLI::log( sprintf( ' - %s (%s)', (string) ( $candidate['path'] ?? '' ), (string) ( $candidate['reason'] ?? 'prunable' ) ) ); + } + if ( ! empty( $result['apply_command'] ) ) { + WP_CLI::log( 'Apply: ' . (string) $result['apply_command'] ); + } + } elseif ( ! empty( $pruned ) ) { WP_CLI::success( sprintf( 'Pruned worktree registry across: %s', implode( ', ', $pruned ) ) ); } if ( ! empty( $stale_inventory ) ) { @@ -5877,6 +5917,9 @@ function ( $wt ) { WP_CLI::log( sprintf( ' - %s at %s', (string) ( $blocker['handle'] ?? '' ), (string) ( $blocker['path'] ?? '' ) ) ); } } + if ( ! empty( $result['continuation']['available'] ) && ! empty( $result['continuation']['after_repo'] ) ) { + WP_CLI::log( 'More primaries: rerun with --after-repo=' . (string) $result['continuation']['after_repo'] . ( $dry_run ? ' --dry-run' : ' --yes' ) . ' --format=json' ); + } return; case 'cleanup': diff --git a/inc/Workspace/GitCheckout.php b/inc/Workspace/GitCheckout.php index 4437b1c1..eab3c911 100644 --- a/inc/Workspace/GitCheckout.php +++ b/inc/Workspace/GitCheckout.php @@ -72,6 +72,39 @@ public static function deletion_protection( string $candidate, string $workspace return null; } + /** + * Parse `git worktree list --porcelain` into registrations Git can prune. + * + * @return array + */ + public static function prunable_registrations_from_porcelain( string $porcelain ): array { + $registrations = array(); + $current_path = ''; + $lines = preg_split( '/\r?\n/', $porcelain ); + foreach ( false === $lines ? array() : $lines as $line ) { + if ( str_starts_with( $line, 'worktree ' ) ) { + $current_path = trim( substr( $line, strlen( 'worktree ' ) ) ); + continue; + } + if ( ! str_starts_with( $line, 'prunable ' ) || '' === $current_path ) { + continue; + } + $registrations[] = array( + 'path' => $current_path, + 'reason' => trim( substr( $line, strlen( 'prunable ' ) ) ), + ); + } + + return $registrations; + } + + /** Git args that preview or immediately drop proven-stale worktree registrations. */ + public static function prune_git_args( bool $dry_run ): string { + return $dry_run + ? 'worktree prune --dry-run -v --expire=now' + : 'worktree prune -v --expire=now'; + } + private static function path_contains( string $container, string $path ): bool { $container = rtrim($container, '/') . '/'; $path = rtrim($path, '/') . '/'; diff --git a/inc/Workspace/RemoteWorkspaceBackend.php b/inc/Workspace/RemoteWorkspaceBackend.php index db67159a..78003afe 100644 --- a/inc/Workspace/RemoteWorkspaceBackend.php +++ b/inc/Workspace/RemoteWorkspaceBackend.php @@ -464,29 +464,44 @@ private function find_worktree_handle_by_repo_branch( array $state, string $repo /** * Prune remote worktree state whose primary repo registration disappeared. * + * @param array{dry_run?:bool} $opts Prune options. * @return array */ - public function worktree_prune(): array { - $state = $this->state(); - $pruned = array(); + public function worktree_prune( array $opts = array() ): array { + $dry_run = ! empty( $opts['dry_run'] ); + $state = $this->state(); + $pruned = array(); + $candidates = array(); foreach ( $state['worktrees'] as $handle => $worktree ) { $repo_name = is_array($worktree) ? (string) ( $worktree['repo_name'] ?? '' ) : ''; if ( '' !== $repo_name && isset($state['repos'][ $repo_name ]) ) { continue; } + $candidates[] = array( + 'handle' => (string) $handle, + 'repo' => $repo_name, + ); + if ( $dry_run ) { + $pruned[] = (string) $handle; + continue; + } + unset($state['worktrees'][ $handle ]); $pruned[] = (string) $handle; } - if ( array() !== $pruned ) { + if ( ! $dry_run && array() !== $candidates ) { $this->save_state($state); } return array( - 'success' => true, - 'backend' => 'github_api', - 'pruned' => $pruned, + 'success' => true, + 'backend' => 'github_api', + 'dry_run' => $dry_run, + 'pruned' => $dry_run ? array() : $pruned, + 'would_prune' => $dry_run ? $pruned : array(), + 'candidates' => $candidates, ); } diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 2fb01549..b6b491f7 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -29,6 +29,12 @@ trait WorkspaceWorktreeLifecycle { /** One deadline covers finalizer admission, probes, persistence, and readback. */ private const WORKTREE_FINALIZE_DEFAULT_BUDGET = '10s'; + /** Maximum primaries inspected by a bounded worktree prune page. */ + private const WORKTREE_PRUNE_MAX_LIMIT = 200; + + /** Default wall-clock budget for a bounded worktree prune page. */ + private const WORKTREE_PRUNE_DEFAULT_BUDGET = '30s'; + /** * Produce a non-mutating, digest-addressed worktree allocation decision. * @@ -5556,89 +5562,189 @@ function () use ( $primary_path, $wt_path, $force, $wt_handle ) { } /** - * Prune stale worktree registry entries across all primaries. + * Prune stale worktree registry entries across managed primaries. * * Git removes only registrations whose worktree path is absent. Expiring now * makes that reconciliation immediate without touching existing checkouts, - * including dirty or unpushed worktrees. + * including dirty or unpushed worktrees. Bounded pages preview first through + * cheap porcelain probes and skip the full inventory refresh. * - * @return array{success: bool, pruned: array, skipped?: array, next_commands?: array, inventory?: array, stale_inventory?: array, stale_marker_blockers?: array}|\WP_Error + * @param array{dry_run?:bool,limit?:int,after_repo?:string,until_budget?:string} $opts Prune options. + * @return array{success: bool, dry_run: bool, pruned: array, candidates: array, skipped?: array, next_commands?: array, inventory?: array, stale_inventory?: array, stale_marker_blockers?: array}|\WP_Error */ - public function worktree_prune(): array|\WP_Error { + public function worktree_prune( array $opts = array() ): array|\WP_Error { + $dry_run = ! empty( $opts['dry_run'] ); + $limit = array_key_exists( 'limit', $opts ) ? (int) $opts['limit'] : 0; + $after_repo = isset( $opts['after_repo'] ) ? trim( (string) $opts['after_repo'] ) : ''; $pruned = array(); + $candidates = array(); $skipped = array(); $next_commands = array(); $stale_rows = array(); $marker_blocks = array(); $marker_repaired = array(); + $inspected = 0; + $last_repo = null; + $stopped_early = false; + $budget = null; + $refresh = null; - if ( ! is_dir($this->workspace_path) ) { - return array( - 'success' => true, - 'pruned' => $pruned, - ); + if ( $limit < 0 || $limit > self::WORKTREE_PRUNE_MAX_LIMIT ) { + return new \WP_Error( 'invalid_worktree_prune_limit', 'Worktree prune limit must be an integer between 1 and 200, or omitted for an unbounded apply.', array( 'status' => 400 ) ); } - $entries = scandir($this->workspace_path); - foreach ( $entries as $entry ) { - if ( '.' === $entry || '..' === $entry || str_contains($entry, '@') ) { - continue; + if ( $dry_run || $limit > 0 || isset( $opts['until_budget'] ) || '' !== $after_repo ) { + $budget = WallClockBudget::from_duration( $opts['until_budget'] ?? self::WORKTREE_PRUNE_DEFAULT_BUDGET, self::WORKTREE_PRUNE_DEFAULT_BUDGET, 'invalid_worktree_prune_budget' ); + if ( is_wp_error( $budget ) ) { + return $budget; } - $primary_path = $this->workspace_path . '/' . $entry; - if ( ! GitCheckout::exists($primary_path) ) { - continue; - } - $result = WorkspaceMutationLock::with_repo( - $this->workspace_path, - $entry, - fn() => $this->run_git($primary_path, 'worktree prune -v --expire=now') - ); - if ( is_wp_error($result) ) { - if ( 'datamachine_workspace_git_unavailable' === $result->get_error_code() ) { - $skipped[] = array( + } + + if ( is_dir( $this->workspace_path ) ) { + $entries = scandir( $this->workspace_path ); + $entries = false === $entries ? array() : $entries; + sort( $entries ); + $skipping = '' !== $after_repo; + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry || str_contains( $entry, '@' ) ) { + continue; + } + $primary_path = $this->workspace_path . '/' . $entry; + if ( ! GitCheckout::exists( $primary_path ) ) { + continue; + } + if ( $skipping ) { + if ( $entry === $after_repo ) { + $skipping = false; + } + continue; + } + if ( null !== $budget && $budget->expired() ) { + $stopped_early = true; + break; + } + if ( $limit > 0 && $inspected >= $limit ) { + $stopped_early = true; + break; + } + + $timeout = null !== $budget ? $budget->probe_timeout_seconds( self::WORKTREE_LIST_GIT_PROBE_TIMEOUT_SECONDS ) : 0; + $list = WorkspaceMutationLock::with_repo( + $this->workspace_path, + $entry, + fn() => $this->run_git( $primary_path, 'worktree list --porcelain', $timeout ) + ); + if ( is_wp_error( $list ) ) { + if ( 'datamachine_workspace_git_unavailable' === $list->get_error_code() ) { + $skipped[] = array( + 'repo' => $entry, + 'primary_path' => $primary_path, + 'reason' => $list->get_error_message(), + ); + $next_commands[] = sprintf( 'git -C %s worktree prune -v --expire=now', escapeshellarg( $primary_path ) ); + ++$inspected; + $last_repo = $entry; + continue; + } + return $list; + } + + $registrations = GitCheckout::prunable_registrations_from_porcelain( (string) ( $list['output'] ?? '' ) ); + foreach ( $registrations as $registration ) { + $candidates[] = array( 'repo' => $entry, 'primary_path' => $primary_path, - 'reason' => $result->get_error_message(), + 'path' => (string) ( $registration['path'] ?? '' ), + 'reason' => (string) ( $registration['reason'] ?? '' ), ); - $next_commands[] = sprintf('git -C %s worktree prune -v --expire=now', escapeshellarg($primary_path)); - continue; } - return $result; - } - // Git emits a verbose line for every removed registration. Preserve the - // existing `pruned` result as evidence of actual reconciliation instead - // of reporting every primary that was merely scanned. - if ( '' !== trim( (string) ( $result['output'] ?? '' )) ) { - $pruned[] = $entry; + + if ( ! $dry_run && array() !== $registrations ) { + $result = WorkspaceMutationLock::with_repo( + $this->workspace_path, + $entry, + fn() => $this->run_git( $primary_path, GitCheckout::prune_git_args( false ), $timeout ) + ); + if ( is_wp_error( $result ) ) { + if ( 'datamachine_workspace_git_unavailable' === $result->get_error_code() ) { + $skipped[] = array( + 'repo' => $entry, + 'primary_path' => $primary_path, + 'reason' => $result->get_error_message(), + ); + $next_commands[] = sprintf( 'git -C %s worktree prune -v --expire=now', escapeshellarg( $primary_path ) ); + ++$inspected; + $last_repo = $entry; + continue; + } + return $result; + } + $pruned[] = $entry; + } elseif ( $dry_run && array() !== $registrations ) { + $pruned[] = $entry; + } + + ++$inspected; + $last_repo = $entry; } } - $refresh = $this->worktree_inventory_refresh(); - if ( $refresh instanceof \WP_Error ) { - return $refresh; - } + $repair_inventory = ! $dry_run && 0 === $limit && '' === $after_repo && ! isset( $opts['until_budget'] ); + if ( $repair_inventory ) { + $refresh = $this->worktree_inventory_refresh(); + if ( $refresh instanceof \WP_Error ) { + return $refresh; + } - $inventory_diagnostics = $this->prune_stale_worktree_inventory_rows(); - if ( $inventory_diagnostics instanceof \WP_Error ) { - return $inventory_diagnostics; + $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() ); + $marker_repaired = (array) ( $inventory_diagnostics['stale_marker_repaired'] ?? array() ); + foreach ( (array) ( $inventory_diagnostics['next_commands'] ?? array() ) as $command ) { + $next_commands[] = (string) $command; + } } - $stale_rows = (array) ( $inventory_diagnostics['stale_inventory'] ?? array() ); - $marker_blocks = (array) ( $inventory_diagnostics['stale_marker_blockers'] ?? array() ); - $marker_repaired = (array) ( $inventory_diagnostics['stale_marker_repaired'] ?? array() ); - foreach ( (array) ( $inventory_diagnostics['next_commands'] ?? array() ) as $command ) { - $next_commands[] = (string) $command; + if ( $dry_run ) { + $apply_flags = '--yes --format=json'; + if ( $limit > 0 ) { + $apply_flags = sprintf( '--yes --limit=%d --format=json', $limit ); + } + $next_commands[] = 'studio wp datamachine-code workspace worktree prune ' . $apply_flags; + $next_commands[] = 'studio wp datamachine-code workspace inventory prune-missing --dry-run --format=json'; + if ( $stopped_early && is_string( $last_repo ) ) { + $continue_flags = sprintf( '--dry-run --after-repo=%s --format=json', $last_repo ); + if ( $limit > 0 ) { + $continue_flags = sprintf( '--dry-run --limit=%d --after-repo=%s --format=json', $limit, $last_repo ); + } + array_unshift( $next_commands, 'studio wp datamachine-code workspace worktree prune ' . $continue_flags ); + } } return array( 'success' => true, - 'pruned' => $pruned, + 'dry_run' => $dry_run, + 'pruned' => $dry_run ? array() : $pruned, + 'would_prune' => $dry_run ? $pruned : array(), + 'candidates' => $candidates, + 'scanned_primary_count' => $inspected, 'skipped' => $skipped, - 'next_commands' => array_values(array_unique($next_commands)), + 'next_commands' => array_values( array_unique( $next_commands ) ), 'inventory' => $refresh, 'stale_inventory' => $stale_rows, 'stale_marker_blockers' => $marker_blocks, 'stale_marker_repaired' => $marker_repaired, + 'partial' => $stopped_early, + 'continuation' => array( + 'available' => $stopped_early && is_string( $last_repo ), + 'after_repo' => $stopped_early ? $last_repo : null, + 'reason' => $stopped_early ? ( null !== $budget && $budget->expired() ? 'scan_budget_exhausted' : 'more_rows' ) : null, + ), + 'apply_command' => $dry_run ? 'studio wp datamachine-code workspace worktree prune --yes --format=json' : null, ); } diff --git a/inc/Workspace/WorktreeDiskBudget.php b/inc/Workspace/WorktreeDiskBudget.php index fed68cf5..15535435 100644 --- a/inc/Workspace/WorktreeDiskBudget.php +++ b/inc/Workspace/WorktreeDiskBudget.php @@ -376,6 +376,7 @@ public static function evaluate( array $metrics, array $thresholds = array(), bo $budget['advisory_fingerprint'] = self::advisory_fingerprint( $budget ); $budget['evidence_reference'] = sprintf( '%s@%s', self::DIAGNOSTIC_ID, substr( $budget['advisory_fingerprint'], 0, 12 ) ); $budget['evidence_command'] = 'studio wp datamachine-code workspace hygiene --format=json'; + $budget['preview_command'] = ''; $budget['recovery_actions'] = array( array( 'action' => 'inspect_full_capacity_evidence', @@ -386,6 +387,22 @@ public static function evaluate( array $metrics, array $thresholds = array(), bo 'command' => 'studio wp datamachine-code workspace hygiene --include-sizes --size-limit=100 --format=json', ), ); + if ( in_array( 'worktree_count_warning_threshold', $trigger_reasons, true ) ) { + $budget['preview_command'] = 'studio wp datamachine-code workspace worktree prune --dry-run --format=json'; + $budget['recovery_actions'] = array_merge( + array( + array( + 'action' => 'preview_stale_git_registrations', + 'command' => $budget['preview_command'], + ), + array( + 'action' => 'preview_missing_inventory_rows', + 'command' => 'studio wp datamachine-code workspace inventory prune-missing --dry-run --format=json', + ), + ), + $budget['recovery_actions'] + ); + } return $budget; } @@ -649,6 +666,19 @@ public static function format_advisory( array $budget ): string { } $reference = (string) ( $budget['evidence_reference'] ?? self::DIAGNOSTIC_ID ); $admission = ! empty( $budget['creation_allowed'] ) ? 'admission allowed' : 'admission blocked'; + $preview = trim( (string) ( $budget['preview_command'] ?? '' ) ); + $evidence = (string) ( $budget['evidence_command'] ?? 'studio wp datamachine-code workspace hygiene --format=json' ); + if ( '' !== $preview && $preview !== $evidence ) { + return sprintf( + 'Capacity advisory [%s]: status=%s; %s; triggers=%s. Preview: %s. Full evidence: %s', + $reference, + (string) ( $budget['status'] ?? 'unknown' ), + $admission, + implode(',', $codes), + $preview, + $evidence + ); + } return sprintf( 'Capacity advisory [%s]: status=%s; %s; triggers=%s. Full evidence: %s', @@ -656,7 +686,7 @@ public static function format_advisory( array $budget ): string { (string) ( $budget['status'] ?? 'unknown' ), $admission, implode(',', $codes), - (string) ( $budget['evidence_command'] ?? 'studio wp datamachine-code workspace hygiene --format=json' ) + $evidence ); } diff --git a/tests/workspace-capacity-advisory.php b/tests/workspace-capacity-advisory.php index b8ed2151..d452c3a0 100644 --- a/tests/workspace-capacity-advisory.php +++ b/tests/workspace-capacity-advisory.php @@ -36,15 +36,22 @@ function capacity_advisory_assert( bool $condition, string $message ): void { capacity_advisory_assert(($warning['advisory_fingerprint'] ?? null) !== ($new_threshold['advisory_fingerprint'] ?? null), 'A changed active threshold must produce a new fingerprint.'); capacity_advisory_assert(($warning['advisory_fingerprint'] ?? null) !== ($blocked['advisory_fingerprint'] ?? null), 'A blocking state change must produce a new fingerprint.'); capacity_advisory_assert(str_starts_with((string) ($warning['evidence_reference'] ?? ''), 'workspace_capacity@'), 'Capacity evidence must expose a compact reference.'); -capacity_advisory_assert(2 === count((array) ($warning['recovery_actions'] ?? array())), 'Structured capacity evidence must retain bounded recovery actions.'); +capacity_advisory_assert(4 === count((array) ($warning['recovery_actions'] ?? array())), 'Worktree-count warnings must lead with preview-first recovery actions.'); +capacity_advisory_assert('preview_stale_git_registrations' === ($warning['recovery_actions'][0]['action'] ?? null), 'Worktree-count warnings must preview stale Git registrations first.'); +capacity_advisory_assert('studio wp datamachine-code workspace worktree prune --dry-run --format=json' === ($warning['preview_command'] ?? null), 'Worktree-count warnings must expose a prune dry-run preview command.'); capacity_advisory_assert(50 * 1073741824 === ($warning['filesystem_free_bytes'] ?? null) && 5000000 === ($warning['filesystem_free_inodes'] ?? null), 'Advisory metadata must not replace full byte and inode evidence.'); $line = WorktreeDiskBudget::format_advisory($warning); capacity_advisory_assert(1 === count(explode("\n", $line)), 'Default capacity advisory must fit on one line.'); capacity_advisory_assert(str_contains($line, (string) $warning['evidence_reference']) && str_contains($line, 'admission allowed'), 'Compact advisory must expose its evidence reference and admission state.'); +capacity_advisory_assert(str_contains($line, 'Preview: studio wp datamachine-code workspace worktree prune --dry-run --format=json'), 'Compact worktree-count advisory must include the prune dry-run preview.'); capacity_advisory_assert(str_contains(WorktreeDiskBudget::format_summary($blocked), 'Admission: blocked'), 'Blocking capacity must retain the complete immediate summary.'); capacity_advisory_assert(str_contains(WorktreeDiskBudget::format_trigger_reasons($blocked)[0] ?? '', 'Creation is blocked unless --force is explicit.'), 'Blocking capacity must retain immediate remediation.'); capacity_advisory_assert(in_array('filesystem_free_bytes_measurement_unavailable', $measurement_warning['trigger_reasons'] ?? array(), true), 'Failed capacity measurement must retain a compact typed advisory.'); capacity_advisory_assert('' !== WorktreeDiskBudget::format_advisory($measurement_warning), 'Failed capacity measurement must remain visible in default human output.'); +$disk_only = WorktreeDiskBudget::evaluate(array_merge($metrics, array( 'worktree_count' => 10, 'free_bytes' => 12 * 1073741824 ))); +capacity_advisory_assert(2 === count((array) ($disk_only['recovery_actions'] ?? array())), 'Disk-only warnings must keep the compact evidence recovery actions.'); +capacity_advisory_assert(! str_contains(WorktreeDiskBudget::format_advisory($disk_only), 'worktree prune --dry-run'), 'Disk-only advisories must not advertise Git registration prune as the preview.'); + echo "workspace-capacity-advisory: ok\n"; diff --git a/tests/worktree-command-help-snapshots.php b/tests/worktree-command-help-snapshots.php index 5d6ad6b2..8df0613f 100644 --- a/tests/worktree-command-help-snapshots.php +++ b/tests/worktree-command-help-snapshots.php @@ -85,5 +85,10 @@ function worktree_help_assert( bool $condition, string $message ): void { worktree_help_assert(array_column($cleanup['synopsis'], 'name') === array( 'repo', 'dry-run', 'force', 'skip-github', 'inventory-only', 'include-repaired-metadata', 'limit', 'offset', 'until-budget', 'apply-plan', 'older-than', 'sort', 'format', 'verbose', 'only' ), 'Cleanup help option snapshot changed.'); worktree_help_assert(str_contains($cleanup['longdesc'], 'worktree cleanup --dry-run --format=json'), 'Cleanup help lacks a review example.'); + $prune = $definitions['prune']; + worktree_help_assert(array_column($prune['synopsis'], 'name') === array( 'dry-run', 'yes', 'limit', 'after-repo', 'until-budget', 'format' ), 'Prune help option snapshot changed.'); + worktree_help_assert(str_contains($prune['longdesc'], 'worktree prune --dry-run --format=json'), 'Prune help lacks a preview example.'); + worktree_help_assert(str_contains($prune['longdesc'], 'worktree prune --yes --format=json'), 'Prune help lacks an apply example.'); + echo "worktree-command-help-snapshots: ok\n"; } diff --git a/tests/worktree-command-wp-cli-synopsis.php b/tests/worktree-command-wp-cli-synopsis.php index 85649e32..f4996dc5 100644 --- a/tests/worktree-command-wp-cli-synopsis.php +++ b/tests/worktree-command-wp-cli-synopsis.php @@ -55,6 +55,9 @@ function worktree_wp_cli_synopsis_assert( bool $condition, string $message ): vo $parse('remove', array( 'data-machine-code', 'fix/1070' ), array()); $parse('finalize', array( 'data-machine-code@fix-1070' ), array( 'pr' => 'https://github.com/Extra-Chill/data-machine-code/pull/1070' )); $parse('locks', array(), array( 'prune-stale' => true, 'dry-run' => true, 'format' => 'json' )); + $prune_synopsis = $parse('prune', array(), array( 'dry-run' => true, 'format' => 'json' )); + worktree_wp_cli_synopsis_assert(str_contains($prune_synopsis, '[--dry-run]'), 'prune did not render --dry-run as optional.'); + $parse('prune', array(), array( 'yes' => true, 'limit' => '25', 'after-repo' => 'data-machine-code', 'until-budget' => '30s', 'format' => 'json' )); $list_synopsis = $parse('list', array(), array( 'task-ref' => 'https://github.com/Extra-Chill/data-machine-code/issues/1070', 'all' => true, 'format' => 'json' )); worktree_wp_cli_synopsis_assert(str_starts_with($list_synopsis, '[]'), 'list did not render repo as an optional positional argument.'); $parse('abandoned', array(), array( 'apply' => true, 'limit' => '100', 'passes' => '2', 'until-budget' => '300s', 'format' => 'json' )); diff --git a/tests/worktree-emergency-cleanup-lock-reactivation.php b/tests/worktree-emergency-cleanup-lock-reactivation.php index a8a21473..0f6e7500 100644 --- a/tests/worktree-emergency-cleanup-lock-reactivation.php +++ b/tests/worktree-emergency-cleanup-lock-reactivation.php @@ -70,7 +70,7 @@ public function remove_worktree_by_path( string $repo, string $branch, string $p } public function count_unpushed_commits( string $path ): int { return 0; } - public function worktree_prune(): array { return array( 'success' => true ); } + public function worktree_prune( array $opts = array() ): array { return array( 'success' => true ); } public function summarize_top_worktree_rows( array $rows, string $field ): array { return array(); } } diff --git a/tests/worktree-prune-linked-primary.php b/tests/worktree-prune-linked-primary.php index 92e94098..b9bfeaff 100644 --- a/tests/worktree-prune-linked-primary.php +++ b/tests/worktree-prune-linked-primary.php @@ -87,6 +87,7 @@ function linked_primary_prune_remove_tree( string $path ): void { $source = file_get_contents(dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeLifecycle.php'); linked_primary_prune_assert(false !== $source && str_contains($source, 'GitCheckout::exists($primary_path)'), 'DMC worktree prune must recognize linked primary checkouts'); + linked_primary_prune_assert(false !== $source && str_contains($source, 'worktree list --porcelain'), 'DMC worktree prune must preview stale registrations through cheap porcelain probes'); linked_primary_prune_assert(false !== $source && str_contains($source, 'worktree prune -v --expire=now'), 'DMC worktree prune must reconcile proven stale registrations immediately'); printf("worktree-prune-linked-primary: ok\n"); diff --git a/tests/worktree-prune-preview.php b/tests/worktree-prune-preview.php new file mode 100644 index 00000000..1cf0ac2a --- /dev/null +++ b/tests/worktree-prune-preview.php @@ -0,0 +1,118 @@ +&1', $output, $status); + if ( 0 !== $status ) { + throw new RuntimeException(sprintf('Git command failed (%d): %s', $status, implode("\n", $output))); + } + + return implode("\n", $output); +} + +function worktree_prune_preview_remove_tree( string $path ): void { + if ( ! is_dir($path) ) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $iterator as $item ) { + if ( $item->isDir() ) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + rmdir($path); +} + +$porcelain = <<<'PORCELAIN' +worktree /workspace/repo +HEAD abcdef +branch refs/heads/main + +worktree /workspace/repo@dead +HEAD 123456 +dead 1 +prunable gitdir file points to non-existent location + +worktree /workspace/repo@live +HEAD fedcba +branch refs/heads/feat +PORCELAIN; + +$parsed = GitCheckout::prunable_registrations_from_porcelain($porcelain); +worktree_prune_preview_assert(1 === count($parsed) && '/workspace/repo@dead' === ($parsed[0]['path'] ?? null), 'Porcelain parser must return only prunable registrations.'); +worktree_prune_preview_assert('gitdir file points to non-existent location' === ($parsed[0]['reason'] ?? null), 'Porcelain parser must retain the Git prunable reason.'); +worktree_prune_preview_assert(array() === GitCheckout::prunable_registrations_from_porcelain(''), 'Empty porcelain must yield no candidates.'); +worktree_prune_preview_assert('worktree prune --dry-run -v --expire=now' === GitCheckout::prune_git_args(true), 'Dry-run Git args must preview without mutation.'); +worktree_prune_preview_assert('worktree prune -v --expire=now' === GitCheckout::prune_git_args(false), 'Apply Git args must prune immediately.'); + +$lifecycle = file_get_contents(dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeLifecycle.php'); +$ability = file_get_contents(dirname(__DIR__) . '/inc/Abilities/WorkspaceAbilities.php'); +$cli = file_get_contents(dirname(__DIR__) . '/inc/Cli/Commands/WorkspaceCommand.php'); +worktree_prune_preview_assert(is_string($lifecycle) && str_contains($lifecycle, "'dry_run'"), 'Bounded prune must accept a dry-run option.'); +worktree_prune_preview_assert(is_string($lifecycle) && str_contains($lifecycle, 'worktree_inventory_refresh()') && str_contains($lifecycle, '$repair_inventory'), 'Dry-run prune must skip the full inventory refresh.'); +worktree_prune_preview_assert(is_string($ability) && str_contains($ability, "! array_key_exists( 'dry_run', \$input ) || ! empty( \$input['dry_run'] )"), 'Ability prune must default to preview.'); +worktree_prune_preview_assert(is_string($cli) && str_contains($cli, "empty( \$assoc_args['yes'] )"), 'CLI prune must stay preview-first unless --yes is passed.'); +worktree_prune_preview_assert(is_string($cli) && str_contains($cli, "'after-repo'"), 'CLI prune must expose bounded continuation.'); + +$root = sys_get_temp_dir() . '/dmc-prune-preview-' . getmypid() . '-' . bin2hex(random_bytes(4)); +$repo = $root . '/repo'; +$attempt = $root . '/repo@deleted-attempt'; +$live = $root . '/repo@live-attempt'; + +try { + worktree_prune_preview_git(sprintf('git init --initial-branch=main %s', escapeshellarg($repo))); + worktree_prune_preview_git(sprintf('git -C %s config user.email test@example.test', escapeshellarg($repo))); + worktree_prune_preview_git(sprintf('git -C %s config user.name Test', escapeshellarg($repo))); + worktree_prune_preview_git(sprintf('git -C %s commit --allow-empty -m initial', escapeshellarg($repo))); + worktree_prune_preview_git(sprintf('git -C %s worktree add --detach %s', escapeshellarg($repo), escapeshellarg($attempt))); + worktree_prune_preview_git(sprintf('git -C %s worktree add --detach %s', escapeshellarg($repo), escapeshellarg($live))); + worktree_prune_preview_assert(unlink($attempt . '/.git') && rmdir($attempt), 'attempt fixture must be deleted while its Git registration remains'); + + $list = worktree_prune_preview_git(sprintf('git -C %s worktree list --porcelain', escapeshellarg($repo))); + $dead = GitCheckout::prunable_registrations_from_porcelain($list); + worktree_prune_preview_assert(1 === count($dead) && str_ends_with((string) ($dead[0]['path'] ?? ''), '/repo@deleted-attempt'), 'Cheap porcelain must identify the stale registration before prune.'); + + $preview = worktree_prune_preview_git(sprintf('git -C %s %s', escapeshellarg($repo), GitCheckout::prune_git_args(true))); + worktree_prune_preview_assert(str_contains($preview, basename($attempt)), 'Git dry-run must name the stale registration.'); + $still = worktree_prune_preview_git(sprintf('git -C %s worktree list --porcelain', escapeshellarg($repo))); + worktree_prune_preview_assert(1 === count(GitCheckout::prunable_registrations_from_porcelain($still)), 'Dry-run must leave the stale registration in place.'); + worktree_prune_preview_assert(is_dir($live), 'Dry-run must preserve a live worktree.'); + + $applied = worktree_prune_preview_git(sprintf('git -C %s %s', escapeshellarg($repo), GitCheckout::prune_git_args(false))); + worktree_prune_preview_assert(str_contains($applied, basename($attempt)), 'Apply must prune the stale registration.'); + $after = worktree_prune_preview_git(sprintf('git -C %s worktree list --porcelain', escapeshellarg($repo))); + worktree_prune_preview_assert(array() === GitCheckout::prunable_registrations_from_porcelain($after), 'Apply must leave no prunable registrations.'); + worktree_prune_preview_assert(is_dir($live), 'Apply must preserve a live worktree.'); + + echo "worktree-prune-preview: ok\n"; +} finally { + worktree_prune_preview_remove_tree($attempt); + worktree_prune_preview_remove_tree($live); + worktree_prune_preview_remove_tree($repo); + worktree_prune_preview_remove_tree($root); +}