From 0f8cb263dc9a022d935ab665d1fedac7faf3847a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 6 Jun 2026 16:37:58 -0400 Subject: [PATCH] fix: drain abandoned cleanup pages --- inc/Cli/Commands/WorkspaceCommand.php | 71 ++++++++++++++++++++++++++- tests/smoke-worktree-cleanup-cli.php | 27 ++++++++-- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index c442264..8eeeb39 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -2829,7 +2829,7 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'apply' => $apply, ) ); - $reconcile = $abilities['reconcile_metadata']->execute($reconcile_input); + $reconcile = $this->drain_worktree_abandoned_pages($abilities['reconcile_metadata'], $reconcile_input, $apply); if ( is_wp_error($reconcile) ) { return $reconcile; } @@ -2855,7 +2855,7 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra 'offset' => 0, ) ); - $step = $ability->execute($step_input); + $step = $this->drain_worktree_abandoned_pages($ability, $step_input, $apply); if ( is_wp_error($step) ) { return $step; } @@ -2928,6 +2928,72 @@ private function run_worktree_abandoned_orchestration( array $assoc_args ): arra return $result; } + /** + * Drain paginated abandoned-cleanup classifier pages. + * + * Preview intentionally stays bounded to one page. Apply mode follows + * pagination.next_offset so stale rows beyond page zero can be reconciled or + * marked cleanup-eligible before bounded removal runs. + * + * @param object $ability Ability object with execute(). + * @param array $base_input Base ability input. + * @param bool $apply Whether the orchestration is applying changes. + * @return array|\WP_Error + */ + private function drain_worktree_abandoned_pages( object $ability, array $base_input, bool $apply ): array|\WP_Error { + $pages = array(); + $summary = array(); + $pagination = array(); + $offset = isset($base_input['offset']) ? max(0, (int) $base_input['offset']) : 0; + $max_pages = $apply ? 100 : 1; + $last_result = array(); + + for ( $page = 1; $page <= $max_pages; ++$page ) { + $input = $base_input; + if ( isset($base_input['offset']) || $page > 1 ) { + $input['offset'] = $offset; + } + + $result = $ability->execute($input); + if ( is_wp_error($result) ) { + return $result; + } + + $last_result = $result; + $pages[] = $this->summarize_worktree_abandoned_step($result); + + foreach ( (array) ( $result['summary'] ?? array() ) as $key => $value ) { + if ( is_numeric($value) ) { + $summary[ $key ] = (int) ( $summary[ $key ] ?? 0 ) + (int) $value; + } + } + + $pagination = (array) ( $result['pagination'] ?? $result['continuation'] ?? array() ); + if ( ! $apply || empty($pagination) || ! empty($pagination['complete']) ) { + break; + } + + $next_offset = isset($pagination['next_offset']) ? (int) $pagination['next_offset'] : null; + if ( null === $next_offset || $next_offset <= $offset ) { + break; + } + + $total = isset($pagination['total']) ? (int) $pagination['total'] : null; + if ( null !== $total && $next_offset >= $total ) { + break; + } + + $offset = $next_offset; + } + + $last_result['summary'] = $summary; + $last_result['pagination'] = $pagination; + $last_result['pages'] = $pages; + $last_result['page_count'] = count($pages); + + return $last_result; + } + /** * Build a compact step summary for abandoned cleanup output. * @@ -2938,6 +3004,7 @@ private function summarize_worktree_abandoned_step( array $step ): array { $summary = (array) ( $step['summary'] ?? array() ); return array( 'mode' => (string) ( $step['mode'] ?? '' ), + 'page_count' => (int) ( $step['page_count'] ?? 1 ), 'dry_run' => ! empty($step['dry_run']), 'applied' => ! empty($step['applied']) || ! empty($step['destructive']), 'inspected' => (int) ( $summary['inspected'] ?? $summary['processed'] ?? 0 ), diff --git a/tests/smoke-worktree-cleanup-cli.php b/tests/smoke-worktree-cleanup-cli.php index 7846755..6bcbdad 100644 --- a/tests/smoke-worktree-cleanup-cli.php +++ b/tests/smoke-worktree-cleanup-cli.php @@ -376,6 +376,7 @@ public function execute( array $input ): array class FakeActiveNoSignalAbility { public array $last_input = array(); + public array $inputs = array(); private string $mode; public function __construct( string $mode ) @@ -386,6 +387,7 @@ public function __construct( string $mode ) public function execute( array $input ): array { $this->last_input = $input; + $this->inputs[] = $input; $limit = (int) ( $input['limit'] ?? 25 ); $offset = (int) ( $input['offset'] ?? 0 ); $budget = isset($input['until_budget']) && '' !== trim((string) $input['until_budget']) ? ' --until-budget=' . trim((string) $input['until_budget']) : ''; @@ -458,18 +460,31 @@ public function execute( array $input ): array class FakeReconcileMetadataAbility { public array $last_input = array(); + public array $inputs = array(); public function execute( array $input ): array { $this->last_input = $input; + $this->inputs[] = $input; + $limit = (int) ( $input['limit'] ?? 25 ); + $offset = (int) ( $input['offset'] ?? 0 ); return array( 'success' => true, 'mode' => 'metadata_reconcile', 'dry_run' => ! empty($input['dry_run']), 'summary' => array( - 'inspected' => 2, - 'proposed' => 2, - 'written' => empty($input['dry_run']) ? 2 : 0, + 'inspected' => 1, + 'proposed' => 1, + 'written' => empty($input['dry_run']) ? 1 : 0, + ), + 'pagination' => array( + 'total' => 2, + 'offset' => $offset, + 'limit' => $limit, + 'scanned' => 1, + 'partial' => $offset + $limit < 2, + 'complete' => $offset + $limit >= 2, + 'next_offset' => $offset + $limit, ), ); } @@ -1055,14 +1070,16 @@ public function execute( array $input ): array echo "\n[1a] abandoned cleanup orchestrates safe DMC abilities\n"; WP_CLI::$logs = array(); WP_CLI::$successes = array(); - $command->worktree(array( 'abandoned' ), array( 'apply' => true, 'force' => true, 'limit' => 5, 'passes' => 1, 'until-budget' => '30s', 'format' => 'json' )); + $command->worktree(array( 'abandoned' ), array( 'apply' => true, 'force' => true, 'limit' => 1, 'passes' => 1, 'until-budget' => '30s', 'format' => 'json' )); $abandoned_json = json_decode(WP_CLI::$logs[0] ?? '', true); datamachine_code_cleanup_assert(JSON_ERROR_NONE === json_last_error(), 'abandoned JSON output parses cleanly'); datamachine_code_cleanup_assert(true === ( $abandoned_json['applied'] ?? null ), 'abandoned apply mode is explicit in JSON'); datamachine_code_cleanup_assert(true === ( $abandoned_json['force'] ?? null ), 'abandoned force mode is explicit in JSON'); - datamachine_code_cleanup_assert(5 === (int) ( $reconcile_metadata_ability->last_input['limit'] ?? 0 ), 'abandoned forwards limit to metadata reconciliation'); + datamachine_code_cleanup_assert(1 === (int) ( $reconcile_metadata_ability->last_input['limit'] ?? 0 ), 'abandoned forwards limit to metadata reconciliation'); datamachine_code_cleanup_assert(false === ( $reconcile_metadata_ability->last_input['dry_run'] ?? null ), 'abandoned --apply applies metadata reconciliation'); + datamachine_code_cleanup_assert(array( 0, 1 ) === array_map(fn( $input ) => (int) ( $input['offset'] ?? 0 ), array_slice($reconcile_metadata_ability->inputs, -2)), 'abandoned drains metadata reconciliation pages in apply mode'); datamachine_code_cleanup_assert('30s' === ( $active_finalized_ability->last_input['until_budget'] ?? '' ), 'abandoned forwards time budget to active/no-signal marking'); + datamachine_code_cleanup_assert(array( 0, 1 ) === array_map(fn( $input ) => (int) ( $input['offset'] ?? 0 ), array_slice($active_finalized_ability->inputs, -2)), 'abandoned drains active/no-signal classifier pages in apply mode'); datamachine_code_cleanup_assert(true === ( $bounded_apply_ability->last_input['force'] ?? null ), 'abandoned forwards force only to bounded cleanup removal'); datamachine_code_cleanup_assert(false === ( $bounded_apply_ability->last_input['dry_run'] ?? null ), 'abandoned --apply removes eligible rows'); datamachine_code_cleanup_assert(1 === $prune_ability->calls, 'abandoned prunes stale git metadata after cleanup pass');