Skip to content

Commit ef1bb7a

Browse files
authored
Fix cleanup disk-pressure recommendations (#668)
1 parent ed0b7d0 commit ef1bb7a

8 files changed

Lines changed: 330 additions & 101 deletions

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 122 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,9 @@ private function run_cleanup_review( array $assoc_args ): void {
886886
if ( ! empty($assoc_args['safety-probes']) ) {
887887
$artifact_input['safety_probes'] = true;
888888
}
889+
if ( isset($assoc_args['sort']) && '' !== trim( (string) $assoc_args['sort']) ) {
890+
$artifact_input['sort'] = trim( (string) $assoc_args['sort']);
891+
}
889892
$result = $ability ? $ability->execute($artifact_input) : new \WP_Error('artifact_cleanup_ability_missing', 'Artifact cleanup ability not registered.');
890893
$this->render_worktree_artifact_cleanup_result_from_ability($result, $assoc_args);
891894
return;
@@ -2580,8 +2583,10 @@ private function renderGitOperationResult( string $operation, array $result, arr
25802583
* metadata older than the compact duration (cleanup only, e.g. 7d, 24h).
25812584
* Candidate worktrees without valid `created_at` metadata are skipped.
25822585
*
2583-
* [--sort=<field>]
2584-
* : Sort cleanup candidates by reporting field (cleanup only).
2586+
* [--sort=<field>]
2587+
* : Sort cleanup candidates by reporting field. For artifact cleanup,
2588+
* `--sort=size` scans the cheap inventory once and returns the largest
2589+
* artifact opportunities without manual pagination.
25852590
* ---
25862591
* options:
25872592
* - size
@@ -3078,6 +3083,9 @@ public function worktree( array $args, array $assoc_args ): void {
30783083
if ( ! empty($assoc_args['safety-probes']) ) {
30793084
$input['safety_probes'] = true;
30803085
}
3086+
if ( isset($assoc_args['sort']) && '' !== trim( (string) $assoc_args['sort']) ) {
3087+
$input['sort'] = trim( (string) $assoc_args['sort']);
3088+
}
30813089
if ( ! empty($assoc_args['apply-plan']) ) {
30823090
$input['apply_plan'] = $this->read_worktree_cleanup_plan( (string) $assoc_args['apply-plan']);
30833091
}
@@ -4580,6 +4588,10 @@ private function render_worktree_cleanup_result( array $result, array $assoc_arg
45804588
return;
45814589
}
45824590

4591+
if ( ! $dry_run ) {
4592+
WP_CLI::log(sprintf('Result: removed %d worktree(s); reclaimed %s; skipped %d.', (int) ( $summary['removed'] ?? count($removed) ), $this->format_bytes($summary['bytes_reclaimed'] ?? 0), (int) ( $summary['skipped'] ?? count($skipped) )));
4593+
}
4594+
45834595
WP_CLI::log('Summary:');
45844596
$summary_rows = array(
45854597
array(
@@ -4702,27 +4714,32 @@ private function render_worktree_cleanup_result( array $result, array $assoc_arg
47024714

47034715
if ( ! empty($skipped) ) {
47044716
WP_CLI::log('');
4705-
WP_CLI::log('Skipped:');
4706-
$skipped_rows = array_map(
4707-
fn( $s ) => array(
4708-
'handle' => $s['handle'] ?? '',
4709-
'reason_code' => $s['reason_code'] ?? '',
4710-
'reason' => $verbose ? ( $s['reason'] ?? '' ) : $this->shorten_cleanup_reason( (string) ( $s['reason'] ?? '' )),
4711-
'age_days' => $s['age_days'] ?? '',
4712-
'size' => $this->format_bytes($s['size_bytes'] ?? null),
4713-
'artifacts' => $this->format_bytes($s['artifact_size_bytes'] ?? 0),
4714-
'repo' => $s['repo'] ?? '',
4715-
'branch' => $s['branch'] ?? '',
4716-
'path' => $s['path'] ?? '',
4717-
'primary_path' => $s['primary_path'] ?? '',
4718-
'missing' => implode(',', (array) ( $s['missing_fields'] ?? array() )),
4719-
'hint' => $s['hint'] ?? '',
4720-
),
4721-
array_slice($skipped, 0, $limit)
4722-
);
4723-
$fields = $verbose ? array( 'handle', 'reason_code', 'reason', 'age_days', 'size', 'artifacts', 'repo', 'branch', 'path', 'primary_path', 'missing', 'hint' ) : array( 'handle', 'reason_code', 'age_days', 'size', 'artifacts', 'reason' );
4724-
$this->format_items($skipped_rows, $fields, array( 'format' => 'table' ), 'handle');
4725-
$this->render_cleanup_truncation_hint(count($skipped), $limit, 'skipped rows');
4717+
if ( $verbose ) {
4718+
WP_CLI::log('Skipped:');
4719+
$skipped_rows = array_map(
4720+
fn( $s ) => array(
4721+
'handle' => $s['handle'] ?? '',
4722+
'reason_code' => $s['reason_code'] ?? '',
4723+
'reason' => $s['reason'] ?? '',
4724+
'age_days' => $s['age_days'] ?? '',
4725+
'size' => $this->format_bytes($s['size_bytes'] ?? null),
4726+
'artifacts' => $this->format_bytes($s['artifact_size_bytes'] ?? 0),
4727+
'repo' => $s['repo'] ?? '',
4728+
'branch' => $s['branch'] ?? '',
4729+
'path' => $s['path'] ?? '',
4730+
'primary_path' => $s['primary_path'] ?? '',
4731+
'missing' => implode(',', (array) ( $s['missing_fields'] ?? array() )),
4732+
'hint' => $s['hint'] ?? '',
4733+
),
4734+
array_slice($skipped, 0, $limit)
4735+
);
4736+
$this->format_items($skipped_rows, array( 'handle', 'reason_code', 'reason', 'age_days', 'size', 'artifacts', 'repo', 'branch', 'path', 'primary_path', 'missing', 'hint' ), array( 'format' => 'table' ), 'handle');
4737+
$this->render_cleanup_truncation_hint(count($skipped), $limit, 'skipped rows');
4738+
} else {
4739+
WP_CLI::log('Skipped summary:');
4740+
$this->format_items($this->summarize_cleanup_skipped_rows($skipped), array( 'reason_code', 'count', 'examples' ), array( 'format' => 'table' ), 'reason_code');
4741+
WP_CLI::log('Re-run with --verbose to list every skipped row or --only=<reason_code> to inspect one bucket.');
4742+
}
47264743
}
47274744

47284745
WP_CLI::log('');
@@ -5455,6 +5472,8 @@ private function render_worktree_artifact_cleanup_result( array $result, array $
54555472
$skipped = (array) ( $result['skipped'] ?? array() );
54565473
$summary = (array) ( $result['summary'] ?? array() );
54575474
$dry_run = ! empty($result['dry_run']);
5475+
$verbose = ! empty($assoc_args['verbose']);
5476+
$pagination = $result['pagination'] ?? ( $summary['pagination'] ?? null );
54585477

54595478
if ( empty($candidates) && empty($removed) && empty($skipped) ) {
54605479
WP_CLI::log('No worktree artifacts found.');
@@ -5488,7 +5507,7 @@ private function render_worktree_artifact_cleanup_result( array $result, array $
54885507

54895508
if ( ! empty($candidates) ) {
54905509
WP_CLI::log('');
5491-
WP_CLI::log($dry_run ? 'Would remove artifacts:' : 'Artifact candidates:');
5510+
WP_CLI::log($dry_run && is_array($pagination) && 'size' === (string) ( $pagination['sort'] ?? '' ) ? 'Largest artifact opportunities:' : ( $dry_run ? 'Would remove artifacts:' : 'Artifact candidates:' ));
54925511
$this->format_items($this->flatten_artifact_cleanup_rows($candidates), array( 'handle', 'repo', 'branch', 'artifact', 'size', 'path' ), array( 'format' => 'table' ), 'handle');
54935512
}
54945513

@@ -5500,24 +5519,29 @@ private function render_worktree_artifact_cleanup_result( array $result, array $
55005519

55015520
if ( ! empty($skipped) ) {
55025521
WP_CLI::log('');
5503-
WP_CLI::log('Skipped worktrees:');
5504-
$rows = array_map(
5505-
fn( $row ) => array(
5506-
'handle' => $row['handle'] ?? '',
5507-
'repo' => $row['repo'] ?? '',
5508-
'branch' => $row['branch'] ?? '',
5509-
'artifacts' => count( (array) ( $row['artifacts'] ?? array() )),
5510-
'reason_code' => $row['reason_code'] ?? '',
5511-
'reason' => $row['reason'] ?? '',
5512-
),
5513-
$skipped
5514-
);
5515-
$this->format_items($rows, array( 'handle', 'repo', 'branch', 'artifacts', 'reason_code', 'reason' ), array( 'format' => 'table' ), 'handle');
5522+
if ( $verbose ) {
5523+
WP_CLI::log('Skipped worktrees:');
5524+
$rows = array_map(
5525+
fn( $row ) => array(
5526+
'handle' => $row['handle'] ?? '',
5527+
'repo' => $row['repo'] ?? '',
5528+
'branch' => $row['branch'] ?? '',
5529+
'artifacts' => count( (array) ( $row['artifacts'] ?? array() )),
5530+
'reason_code' => $row['reason_code'] ?? '',
5531+
'reason' => $row['reason'] ?? '',
5532+
),
5533+
$skipped
5534+
);
5535+
$this->format_items($rows, array( 'handle', 'repo', 'branch', 'artifacts', 'reason_code', 'reason' ), array( 'format' => 'table' ), 'handle');
5536+
} else {
5537+
WP_CLI::log('Skipped worktrees summary:');
5538+
$this->format_items($this->summarize_cleanup_skipped_rows($skipped), array( 'reason_code', 'count', 'examples' ), array( 'format' => 'table' ), 'reason_code');
5539+
WP_CLI::log('Re-run with --verbose to list every skipped worktree.');
5540+
}
55165541
}
55175542

55185543
WP_CLI::log('');
55195544

5520-
$pagination = $result['pagination'] ?? ( $summary['pagination'] ?? null );
55215545
if ( is_array($pagination) ) {
55225546
$mode_label = (string) ( $pagination['mode'] ?? 'bounded_inventory' );
55235547
WP_CLI::log(
@@ -5534,6 +5558,8 @@ private function render_worktree_artifact_cleanup_result( array $result, array $
55345558
);
55355559
if ( ! empty($pagination['partial']) && isset($pagination['next_offset']) ) {
55365560
WP_CLI::log(sprintf('Partial scan — re-run with --offset=%d to continue, or pass --exhaustive for a full audit.', (int) $pagination['next_offset']));
5561+
} elseif ( 'size' === (string) ( $pagination['sort'] ?? '' ) ) {
5562+
WP_CLI::log(sprintf('Ranked by size across %d scanned worktree(s); showing the largest %d candidate(s).', (int) ( $pagination['scanned'] ?? 0 ), count($candidates)));
55375563
}
55385564
WP_CLI::log('');
55395565
}
@@ -5595,6 +5621,11 @@ private function render_worktree_bounded_cleanup_eligible_apply_result( array $r
55955621
$continuation = (array) ( $result['continuation'] ?? array() );
55965622
$dry_run = ! empty($result['dry_run']);
55975623
$job_backed = ! empty($result['job_backed']);
5624+
$verbose = ! empty($assoc_args['verbose']);
5625+
5626+
if ( ! $dry_run ) {
5627+
WP_CLI::log(sprintf('Result: removed %d worktree(s); reclaimed %s; skipped %d.', (int) ( $summary['removed'] ?? count($removed) ), $this->format_bytes($summary['bytes_reclaimed'] ?? 0), (int) ( $summary['skipped'] ?? count($skipped) )));
5628+
}
55985629

55995630
WP_CLI::log('Bounded cleanup apply summary:');
56005631
$summary_rows = array(
@@ -5666,16 +5697,22 @@ private function render_worktree_bounded_cleanup_eligible_apply_result( array $r
56665697

56675698
if ( ! empty($skipped) ) {
56685699
WP_CLI::log('');
5669-
WP_CLI::log('Skipped:');
5670-
$rows = array_map(
5671-
fn( $row ) => array(
5672-
'handle' => $row['handle'] ?? '',
5673-
'reason_code' => $row['reason_code'] ?? '',
5674-
'reason' => $this->shorten_cleanup_reason( (string) ( $row['reason'] ?? '' )),
5675-
),
5676-
$skipped
5677-
);
5678-
$this->format_items($rows, array( 'handle', 'reason_code', 'reason' ), array( 'format' => 'table' ), 'handle');
5700+
if ( $verbose ) {
5701+
WP_CLI::log('Skipped:');
5702+
$rows = array_map(
5703+
fn( $row ) => array(
5704+
'handle' => $row['handle'] ?? '',
5705+
'reason_code' => $row['reason_code'] ?? '',
5706+
'reason' => $this->shorten_cleanup_reason( (string) ( $row['reason'] ?? '' )),
5707+
),
5708+
$skipped
5709+
);
5710+
$this->format_items($rows, array( 'handle', 'reason_code', 'reason' ), array( 'format' => 'table' ), 'handle');
5711+
} else {
5712+
WP_CLI::log('Skipped summary:');
5713+
$this->format_items($this->summarize_cleanup_skipped_rows($skipped), array( 'reason_code', 'count', 'examples' ), array( 'format' => 'table' ), 'reason_code');
5714+
WP_CLI::log('Re-run with --verbose to list every skipped row.');
5715+
}
56795716
}
56805717

56815718
WP_CLI::log('');
@@ -5941,6 +5978,43 @@ private function render_cleanup_truncation_hint( int $total, int $limit, string
59415978
WP_CLI::log(sprintf('Showing %d of %d %s. Re-run with --verbose for all rows or --only=<reason_code> to filter.', $limit, $total, $label));
59425979
}
59435980

5981+
/**
5982+
* Summarize skipped cleanup rows by reason with representative handles.
5983+
*
5984+
* @param array<int,array<string,mixed>> $skipped Skipped rows.
5985+
* @return array<int,array<string,mixed>>
5986+
*/
5987+
private function summarize_cleanup_skipped_rows( array $skipped ): array {
5988+
$summary = array();
5989+
foreach ( $skipped as $row ) {
5990+
$reason_code = (string) ( $row['reason_code'] ?? 'unknown' );
5991+
if ( ! isset($summary[ $reason_code ]) ) {
5992+
$summary[ $reason_code ] = array(
5993+
'reason_code' => $reason_code,
5994+
'count' => 0,
5995+
'examples' => array(),
5996+
);
5997+
}
5998+
++$summary[ $reason_code ]['count'];
5999+
$handle = (string) ( $row['handle'] ?? '' );
6000+
if ( '' !== $handle && count($summary[ $reason_code ]['examples']) < 3 ) {
6001+
$summary[ $reason_code ]['examples'][] = $handle;
6002+
}
6003+
}
6004+
6005+
ksort($summary);
6006+
return array_values(
6007+
array_map(
6008+
fn( $row ) => array(
6009+
'reason_code' => $row['reason_code'],
6010+
'count' => $row['count'],
6011+
'examples' => implode(', ', $row['examples']),
6012+
),
6013+
$summary
6014+
)
6015+
);
6016+
}
6017+
59446018
/**
59456019
* Format a byte count without depending on WordPress helpers in smoke tests.
59466020
*

inc/Workspace/WorkspaceArtifactCleanup.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E
4141
$force = ! empty($opts['force']);
4242
$apply_plan = isset($opts['apply_plan']) && is_array($opts['apply_plan']) ? $opts['apply_plan'] : null;
4343
$exhaustive = ! empty($opts['exhaustive']);
44+
$sort = isset($opts['sort']) ? strtolower(trim( (string) $opts['sort'])) : '';
4445
$limit = isset($opts['limit']) ? (int) $opts['limit'] : self::ARTIFACT_CLEANUP_DEFAULT_LIMIT;
4546
$offset = isset($opts['offset']) ? max(0, (int) $opts['offset']) : 0;
4647
if ( $limit < 0 ) {
@@ -87,11 +88,14 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E
8788
$only_handles = array_keys($only_handles);
8889
}
8990

91+
$rank_by_size = $dry_run && null === $apply_plan && ! $exhaustive && in_array($sort, array( 'size', 'bytes' ), true);
92+
$plan_limit = $rank_by_size ? 0 : $limit;
93+
9094
$plan = $this->build_worktree_artifact_cleanup_plan(
9195
$force,
9296
array(
93-
'limit' => $limit,
94-
'offset' => $offset,
97+
'limit' => $plan_limit,
98+
'offset' => $rank_by_size ? 0 : $offset,
9599
'only_handles' => $only_handles,
96100
'safety_probes' => $safety_probes,
97101
)
@@ -110,6 +114,25 @@ public function worktree_cleanup_artifacts( array $opts = array() ): array|\WP_E
110114
$skipped = $scoped['skipped'];
111115
}
112116

117+
if ( $rank_by_size ) {
118+
usort($candidates, fn( $a, $b ) => (int) ( $b['artifact_size_bytes'] ?? 0 ) <=> (int) ( $a['artifact_size_bytes'] ?? 0 ));
119+
$total_ranked = count($candidates);
120+
$candidates = array_slice($candidates, 0, $limit);
121+
$pagination = array(
122+
'mode' => 'ranked_inventory',
123+
'limit' => $limit,
124+
'offset' => 0,
125+
'scanned' => (int) ( $pagination['scanned'] ?? 0 ),
126+
'total' => (int) ( $pagination['total'] ?? 0 ),
127+
'complete' => true,
128+
'partial' => false,
129+
'next_offset' => null,
130+
'safety_probes' => $safety_probes,
131+
'sort' => 'size',
132+
'ranked_total' => $total_ranked,
133+
);
134+
}
135+
113136
$summary = $this->build_worktree_artifact_cleanup_summary($candidates, array(), $skipped);
114137
if ( null !== $pagination ) {
115138
$summary['pagination'] = $pagination;

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,16 +105,25 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
105105

106106
$disk_budget = WorktreeDiskBudget::inspect($this->workspace_path, WorktreeDiskBudget::thresholds($repo, $branch), $force);
107107
if ( 'refused' === ( $disk_budget['status'] ?? '' ) ) {
108+
$recommendations = array_map(
109+
fn( $row ) => sprintf(
110+
'%d. %s: %s (target reclaim: %s)',
111+
(int) ( $row['priority'] ?? 0 ),
112+
(string) ( $row['action'] ?? 'cleanup' ),
113+
(string) ( $row['command'] ?? '' ),
114+
(string) ( $row['expected_reclaim'] ?? 'unknown' )
115+
),
116+
(array) ( $disk_budget['cleanup_recommendations'] ?? array() )
117+
);
108118
return new \WP_Error(
109119
'worktree_disk_budget_exceeded',
110120
sprintf(
111-
"Refusing to create worktree before bootstrap/install because the workspace disk budget is unsafe.\n%s\nThreshold: keep at least %.1f GiB free and %.1f%% free; effective floor on this filesystem is %.1f GiB.\nRun %s to review cleanup candidates, run %s to review artifact cleanup, or retry with --force only when a human explicitly accepts the disk-pressure risk.",
121+
"Refusing to create worktree before bootstrap/install because the workspace disk budget is unsafe.\n%s\nThreshold: keep at least %.1f GiB free and %.1f%% free; effective floor on this filesystem is %.1f GiB.\nRecommended cleanup, in order:\n%s\nRetry with --force only when a human explicitly accepts the disk-pressure risk.",
112122
WorktreeDiskBudget::format_summary($disk_budget),
113123
(float) ( $disk_budget['refuse_free_gib'] ?? 0 ),
114124
(float) ( $disk_budget['refuse_free_percent'] ?? 0 ),
115125
(float) ( $disk_budget['effective_refuse_gib'] ?? 0 ),
116-
$disk_budget['cleanup_dry_run_command'],
117-
$disk_budget['artifact_cleanup_command']
126+
implode("\n", array_filter($recommendations))
118127
),
119128
array(
120129
'status' => 507,

0 commit comments

Comments
 (0)