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
2 changes: 1 addition & 1 deletion inc/Support/GitRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ public static function run( string $path, string $args, int $timeout_seconds = 0
);

if ( is_wp_error($result) ) {
$data = $result->get_error_data();
$data = method_exists($result, 'get_error_data') ? $result->get_error_data() : array();
$data = is_array($data) ? $data : array();
if ( $timeout_seconds > 0 && isset($data['timeout']) ) {
return new \WP_Error('git_command_timeout', $result->get_error_message(), $data);
Expand Down
40 changes: 2 additions & 38 deletions inc/Workspace/Workspace.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
require_once __DIR__ . '/WorkspaceWorktreeLifecycle.php';
require_once __DIR__ . '/WorkspaceWorktreeInventoryCleanup.php';
require_once __DIR__ . '/WorkspaceWorktreeEmergencyCleanup.php';
require_once __DIR__ . '/WorktreeCleanupClassifier.php';

class Workspace {

Expand Down Expand Up @@ -3444,44 +3445,7 @@ private function worktree_declares_submodules( string $path ): bool {
* @return array<string,int>
*/
private function worktree_cleanup_buckets( int $candidate_count, array $candidates_by_signal, array $skipped_by_reason ): array {
$needs_reconciliation = (int) ( $skipped_by_reason['needs_metadata_reconcile'] ?? 0 )
+ (int) ( $skipped_by_reason['requires_full_scan'] ?? 0 )
+ (int) ( $skipped_by_reason['missing_metadata'] ?? 0 )
+ (int) ( $skipped_by_reason['lifecycle_reconciliation_candidate'] ?? 0 );
$needs_full_review = (int) ( $skipped_by_reason['active_no_signal'] ?? 0 )
+ (int) ( $skipped_by_reason['no_inventory_cleanup_signal'] ?? 0 )
+ (int) ( $skipped_by_reason['no_merge_signal'] ?? 0 )
+ (int) ( $skipped_by_reason['github_unknown'] ?? 0 )
+ (int) ( $skipped_by_reason['external_worktree'] ?? 0 )
+ (int) ( $skipped_by_reason['protected_branch'] ?? 0 )
+ (int) ( $skipped_by_reason['protected_base_branch_worktree'] ?? 0 )
+ (int) ( $skipped_by_reason['detached_worktree'] ?? 0 )
+ (int) ( $skipped_by_reason['detached_protected_branch'] ?? 0 )
+ (int) ( $skipped_by_reason['submodule_worktree'] ?? 0 )
+ (int) ( $skipped_by_reason['probe_timeout'] ?? 0 )
+ (int) ( $skipped_by_reason['unknown_age'] ?? 0 );
$blocked_by_dirty_or_unpushed = (int) ( $skipped_by_reason['dirty_worktree'] ?? 0 )
+ (int) ( $skipped_by_reason['merged_pr_with_only_obsolete_dirty_changes'] ?? 0 )
+ (int) ( $skipped_by_reason['unpushed_commits'] ?? 0 );

$buckets = array(
'artifact_only_dirty_worktree' => (int) ( $skipped_by_reason['artifact_only_dirty_worktree'] ?? 0 ),
'blocked_by_dirty_or_unpushed' => $blocked_by_dirty_or_unpushed,
'needs_full_review' => $needs_full_review,
'needs_reconciliation' => $needs_reconciliation,
'safe_to_remove_now' => $candidate_count,

// Legacy aliases retained for existing automation while callers migrate
// to the explicit safety buckets above.
'explicit_cleanup_candidates' => (int) ( $candidates_by_signal['cleanup_eligible'] ?? 0 ),
'lifecycle_reconciliation_candidates' => (int) ( $skipped_by_reason['lifecycle_reconciliation_candidate'] ?? 0 ),
'metadata_reconciliation_candidates' => (int) ( $skipped_by_reason['needs_metadata_reconcile'] ?? 0 ) + (int) ( $skipped_by_reason['requires_full_scan'] ?? 0 ) + (int) ( $skipped_by_reason['missing_metadata'] ?? 0 ),
'dirty_unpushed' => $blocked_by_dirty_or_unpushed,
'active_no_signal' => (int) ( $skipped_by_reason['active_no_signal'] ?? 0 ) + (int) ( $skipped_by_reason['no_inventory_cleanup_signal'] ?? 0 ),
);

ksort($buckets);
return $buckets;
return WorktreeCleanupClassifier::buckets($candidate_count, $candidates_by_signal, $skipped_by_reason);
}

/**
Expand Down
14 changes: 7 additions & 7 deletions inc/Workspace/WorkspaceCleanupPlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@

defined('ABSPATH') || exit;

if ( ! class_exists(WorktreeCleanupClassifier::class) ) {
require_once __DIR__ . '/WorktreeCleanupClassifier.php';
}

trait WorkspaceCleanupPlan {


Expand Down Expand Up @@ -220,16 +224,12 @@ private function build_cleanup_plan_resolver_rows( array $skipped ): array {
continue;
}
$reason = (string) ( $row['reason_code'] ?? '' );
if ( ! in_array($reason, array( 'needs_metadata_reconcile', 'requires_full_scan', 'lifecycle_reconciliation_candidate', 'active_no_signal', 'no_inventory_cleanup_signal' ), true) ) {
if ( ! WorktreeCleanupClassifier::is_resolver_reason($reason) ) {
continue;
}

$resolver_type = match ( $reason ) {
'needs_metadata_reconcile', 'requires_full_scan' => 'metadata_reconciliation',
'lifecycle_reconciliation_candidate' => 'lifecycle_reconciliation',
default => 'merge_signal',
};
$next_action = match ( $resolver_type ) {
$resolver_type = WorktreeCleanupClassifier::resolver_type($reason);
$next_action = match ( $resolver_type ) {
'metadata_reconciliation' => 'workspace worktree reconcile-metadata --dry-run --format=json',
'lifecycle_reconciliation' => 'workspace worktree cleanup --dry-run --format=json',
default => 'workspace worktree cleanup --dry-run --skip-github --format=json',
Expand Down
153 changes: 153 additions & 0 deletions inc/Workspace/WorktreeCleanupClassifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php
/**
* Worktree cleanup classification.
*
* Centralizes the stable reason-code to bucket mapping used by worktree cleanup
* reports, cleanup plans, and resolver rows. This class is intentionally
* non-mutating: apply paths still perform their own fresh safety revalidation.
*
* @package DataMachineCode\Workspace
*/

namespace DataMachineCode\Workspace;

defined('ABSPATH') || exit;

final class WorktreeCleanupClassifier {

public const BUCKET_SAFE_TO_REMOVE_NOW = 'safe_to_remove_now';
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';
public const BUCKET_ARTIFACT_ONLY_DIRTY = 'artifact_only_dirty_worktree';

/**
* Reason codes that indicate metadata/lifecycle reconciliation should run
* before cleanup eligibility can be decided.
*
* @var string[]
*/
private const RECONCILIATION_REASONS = array(
'needs_metadata_reconcile',
'requires_full_scan',
'missing_metadata',
'lifecycle_reconciliation_candidate',
);

/**
* Reason codes that are not deletion candidates and require human/full review.
*
* @var string[]
*/
private const FULL_REVIEW_REASONS = array(
'active_no_signal',
'no_inventory_cleanup_signal',
'no_merge_signal',
'github_unknown',
'external_worktree',
'protected_branch',
'protected_base_branch_worktree',
'detached_worktree',
'detached_protected_branch',
'submodule_worktree',
'probe_timeout',
'unknown_age',
);

/**
* Reason codes blocked by uncommitted or unpushed user work.
*
* @var string[]
*/
private const DIRTY_OR_UNPUSHED_REASONS = array(
'dirty_worktree',
'merged_pr_with_only_obsolete_dirty_changes',
'unpushed_commits',
);

/**
* Reason codes that can generate read-only resolver plan rows.
*
* @var string[]
*/
private const RESOLVER_REASONS = array(
'needs_metadata_reconcile',
'requires_full_scan',
'lifecycle_reconciliation_candidate',
'active_no_signal',
'no_inventory_cleanup_signal',
);

/**
* Classify one skipped reason code into a stable high-level cleanup bucket.
*/
public static function bucket_for_reason( string $reason_code ): string {
if ( 'artifact_only_dirty_worktree' === $reason_code ) {
return self::BUCKET_ARTIFACT_ONLY_DIRTY;
}

if ( in_array($reason_code, self::DIRTY_OR_UNPUSHED_REASONS, true) ) {
return self::BUCKET_BLOCKED_BY_DIRTY_OR_UNPUSHED;
}

if ( in_array($reason_code, self::RECONCILIATION_REASONS, true) ) {
return self::BUCKET_NEEDS_RECONCILIATION;
}

if ( in_array($reason_code, self::FULL_REVIEW_REASONS, true) ) {
return self::BUCKET_NEEDS_FULL_REVIEW;
}

return self::BUCKET_NEEDS_FULL_REVIEW;
}

/**
* Build stable high-level bucket counts from cleanup summary primitives.
*
* @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.
* @return array<string,int>
*/
public static function buckets( int $candidate_count, array $candidates_by_signal, array $skipped_by_reason ): array {
$buckets = array(
self::BUCKET_ARTIFACT_ONLY_DIRTY => 0,
self::BUCKET_BLOCKED_BY_DIRTY_OR_UNPUSHED => 0,
self::BUCKET_NEEDS_FULL_REVIEW => 0,
self::BUCKET_NEEDS_RECONCILIATION => 0,
self::BUCKET_SAFE_TO_REMOVE_NOW => $candidate_count,
);

foreach ( $skipped_by_reason as $reason_code => $count ) {
$bucket = self::bucket_for_reason( (string) $reason_code );
$buckets[ $bucket ] = ( $buckets[ $bucket ] ?? 0 ) + (int) $count;
}

$buckets['explicit_cleanup_candidates'] = (int) ( $candidates_by_signal['cleanup_eligible'] ?? 0 );
$buckets['lifecycle_reconciliation_candidates'] = (int) ( $skipped_by_reason['lifecycle_reconciliation_candidate'] ?? 0 );
$buckets['metadata_reconciliation_candidates'] = (int) ( $skipped_by_reason['needs_metadata_reconcile'] ?? 0 ) + (int) ( $skipped_by_reason['requires_full_scan'] ?? 0 ) + (int) ( $skipped_by_reason['missing_metadata'] ?? 0 );
$buckets['dirty_unpushed'] = $buckets[ self::BUCKET_BLOCKED_BY_DIRTY_OR_UNPUSHED ];
$buckets['active_no_signal'] = (int) ( $skipped_by_reason['active_no_signal'] ?? 0 ) + (int) ( $skipped_by_reason['no_inventory_cleanup_signal'] ?? 0 );

ksort($buckets);
return $buckets;
}

/**
* Whether a skip row reason can produce a read-only resolver plan row.
*/
public static function is_resolver_reason( string $reason_code ): bool {
return in_array($reason_code, self::RESOLVER_REASONS, true);
}

/**
* Return resolver type for a skip reason.
*/
public static function resolver_type( string $reason_code ): string {
return match ( $reason_code ) {
'needs_metadata_reconcile', 'requires_full_scan' => 'metadata_reconciliation',
'lifecycle_reconciliation_candidate' => 'lifecycle_reconciliation',
default => 'merge_signal',
};
}
}
85 changes: 85 additions & 0 deletions tests/smoke-worktree-cleanup-classifier.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php
/**
* Pure-PHP smoke test for worktree cleanup classification buckets.
*
* Run: php tests/smoke-worktree-cleanup-classifier.php
*/

declare( strict_types=1 );

if ( ! defined('ABSPATH') ) {
define('ABSPATH', __DIR__ . '/');
}

require __DIR__ . '/../inc/Workspace/WorktreeCleanupClassifier.php';

use DataMachineCode\Workspace\WorktreeCleanupClassifier;

$failures = array();
$total = 0;
$assert = function ( string $label, bool $condition ) use ( &$failures, &$total ): void {
++$total;
if ( $condition ) {
echo " ok {$label}\n";
return;
}

$failures[] = $label;
echo " fail {$label}\n";
};

echo "Worktree cleanup classifier - smoke\n";

$assert('metadata skip maps to reconciliation', WorktreeCleanupClassifier::BUCKET_NEEDS_RECONCILIATION === WorktreeCleanupClassifier::bucket_for_reason('needs_metadata_reconcile'));
$assert('lifecycle skip maps to reconciliation', WorktreeCleanupClassifier::BUCKET_NEEDS_RECONCILIATION === WorktreeCleanupClassifier::bucket_for_reason('lifecycle_reconciliation_candidate'));
$assert('dirty skip maps to dirty blocker', WorktreeCleanupClassifier::BUCKET_BLOCKED_BY_DIRTY_OR_UNPUSHED === WorktreeCleanupClassifier::bucket_for_reason('dirty_worktree'));
$assert('unpushed skip maps to dirty blocker', WorktreeCleanupClassifier::BUCKET_BLOCKED_BY_DIRTY_OR_UNPUSHED === WorktreeCleanupClassifier::bucket_for_reason('unpushed_commits'));
$assert('artifact-only dirty has distinct bucket', WorktreeCleanupClassifier::BUCKET_ARTIFACT_ONLY_DIRTY === WorktreeCleanupClassifier::bucket_for_reason('artifact_only_dirty_worktree'));
$assert('active/no-signal maps to full review', WorktreeCleanupClassifier::BUCKET_NEEDS_FULL_REVIEW === WorktreeCleanupClassifier::bucket_for_reason('active_no_signal'));
$assert('unknown reasons fail into full review', WorktreeCleanupClassifier::BUCKET_NEEDS_FULL_REVIEW === WorktreeCleanupClassifier::bucket_for_reason('future_unknown_reason'));

$buckets = WorktreeCleanupClassifier::buckets(
2,
array(
'cleanup_eligible' => 1,
),
array(
'needs_metadata_reconcile' => 2,
'requires_full_scan' => 1,
'lifecycle_reconciliation_candidate' => 1,
'active_no_signal' => 3,
'github_unknown' => 1,
'dirty_worktree' => 1,
'merged_pr_with_only_obsolete_dirty_changes' => 1,
'unpushed_commits' => 1,
'artifact_only_dirty_worktree' => 1,
)
);

$assert('bucket counts safe candidates', 2 === (int) ( $buckets['safe_to_remove_now'] ?? -1 ));
$assert('bucket counts reconciliation reasons', 4 === (int) ( $buckets['needs_reconciliation'] ?? -1 ));
$assert('bucket counts full review reasons', 4 === (int) ( $buckets['needs_full_review'] ?? -1 ));
$assert('bucket counts dirty/unpushed blockers', 3 === (int) ( $buckets['blocked_by_dirty_or_unpushed'] ?? -1 ));
$assert('bucket counts artifact-only dirty separately', 1 === (int) ( $buckets['artifact_only_dirty_worktree'] ?? -1 ));
$assert('legacy explicit cleanup alias remains', 1 === (int) ( $buckets['explicit_cleanup_candidates'] ?? -1 ));
$assert('legacy metadata alias remains', 3 === (int) ( $buckets['metadata_reconciliation_candidates'] ?? -1 ));
$assert('legacy lifecycle alias remains', 1 === (int) ( $buckets['lifecycle_reconciliation_candidates'] ?? -1 ));
$assert('legacy active alias remains', 3 === (int) ( $buckets['active_no_signal'] ?? -1 ));
$assert('legacy dirty alias remains', 3 === (int) ( $buckets['dirty_unpushed'] ?? -1 ));

$assert('metadata rows can produce resolver rows', WorktreeCleanupClassifier::is_resolver_reason('needs_metadata_reconcile'));
$assert('dirty rows do not produce resolver rows', ! WorktreeCleanupClassifier::is_resolver_reason('dirty_worktree'));
$assert('metadata resolver type is stable', 'metadata_reconciliation' === WorktreeCleanupClassifier::resolver_type('requires_full_scan'));
$assert('lifecycle resolver type is stable', 'lifecycle_reconciliation' === WorktreeCleanupClassifier::resolver_type('lifecycle_reconciliation_candidate'));
$assert('active resolver type is merge signal', 'merge_signal' === WorktreeCleanupClassifier::resolver_type('active_no_signal'));

if ( ! empty($failures) ) {
echo "\nFAIL: " . count($failures) . " assertion(s) failed out of {$total}\n";
foreach ( $failures as $failure ) {
echo " - {$failure}\n";
}
exit(1);
}

echo "\nOK ({$total} assertions)\n";
exit(0);
Loading