diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index f299dda..df8f3b1 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2300,6 +2300,10 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Allow apply on dirty worktrees. Unpushed-commit gate is never overridden.', ), + 'discard_unpushed' => array( + 'type' => 'boolean', + 'description' => 'Explicitly discard unpushed commits for bounded cleanup-eligible rows. This is a data-loss mode and is separate from force.', + ), 'via_jobs' => array( 'type' => 'boolean', 'description' => 'Schedule each candidate as a single-row worktree_cleanup_chunk job for resumable async apply.', @@ -4120,7 +4124,7 @@ public static function worktreeCleanupArtifacts( array $input ): array|\WP_Error /** * Apply only worktrees with explicit lifecycle cleanup_eligible metadata in a bounded batch. * - * @param array $input Input parameters (dry_run, limit, older_than, sort, force, via_jobs, remove_timeout, source). + * @param array $input Input parameters (dry_run, limit, older_than, sort, force, discard_unpushed, via_jobs, remove_timeout, source). * @return array|\WP_Error */ public static function worktreeBoundedCleanupEligibleApply( array $input ): array|\WP_Error { @@ -4128,6 +4132,7 @@ public static function worktreeBoundedCleanupEligibleApply( array $input ): arra $opts = array( 'dry_run' => ! empty($input['dry_run']), 'force' => ! empty($input['force']), + 'discard_unpushed' => ! empty($input['discard_unpushed']), 'via_jobs' => ! empty($input['via_jobs']), 'include_repaired_metadata' => ! empty($input['include_repaired_metadata']), ); diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 4bd5d50..67167b0 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -3326,6 +3326,11 @@ private function renderGitOperationResult( string $operation, array $result, arr * include repaired metadata rows as operator-approved cleanup candidates. * Apply still runs fresh dirty/unpushed/containment/primary safety probes. * + * [--discard-unpushed] + * : With bounded-cleanup-eligible-apply only, explicitly discard unpushed + * commits after reviewed cleanup eligibility and fresh safety probes. This + * is a data-loss mode and is not implied by --force. + * * [--older-than=] * : Limit cleanup candidates to worktrees with lifecycle `created_at` * metadata older than the compact duration (cleanup only, e.g. 7d, 24h). @@ -3484,6 +3489,7 @@ private function renderGitOperationResult( string $operation, array $result, arr * # (cheap inventory only — no full git worktree scan, no GitHub lookup) * wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25 * wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --limit=25 + * wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --discard-unpushed --limit=25 * wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --via-jobs --limit=10 --older-than=7d * wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --include-repaired-metadata --older-than=7d --limit=25 * wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --include-repaired-metadata --older-than=7d --limit=25 @@ -3855,6 +3861,7 @@ public function worktree( array $args, array $assoc_args ): void { case 'bounded-cleanup-eligible-apply': $input['dry_run'] = ! empty($assoc_args['dry-run']); $input['force'] = ! empty($assoc_args['force']); + $input['discard_unpushed'] = ! empty($assoc_args['discard-unpushed']); $input['via_jobs'] = ! empty($assoc_args['via-jobs']); $input['include_repaired_metadata'] = ! empty($assoc_args['include-repaired-metadata']); $input['source'] = self::CLEANUP_CLI_SOURCE; @@ -6460,15 +6467,32 @@ private function render_worktree_bounded_cleanup_eligible_apply_result( array $r WP_CLI::log('Removed worktrees:'); $rows = array_map( fn( $row ) => array( - 'handle' => $row['handle'] ?? '', - 'repo' => $row['repo'] ?? '', - 'branch' => $row['branch'] ?? '', - 'size' => $this->format_bytes($row['size_bytes'] ?? null), - 'path' => $row['path'] ?? '', + 'handle' => $row['handle'] ?? '', + 'repo' => $row['repo'] ?? '', + 'branch' => $row['branch'] ?? '', + 'size' => $this->format_bytes($row['size_bytes'] ?? null), + 'unpushed' => (int) ( $row['unpushed_before_remove'] ?? 0 ), + 'path' => $row['path'] ?? '', ), $removed ); - $this->format_items($rows, array( 'handle', 'repo', 'branch', 'size', 'path' ), array( 'format' => 'table' ), 'handle'); + $this->format_items($rows, array( 'handle', 'repo', 'branch', 'size', 'unpushed', 'path' ), array( 'format' => 'table' ), 'handle'); + } + + $evidence = (array) ( $result['evidence'] ?? array() ); + $discarded_unpushed = (array) ( $evidence['discarded_unpushed'] ?? array() ); + if ( ! empty($discarded_unpushed) ) { + WP_CLI::warning('Discarded unpushed commits for cleanup-eligible worktrees. Evidence follows.'); + $rows = array_map( + fn( $row ) => array( + 'handle' => $row['handle'] ?? '', + 'unpushed_before' => (int) ( $row['unpushed_before_remove'] ?? 0 ), + 'path_exists_after' => ! empty($row['path_exists_after']) ? 'yes' : 'no', + 'path' => $row['path'] ?? '', + ), + $discarded_unpushed + ); + $this->format_items($rows, array( 'handle', 'unpushed_before', 'path_exists_after', 'path' ), array( 'format' => 'table' ), 'handle'); } if ( ! empty($skipped) ) { diff --git a/inc/Tasks/WorktreeCleanupChunkTask.php b/inc/Tasks/WorktreeCleanupChunkTask.php index 03b920f..b9c741c 100644 --- a/inc/Tasks/WorktreeCleanupChunkTask.php +++ b/inc/Tasks/WorktreeCleanupChunkTask.php @@ -108,6 +108,9 @@ public function executeTask( int $jobId, array $params ): void { 'worktrees' => $workspace->worktree_cleanup_merged( array( 'apply_plan' => array( 'candidates' => $rows ), + 'direct_apply_plan' => true, + 'force' => ! empty($params['force']), + 'discard_unpushed' => ! empty($params['discard_unpushed']), 'skip_github' => array_key_exists('skip_github', $params) ? (bool) $params['skip_github'] : true, 'include_repaired_metadata' => ! empty($params['include_repaired_metadata']), 'stale_liveness_only' => ! empty($params['stale_liveness_only']), diff --git a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php index 89b6985..156beb9 100644 --- a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php +++ b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php @@ -50,6 +50,7 @@ trait WorkspaceWorktreeCleanupEngine { public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Error { $dry_run = ! empty($opts['dry_run']); $force = ! empty($opts['force']); + $discard_unpushed = ! empty($opts['discard_unpushed']); $skip_github = ! empty($opts['skip_github']); $direct_apply_plan = ! empty($opts['direct_apply_plan']); $inventory_only = ! empty($opts['inventory_only']); @@ -112,7 +113,7 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro $force = false; if ( $direct_apply_plan && ! $dry_run ) { - return $this->apply_worktree_cleanup_plan_candidates($planned_candidates, $force, $started_at, $stale_liveness_only, $remove_timeout_seconds); + return $this->apply_worktree_cleanup_plan_candidates($planned_candidates, $force, $started_at, $stale_liveness_only, $remove_timeout_seconds, $discard_unpushed); } } @@ -942,13 +943,14 @@ private function get_wp_error_data( \WP_Error $error ): mixed { * batch so the operator can keep going (next call without changes * re-derives the same list cheaply). * - * @param array $opts Options: dry_run, limit, older_than, sort, force, via_jobs, source. + * @param array $opts Options: dry_run, limit, older_than, sort, force, discard_unpushed, via_jobs, source. * @return array|\WP_Error */ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ): array|\WP_Error { $started_at = microtime(true); $dry_run = ! empty($opts['dry_run']); $force = ! empty($opts['force']); + $discard_unpushed = ! empty($opts['discard_unpushed']); $via_jobs = ! empty($opts['via_jobs']); $include_repaired_metadata = ! empty($opts['include_repaired_metadata']); $older_than = isset($opts['older_than']) ? trim( (string) $opts['older_than']) : ''; @@ -1020,28 +1022,30 @@ public function worktree_bounded_cleanup_eligible_apply( array $opts = array() ) ), 'continuation' => $continuation, 'evidence' => array( - 'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000), - 'inventory_total' => count($all_candidates), - 'planned_handles' => array_values(array_filter(array_map(fn( $row ) => is_array($row) ? (string) ( $row['handle'] ?? '' ) : '', $batch))), - 'remove_timeout' => $remove_timeout_seconds, - 'source' => $source, + 'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000), + 'inventory_total' => count($all_candidates), + 'planned_handles' => array_values(array_filter(array_map(fn( $row ) => is_array($row) ? (string) ( $row['handle'] ?? '' ) : '', $batch))), + 'discard_unpushed' => $discard_unpushed, + 'remove_timeout' => $remove_timeout_seconds, + 'source' => $source, ), ); } if ( $via_jobs ) { - return $this->schedule_bounded_cleanup_eligible_chunks($batch, $deferred, $force, $source, $started_at, $continuation, $include_repaired_metadata, $remove_timeout_seconds); + return $this->schedule_bounded_cleanup_eligible_chunks($batch, $deferred, $force, $source, $started_at, $continuation, $include_repaired_metadata, $remove_timeout_seconds, $discard_unpushed); } - $processed = 0; - $removed = array(); - $skipped = $inventory_skipped; - $bytes_reclaimed = 0; - $timeout_handles = array(); + $processed = 0; + $removed = array(); + $skipped = $inventory_skipped; + $bytes_reclaimed = 0; + $timeout_handles = array(); + $discarded_unpushed = array(); foreach ( $batch as $candidate ) { ++$processed; - $revalidated = $this->revalidate_bounded_cleanup_eligible_candidate($candidate, $force); + $revalidated = $this->revalidate_bounded_cleanup_eligible_candidate($candidate, $force, false, $discard_unpushed); if ( isset($revalidated['skipped']) ) { $skipped[] = $revalidated['skipped']; continue; @@ -1090,17 +1094,32 @@ function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) { continue; } - $removed[] = array_merge( + $unpushed_count = (int) ( $validated['unpushed'] ?? 0 ); + $removed_row = array_merge( array( - 'handle' => (string) ( $candidate['handle'] ?? '' ), - 'repo' => $repo, - 'branch' => $branch, - 'path' => $wt_path, - 'size_bytes' => $size, - 'reason_code' => 'cleanup_eligible', + 'handle' => (string) ( $candidate['handle'] ?? '' ), + 'repo' => $repo, + 'branch' => $branch, + 'path' => $wt_path, + 'size_bytes' => $size, + 'reason_code' => 'cleanup_eligible', + 'unpushed_before_remove' => $unpushed_count, + 'discarded_unpushed_commits' => $discard_unpushed && $unpushed_count > 0, + 'path_exists_after' => is_dir($wt_path), ), is_array($candidate['metadata'] ?? null) ? array( 'metadata' => $candidate['metadata'] ) : array() ); + $removed[] = $removed_row; + if ( $discard_unpushed && $unpushed_count > 0 ) { + $discarded_unpushed[] = array( + 'handle' => (string) ( $candidate['handle'] ?? '' ), + 'repo' => $repo, + 'branch' => $branch, + 'path' => $wt_path, + 'unpushed_before_remove' => $unpushed_count, + 'path_exists_after' => is_dir($wt_path), + ); + } $bytes_reclaimed += max(0, $size); } @@ -1124,20 +1143,23 @@ function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) { 'removed' => $removed, 'skipped' => $skipped, 'summary' => array( - 'processed' => $processed, - 'removed' => count($removed), - 'skipped' => count($skipped), - 'bytes_reclaimed' => $bytes_reclaimed, - 'limit' => $limit, + 'processed' => $processed, + 'removed' => count($removed), + 'skipped' => count($skipped), + 'bytes_reclaimed' => $bytes_reclaimed, + 'limit' => $limit, + 'discarded_unpushed' => count($discarded_unpushed), ), 'continuation' => $continuation, 'evidence' => array( - 'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000), - 'inventory_total' => count($all_candidates), - 'removed_handles' => array_values(array_filter(array_map(fn( $row ) => (string) $row['handle'], $removed))), - 'skipped_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $skipped))), - 'remove_timeout' => $remove_timeout_seconds, - 'source' => $source, + 'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000), + 'inventory_total' => count($all_candidates), + 'removed_handles' => array_values(array_filter(array_map(fn( $row ) => (string) $row['handle'], $removed))), + 'skipped_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $skipped))), + 'discard_unpushed' => $discard_unpushed, + 'discarded_unpushed' => $discarded_unpushed, + 'remove_timeout' => $remove_timeout_seconds, + 'source' => $source, ), ); } @@ -1150,7 +1172,7 @@ function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) { * @param float $started_at Start timestamp. * @return array */ - private function apply_worktree_cleanup_plan_candidates( array $candidates, bool $force, float $started_at, bool $stale_liveness_only = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT ): array { + private function apply_worktree_cleanup_plan_candidates( array $candidates, bool $force, float $started_at, bool $stale_liveness_only = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT, bool $discard_unpushed = false ): array { $processed = 0; $removed = array(); $skipped = array(); @@ -1158,7 +1180,7 @@ private function apply_worktree_cleanup_plan_candidates( array $candidates, bool foreach ( $candidates as $candidate ) { ++$processed; - $revalidated = $this->revalidate_bounded_cleanup_eligible_candidate($candidate, $force, $stale_liveness_only); + $revalidated = $this->revalidate_bounded_cleanup_eligible_candidate($candidate, $force, $stale_liveness_only, $discard_unpushed); if ( isset($revalidated['skipped']) ) { $skipped[] = $revalidated['skipped']; continue; @@ -1253,7 +1275,7 @@ function () use ( $repo, $branch, $wt_path, $force, $remove_timeout_seconds ) { * @param bool $force Allow dirty worktrees. * @return array */ - private function revalidate_bounded_cleanup_eligible_candidate( array $candidate, bool $force, bool $stale_liveness_only = false ): array { + private function revalidate_bounded_cleanup_eligible_candidate( array $candidate, bool $force, bool $stale_liveness_only = false, bool $discard_unpushed = false ): array { $handle = (string) ( $candidate['handle'] ?? '' ); $repo = (string) ( $candidate['repo'] ?? '' ); $branch = (string) ( $candidate['branch'] ?? '' ); @@ -1435,7 +1457,7 @@ private function revalidate_bounded_cleanup_eligible_candidate( array $candidate ); } - if ( $unpushed > 0 && ! $allow_effective_clean_removal ) { + if ( $unpushed > 0 && ! $allow_effective_clean_removal && ! $discard_unpushed ) { return array( 'skipped' => array( 'handle' => $handle, @@ -1443,13 +1465,19 @@ private function revalidate_bounded_cleanup_eligible_candidate( array $candidate 'branch' => $branch, 'path' => $wt_path, 'reason_code' => 'unpushed_commits', - 'reason' => sprintf('%d unpushed commit(s) — bounded cleanup-eligible apply refuses to remove even with force=true', $unpushed), + 'reason' => sprintf('%d unpushed commit(s) — bounded cleanup-eligible apply refuses to remove without discard_unpushed=true', $unpushed), 'unpushed' => $unpushed, ), ); } - return array_merge($candidate, array( 'path' => $real_path )); + return array_merge( + $candidate, + array( + 'path' => $real_path, + 'unpushed' => (int) $unpushed, + ) + ); } /** @@ -1467,7 +1495,7 @@ private function revalidate_bounded_cleanup_eligible_candidate( array $candidate * @param array $continuation Continuation envelope. * @return array|\WP_Error */ - private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $deferred, bool $force, string $source, float $started_at, array $continuation, bool $include_repaired_metadata = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT ): array|\WP_Error { + private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $deferred, bool $force, string $source, float $started_at, array $continuation, bool $include_repaired_metadata = false, int $remove_timeout_seconds = self::CLEANUP_GIT_REMOVE_TIMEOUT, bool $discard_unpushed = false ): array|\WP_Error { if ( ! class_exists('\DataMachine\Engine\Tasks\TaskScheduler') ) { return new \WP_Error('task_scheduler_unavailable', 'Data Machine TaskScheduler is unavailable; cannot schedule bounded cleanup-eligible apply chunks.', array( 'status' => 500 )); } @@ -1520,6 +1548,7 @@ private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $ 'chunk_index' => count($item_params), 'rows' => array( $row ), 'force' => $force, + 'discard_unpushed' => $discard_unpushed, 'skip_github' => true, 'include_repaired_metadata' => $include_repaired_metadata, 'remove_timeout' => $remove_timeout_seconds, @@ -1559,12 +1588,13 @@ private function schedule_bounded_cleanup_eligible_chunks( array $batch, array $ ), 'continuation' => $continuation, 'evidence' => array( - 'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000), - 'planned_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $batch))), - 'batch_job_id' => (int) ( $batch_result['batch_job_id'] ?? 0 ), - 'direct_job_ids' => $batch_result['job_ids'] ?? array(), - 'remove_timeout' => $remove_timeout_seconds, - 'source' => $source, + 'elapsed_ms' => (int) round(( microtime(true) - $started_at ) * 1000), + 'planned_handles' => array_values(array_filter(array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $batch))), + 'batch_job_id' => (int) ( $batch_result['batch_job_id'] ?? 0 ), + 'direct_job_ids' => $batch_result['job_ids'] ?? array(), + 'discard_unpushed' => $discard_unpushed, + 'remove_timeout' => $remove_timeout_seconds, + 'source' => $source, ), ); } @@ -1616,6 +1646,9 @@ private function build_bounded_cleanup_resume_command( int $limit, array $opts, if ( ! empty($opts['force']) ) { $parts[] = '--force'; } + if ( ! empty($opts['discard_unpushed']) ) { + $parts[] = '--discard-unpushed'; + } if ( ! empty($opts['include_repaired_metadata']) ) { $parts[] = '--include-repaired-metadata'; } diff --git a/tests/smoke-worktree-bounded-cleanup-eligible-apply.php b/tests/smoke-worktree-bounded-cleanup-eligible-apply.php index 0d6a743..e1b57dd 100644 --- a/tests/smoke-worktree-bounded-cleanup-eligible-apply.php +++ b/tests/smoke-worktree-bounded-cleanup-eligible-apply.php @@ -438,7 +438,7 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra $assert(false, is_dir($tmp . '/demo@repaired-metadata-clean'), 'repaired metadata clean directory removed from disk'); $assert(true, is_dir($tmp . '/demo@repaired-metadata-dirty'), 'repaired metadata dirty directory survives apply'); - echo "\nForce gate (dirty allowed, unpushed never allowed)\n"; + echo "\nForce gate (dirty allowed, unpushed still requires explicit discard)\n"; $force_apply = $ws->worktree_bounded_cleanup_eligible_apply(array( 'limit' => 10, 'force' => true )); $assert(true, ! is_wp_error($force_apply) && ( $force_apply['success'] ?? false ), 'force apply returns success'); // Dirty worktree should now be removed. @@ -447,6 +447,19 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra $assert_skipped($force_apply['skipped'] ?? array(), 'demo@eligible-unpushed', 'unpushed_commits', 'unpushed gate not overridden by force=true'); $assert(true, is_dir($tmp . '/demo@eligible-unpushed'), 'unpushed cleanup-eligible directory survives force apply'); + echo "\nDiscard-unpushed gate (operator-approved data loss)\n"; + $discard_apply = $ws->worktree_bounded_cleanup_eligible_apply(array( 'limit' => 10, 'discard_unpushed' => true )); + $assert(true, ! is_wp_error($discard_apply) && ( $discard_apply['success'] ?? false ), 'discard-unpushed apply returns success'); + $assert_contains($discard_apply['removed'] ?? array(), 'demo@eligible-unpushed', 'discard_unpushed=true removes cleanup-eligible worktree with unpushed commits'); + $discard_rows = array_values(array_filter($discard_apply['removed'] ?? array(), fn( $row ) => ( $row['handle'] ?? '' ) === 'demo@eligible-unpushed')); + $assert(1, (int) ( $discard_rows[0]['unpushed_before_remove'] ?? 0 ), 'discard evidence records unpushed count before removal'); + $assert(false, (bool) ( $discard_rows[0]['path_exists_after'] ?? true ), 'discard evidence records path removed after cleanup'); + $evidence = (array) ( $discard_apply['evidence'] ?? array() ); + $discard_evidence = (array) ( $evidence['discarded_unpushed'] ?? array() ); + $assert('demo@eligible-unpushed', (string) ( $discard_evidence[0]['handle'] ?? '' ), 'discard evidence lists exact discarded handle'); + $assert(1, (int) ( $discard_evidence[0]['unpushed_before_remove'] ?? 0 ), 'discard evidence lists exact discarded unpushed count'); + $assert(false, is_dir($tmp . '/demo@eligible-unpushed'), 'unpushed cleanup-eligible directory is removed only after discard_unpushed=true'); + echo "\nResult: " . ( $total - $failures ) . "/{$total} passed\n"; exit($failures > 0 ? 1 : 0); } diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 6add6d0..c933fa1 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -635,12 +635,13 @@ class FakeBoundedCleanupEligibleApplyAbility public array $inputs = array(); public int $extra_skipped = 0; - public function execute( array $input ): array - { - $this->last_input = $input; - $this->inputs[] = $input; - $skipped = array( - array( + public function execute( array $input ): array + { + $this->last_input = $input; + $this->inputs[] = $input; + $discard_unpushed = ! empty($input['discard_unpushed']); + $skipped = array( + array( 'handle' => 'repo@dirty', 'repo' => 'repo', 'branch' => 'dirty', @@ -649,18 +650,21 @@ public function execute( array $input ): array 'reason' => 'working tree dirty', 'dirty' => 1, 'unpushed' => 0, - ), - array( - 'handle' => 'repo@unpushed', - 'repo' => 'repo', - 'branch' => 'unpushed', + ), + ); + $unpushed_skip = array( + 'handle' => 'repo@unpushed', + 'repo' => 'repo', + 'branch' => 'unpushed', 'path' => '/workspace/repo@unpushed', 'reason_code' => 'unpushed_commits', 'reason' => 'unpushed commits remain protected', 'dirty' => 0, - 'unpushed' => 2, - ), - ); + 'unpushed' => 2, + ); + if ( ! $discard_unpushed ) { + $skipped[] = $unpushed_skip; + } for ( $i = 0; $i < $this->extra_skipped; ++$i ) { $skipped[] = array( 'handle' => 'repo@blocked-' . $i, @@ -673,18 +677,46 @@ public function execute( array $input ): array } $remaining_handles = array_map(fn( $row ) => (string) ( $row['handle'] ?? '' ), $skipped); - return array( - 'success' => true, - 'mode' => 'bounded_cleanup_eligible_apply', - 'dry_run' => ! empty($input['dry_run']), + $removed = empty($input['dry_run']) ? array( + array( + 'handle' => $discard_unpushed ? 'repo@unpushed' : 'repo@clean', + 'repo' => 'repo', + 'branch' => $discard_unpushed ? 'unpushed' : 'clean', + 'path' => $discard_unpushed ? '/workspace/repo@unpushed' : '/workspace/repo@clean', + 'size_bytes' => 4096, + 'unpushed_before_remove' => $discard_unpushed ? 2 : 0, + 'discarded_unpushed_commits' => $discard_unpushed, + 'path_exists_after' => false, + ), + ) : array(); + + return array( + 'success' => true, + 'mode' => 'bounded_cleanup_eligible_apply', + 'dry_run' => ! empty($input['dry_run']), 'summary' => array( 'inspected' => 3, 'would_remove' => ! empty($input['dry_run']) ? 1 : 0, 'removed' => empty($input['dry_run']) ? 1 : 0, - 'skipped' => 2, - 'bytes_reclaimed' => empty($input['dry_run']) ? 4096 : 0, - ), - 'skipped' => $skipped, + 'skipped' => $discard_unpushed ? 1 : 2, + 'bytes_reclaimed' => empty($input['dry_run']) ? 4096 : 0, + 'discarded_unpushed' => $discard_unpushed && empty($input['dry_run']) ? 1 : 0, + ), + 'removed' => $removed, + 'skipped' => $skipped, + 'evidence' => array( + 'discard_unpushed' => $discard_unpushed, + 'discarded_unpushed' => $discard_unpushed && empty($input['dry_run']) ? array( + array( + 'handle' => 'repo@unpushed', + 'repo' => 'repo', + 'branch' => 'unpushed', + 'path' => '/workspace/repo@unpushed', + 'unpushed_before_remove' => 2, + 'path_exists_after' => false, + ), + ) : array(), + ), 'pagination' => array( 'remaining_total' => count($remaining_handles), 'remaining_handles' => $remaining_handles, @@ -1219,6 +1251,7 @@ public function execute( array $input ): array datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--apply]"), 'worktree synopsis declares --apply at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--via-jobs]"), 'worktree synopsis declares --via-jobs at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--remove-timeout=]"), 'worktree synopsis declares --remove-timeout at top level'); + datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--discard-unpushed]"), 'worktree synopsis declares --discard-unpushed at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--passes=]"), 'worktree synopsis declares abandoned --passes at top level'); datamachine_code_cleanup_assert(str_contains($doc_comment, "\n\t * [--stage=]"), 'worktree synopsis declares abandoned --stage at top level'); datamachine_code_cleanup_assert(! str_contains($doc_comment, "\n\t\t * [--apply-plan=]"), 'cleanup flags are not hidden behind nested docblock indentation'); @@ -1897,6 +1930,14 @@ public function execute( array $input ): array $command->worktree(array( 'bounded-cleanup-eligible-apply' ), array( 'limit' => 25, 'remove-timeout' => 120, 'format' => 'json' )); datamachine_code_cleanup_assert(120 === (int) ( $bounded_apply_ability->last_input['remove_timeout'] ?? 0 ), 'bounded cleanup apply forwards remove-timeout'); + WP_CLI::$logs = array(); + WP_CLI::$successes = array(); + $command->worktree(array( 'bounded-cleanup-eligible-apply' ), array( 'limit' => 25, 'discard-unpushed' => true )); + datamachine_code_cleanup_assert(true === ( $bounded_apply_ability->last_input['discard_unpushed'] ?? null ), 'bounded cleanup apply forwards explicit discard-unpushed flag'); + datamachine_code_cleanup_assert(in_array('table:1:handle,repo,branch,size,unpushed,path', WP_CLI::$logs, true), 'bounded cleanup removed table prints unpushed counts'); + datamachine_code_cleanup_assert(in_array('table:1:handle,unpushed_before,path_exists_after,path', WP_CLI::$logs, true), 'bounded cleanup discard evidence table prints exact handles and before/after evidence'); + datamachine_code_cleanup_assert(in_array('warning: Discarded unpushed commits for cleanup-eligible worktrees. Evidence follows.', WP_CLI::$logs, true), 'bounded cleanup warns when discarding unpushed commits'); + WP_CLI::$logs = array(); WP_CLI::$successes = array(); $command->worktree(array( 'bounded-cleanup-eligible-apply' ), array( 'limit' => 25, 'verbose' => true ));