Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 16 additions & 20 deletions inc/Workspace/WorkspaceHygieneReport.php
Original file line number Diff line number Diff line change
Expand Up @@ -726,24 +726,17 @@ private function summarize_workspace_cleanup( ?array $cleanup, ?array $error, ar
private function build_workspace_fast_stats( array $worktrees, ?array $cleanup, array $size_report, bool $include_worktree_status ): array {
$cleanup_candidates = (array) ( $cleanup['candidates'] ?? array() );
$cleanup_summary = (array) ( $cleanup['summary'] ?? array() );
$safe_handles = array();
foreach ( $cleanup_candidates as $candidate ) {
$handle = is_array($candidate) ? (string) ( $candidate['handle'] ?? '' ) : '';
if ( '' !== $handle ) {
$safe_handles[ $handle ] = true;
}
}

$counts = array(
'total_candidates' => count($worktrees),
'safe_removable_count' => count($cleanup_candidates),
'valid_clean_count' => 0,
'valid_dirty_count' => 0,
'invalid_broken_orphan_count' => 0,
'unmanaged_skipped_count' => 0,
'dirty_probe_skipped_count' => 0,
'known_worktree_count' => 0,
'known_primary_count' => 0,
'total_candidates' => count($worktrees),
'cleanup_eligible_unprobed_count' => count($cleanup_candidates),
'valid_clean_count' => 0,
'valid_dirty_count' => 0,
'invalid_broken_orphan_count' => 0,
'unmanaged_skipped_count' => 0,
'dirty_probe_skipped_count' => 0,
'known_worktree_count' => 0,
'known_primary_count' => 0,
);

foreach ( $worktrees as $row ) {
Expand Down Expand Up @@ -776,9 +769,6 @@ private function build_workspace_fast_stats( array $worktrees, ?array $cleanup,
$dirty = $row['dirty'] ?? null;
if ( null === $dirty ) {
++$counts['dirty_probe_skipped_count'];
if ( isset($safe_handles[ (string) ( $row['handle'] ?? '' ) ]) ) {
++$counts['valid_clean_count'];
}
continue;
}

Expand All @@ -800,11 +790,17 @@ private function build_workspace_fast_stats( array $worktrees, ?array $cleanup,
$estimated_reclaimable += max(0, (int) ( is_array($candidate) ? ( $candidate['size_bytes'] ?? 0 ) : 0 ));
}
}
$safety_probe_status = $include_worktree_status ? 'worktree_status_requested' : 'not_run_inventory_only';
$safety_probe_note = $include_worktree_status
? 'Worktree status was requested for the inventory rows; destructive cleanup still revalidates each row before removal.'
: 'Cleanup-eligible rows came from cheap inventory only; bounded apply revalidates dirty state, unpushed commits, containment, and primary safety before deletion.';

return array(
'mode' => $include_worktree_status ? 'full_git_status' : 'cheap_metadata_first',
'partial' => ! $include_worktree_status || empty($size_report['scan_complete']),
'status_probe_required_for_summary' => false,
'safety_probe_status' => $safety_probe_status,
'safety_probe_note' => $safety_probe_note,
'status_probe_required_for_summary' => ! $include_worktree_status,
'counts' => $counts,
'estimated_reclaimable_bytes' => $estimated_reclaimable,
'estimated_reclaimable_human' => $this->format_bytes($estimated_reclaimable),
Expand Down
22 changes: 18 additions & 4 deletions inc/Workspace/WorkspaceWorktreeCleanupEngine.php
Original file line number Diff line number Diff line change
Expand Up @@ -1782,9 +1782,17 @@ private function build_worktree_remove_failure_skip( array $candidate, \WP_Error
* @param array<int,array> $candidates Candidate rows.
* @param array<int,array> $removed Removed rows.
* @param array<int,array> $skipped Skipped rows.
* @param array|null $age_filter Optional age filter summary.
* @param string $candidate_bucket Bucket to use for candidate rows.
* @return array<string,mixed>
*/
private function build_worktree_cleanup_summary( array $candidates, array $removed, array $skipped, ?array $age_filter = null ): array {
private function build_worktree_cleanup_summary(
array $candidates,
array $removed,
array $skipped,
?array $age_filter = null,
string $candidate_bucket = WorktreeCleanupClassifier::BUCKET_SAFE_TO_REMOVE_NOW
): array {
$skipped_by_reason = array();
$candidates_by_signal = array();
$stale_reasons = array();
Expand Down Expand Up @@ -1840,7 +1848,7 @@ private function build_worktree_cleanup_summary( array $candidates, array $remov
'skipped' => count($skipped),
'skipped_by_reason' => $skipped_by_reason,
'skipped_next_commands' => $this->worktree_cleanup_skipped_next_commands($skipped_by_reason),
'cleanup_buckets' => $this->worktree_cleanup_buckets(count($candidates), $candidates_by_signal, $skipped_by_reason),
'cleanup_buckets' => $this->worktree_cleanup_buckets(count($candidates), $candidates_by_signal, $skipped_by_reason, $candidate_bucket),
'candidates_by_signal' => $candidates_by_signal,
'stale_reasons' => $stale_reasons,
'liveness' => $liveness,
Expand Down Expand Up @@ -1944,10 +1952,16 @@ private function worktree_declares_submodules( string $path ): bool {
* @param int $candidate_count Candidate row count.
* @param array<string,int> $candidates_by_signal Candidate signal counts.
* @param array<string,int> $skipped_by_reason Skipped reason counts.
* @param string $candidate_bucket Bucket to use for candidate rows.
* @return array<string,int>
*/
private function worktree_cleanup_buckets( int $candidate_count, array $candidates_by_signal, array $skipped_by_reason ): array {
return WorktreeCleanupClassifier::buckets($candidate_count, $candidates_by_signal, $skipped_by_reason);
private function worktree_cleanup_buckets(
int $candidate_count,
array $candidates_by_signal,
array $skipped_by_reason,
string $candidate_bucket = WorktreeCleanupClassifier::BUCKET_SAFE_TO_REMOVE_NOW
): array {
return WorktreeCleanupClassifier::buckets($candidate_count, $candidates_by_signal, $skipped_by_reason, $candidate_bucket);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions inc/Workspace/WorkspaceWorktreeInventoryCleanup.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so
$candidate = array_merge(
$base_row,
array(
'dirty' => 0,
'dirty' => null,
'safety_probe_status' => 'not_run_inventory_only',
),
WorktreeCleanupSignal::candidate_fields($signal ?? array(), true)
);
Expand All @@ -186,7 +187,7 @@ private function worktree_cleanup_inventory_only( string $older_than, string $so

$candidates = $this->sort_worktree_cleanup_rows($candidates, $sort);
$pagination = $this->build_worktree_cleanup_pagination($offset, $limit, $processed, $total, false, null);
$summary = $this->build_worktree_cleanup_summary($candidates, array(), $skipped, $age_filter);
$summary = $this->build_worktree_cleanup_summary($candidates, array(), $skipped, $age_filter, WorktreeCleanupClassifier::BUCKET_CLEANUP_ELIGIBLE_UNPROBED);
if ( null !== $pagination ) {
$summary['pagination'] = $pagination;
}
Expand Down
13 changes: 11 additions & 2 deletions inc/Workspace/WorktreeCleanupClassifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
final class WorktreeCleanupClassifier {

public const BUCKET_SAFE_TO_REMOVE_NOW = 'safe_to_remove_now';
public const BUCKET_CLEANUP_ELIGIBLE_UNPROBED = 'cleanup_eligible_pending_revalidation';
public const BUCKET_NEEDS_RECONCILIATION = 'needs_reconciliation';
public const BUCKET_NEEDS_FULL_REVIEW = 'needs_full_review';
public const BUCKET_BLOCKED_BY_DIRTY_OR_UNPUSHED = 'blocked_by_dirty_or_unpushed';
Expand Down Expand Up @@ -122,16 +123,24 @@ public static function bucket_for_reason( string $reason_code ): string {
* @param int $candidate_count Candidate row count.
* @param array<string,int> $candidates_by_signal Candidate signal counts.
* @param array<string,int> $skipped_by_reason Skipped reason counts.
* @param string $candidate_bucket Bucket to use for candidate rows.
* @return array<string,int>
*/
public static function buckets( int $candidate_count, array $candidates_by_signal, array $skipped_by_reason ): array {
public static function buckets(
int $candidate_count,
array $candidates_by_signal,
array $skipped_by_reason,
string $candidate_bucket = self::BUCKET_SAFE_TO_REMOVE_NOW
): array {
$buckets = array(
self::BUCKET_ARTIFACT_ONLY_DIRTY => 0,
self::BUCKET_BLOCKED_BY_DIRTY_OR_UNPUSHED => 0,
self::BUCKET_CLEANUP_ELIGIBLE_UNPROBED => 0,
self::BUCKET_NEEDS_FULL_REVIEW => 0,
self::BUCKET_NEEDS_RECONCILIATION => 0,
self::BUCKET_SAFE_TO_REMOVE_NOW => $candidate_count,
self::BUCKET_SAFE_TO_REMOVE_NOW => 0,
);
$buckets[ $candidate_bucket ] = ( $buckets[ $candidate_bucket ] ?? 0 ) + $candidate_count;

foreach ( $skipped_by_reason as $reason_code => $count ) {
$bucket = self::bucket_for_reason( (string) $reason_code );
Expand Down
3 changes: 2 additions & 1 deletion inc/Workspace/WorktreeDiskBudget.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,13 @@ private static function cleanup_recommendations( ?int $free_bytes, int $effectiv
),
array(
'priority' => 2,
'action' => 'review and apply bounded cleanup-eligible worktrees',
'action' => 'review bounded cleanup-eligible worktrees; apply revalidates before removal',
'expected_reclaim_bytes' => $target_reclaim,
'expected_reclaim' => $target_human,
'command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25',
'preview_command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25',
'apply_command' => 'studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --limit=25',
'apply_note' => 'Apply runs fresh dirty, unpushed, containment, and primary safety probes and may skip rows that the cheap inventory review listed.',
),
array(
'priority' => 3,
Expand Down
20 changes: 19 additions & 1 deletion tests/workspace-compact-output.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,24 @@ function compact_output_large_rows( int $count ): array {
'success' => true,
'workspace_path' => '/workspace',
'disk' => array( 'free_bytes' => 999 ),
'fast_stats' => array(
'counts' => array(
'cleanup_eligible_unprobed_count' => 40,
'dirty_probe_skipped_count' => 40,
),
'safety_probe_status' => 'not_run_inventory_only',
),
'worktrees' => array( 'worktrees' => 40, 'protected_dirty' => 20 ),
'locks' => array( 'active' => 2, 'stale' => 40, 'database' => array( 'locks' => $large_rows ) ),
'cleanup' => array(
'summary' => array( 'would_remove' => 40, 'artifact_size_bytes' => 654321 ),
'summary' => array(
'would_remove' => 40,
'artifact_size_bytes' => 654321,
'cleanup_buckets' => array(
'cleanup_eligible_pending_revalidation' => 40,
'safe_to_remove_now' => 0,
),
),
'biggest_candidates' => $large_rows,
),
'size' => array(
Expand All @@ -179,6 +193,10 @@ function compact_output_large_rows( int $count ): array {
);

compact_output_assert(40 === ( $hygiene['worktrees']['worktrees'] ?? null ), 'Compact hygiene output must preserve worktree counts.');
compact_output_assert(40 === ( $hygiene['fast_stats']['counts']['cleanup_eligible_unprobed_count'] ?? null ), 'Compact hygiene output must label cheap cleanup candidates as unprobed.');
compact_output_assert(! isset($hygiene['fast_stats']['counts']['safe_removable_count']), 'Compact hygiene output must not expose misleading safe_removable_count for cheap inventory.');
compact_output_assert(40 === ( $hygiene['cleanup']['summary']['cleanup_buckets']['cleanup_eligible_pending_revalidation'] ?? null ), 'Compact cleanup summary must preserve pending-revalidation bucket.');
compact_output_assert(0 === ( $hygiene['cleanup']['summary']['cleanup_buckets']['safe_to_remove_now'] ?? null ), 'Compact cleanup summary must not mark unprobed inventory candidates safe.');
compact_output_assert(123456 === ( $hygiene['size']['total_bytes'] ?? null ), 'Compact hygiene output must preserve size bytes.');
compact_output_assert(40 === ( $hygiene['size']['entry_count'] ?? null ), 'Compact hygiene output must preserve size entry count.');
compact_output_assert(count((array) ( $hygiene['cleanup']['biggest_candidates'] ?? array() )) <= 5, 'Compact hygiene output must sample cleanup candidates.');
Expand Down
14 changes: 14 additions & 0 deletions tests/worktree-cleanup-candidate-classifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@

require_once dirname(__DIR__) . '/inc/Workspace/WorktreeAgeFilter.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupSignal.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupClassifier.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupCandidateClassifier.php';

use DataMachineCode\Workspace\WorktreeAgeFilter;
use DataMachineCode\Workspace\WorktreeCleanupClassifier;
use DataMachineCode\Workspace\WorktreeCleanupCandidateClassifier;

function worktree_cleanup_candidate_assert_same( mixed $expected, mixed $actual, string $message ): void {
Expand Down Expand Up @@ -86,4 +88,16 @@ function (): array {
worktree_cleanup_candidate_assert_same('age_filter', $age_skip['row']['reason_code'], 'age skip reason_code matches cleanup contract');
worktree_cleanup_candidate_assert_same(1, $recent_age_filter['excluded'], 'age filter excluded counter is updated');

$inventory_buckets = WorktreeCleanupClassifier::buckets(
3,
array( 'cleanup_eligible' => 3 ),
array(),
WorktreeCleanupClassifier::BUCKET_CLEANUP_ELIGIBLE_UNPROBED
);
worktree_cleanup_candidate_assert_same(3, $inventory_buckets['cleanup_eligible_pending_revalidation'], 'inventory-only candidates are pending revalidation');
worktree_cleanup_candidate_assert_same(0, $inventory_buckets['safe_to_remove_now'], 'inventory-only candidates are not labeled safe to remove now');

$probed_buckets = WorktreeCleanupClassifier::buckets(2, array(), array());
worktree_cleanup_candidate_assert_same(2, $probed_buckets['safe_to_remove_now'], 'probed cleanup candidates keep the safe-to-remove bucket');

echo "worktree-cleanup-candidate-classifier: ok\n";
2 changes: 2 additions & 0 deletions tests/worktree-disk-budget.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ function assert_true( bool $condition, string $message ): void {
assert_true(in_array('studio wp datamachine-code workspace worktree emergency-cleanup --format=json', $commands, true), 'emergency cleanup report command is missing');

$bounded = $budget['cleanup_recommendations'][1];
assert_true(str_contains($bounded['action'], 'apply revalidates'), 'bounded cleanup action must explain apply revalidation');
assert_true('studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --dry-run --limit=25' === $bounded['preview_command'], 'bounded cleanup preview command is missing');
assert_true('studio wp datamachine-code workspace worktree bounded-cleanup-eligible-apply --limit=25' === $bounded['apply_command'], 'bounded cleanup apply command is missing');
assert_true(str_contains($bounded['apply_note'], 'may skip rows'), 'bounded cleanup apply note must explain dirty/unpushed revalidation can block removal');

fwrite(STDOUT, "worktree-disk-budget ok\n");
} catch (Throwable $e) {
Expand Down
Loading