Skip to content

Commit 8f910e2

Browse files
Refactor worktree cleanup candidate classification (#776)
* Refactor worktree cleanup candidate classification * fix: satisfy cleanup classifier lint --------- Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 00a30a9 commit 8f910e2

4 files changed

Lines changed: 250 additions & 107 deletions

File tree

inc/Workspace/Workspace.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
require_once __DIR__ . '/WorkspaceWorktreeLifecycle.php';
2727
require_once __DIR__ . '/WorktreeAgeFilter.php';
2828
require_once __DIR__ . '/WorktreeCleanupSignal.php';
29+
require_once __DIR__ . '/WorktreeCleanupCandidateClassifier.php';
2930
require_once __DIR__ . '/WorkspaceWorktreeCleanupEngine.php';
3031
require_once __DIR__ . '/WorkspaceWorktreeInventoryCleanup.php';
3132
require_once __DIR__ . '/WorkspaceWorktreeEmergencyCleanup.php';

inc/Workspace/WorkspaceWorktreeCleanupEngine.php

Lines changed: 20 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -477,117 +477,30 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro
477477
if ( null === $signal ) {
478478
$signal = $this->detect_merge_signal($primary_path, $repo, $branch, $skip_github, $github_cache);
479479
}
480-
if ( null === $signal ) {
481-
$skipped[] = array_merge(
482-
array(
483-
'handle' => $handle,
484-
'repo' => $repo,
485-
'branch' => $branch,
486-
'path' => $wt_path,
487-
'reason_code' => 'no_merge_signal',
488-
'reason' => 'no merge signal — leaving in place',
489-
'merge_signal_evidence' => $this->build_no_merge_signal_evidence($primary_path, $branch, $skip_github),
490-
'active_review_command' => 'studio wp datamachine-code workspace worktree active-no-signal-report --limit=25 --offset=0 --format=json',
491-
'active_review_commands' => $this->build_active_no_signal_next_commands(25, 0),
492-
'created_at' => $created_at,
493-
'metadata' => $metadata,
494-
), $disk_fields
495-
);
496-
continue;
497-
}
498-
499-
if ( 'probe-timeout' === $signal['signal'] ) {
500-
$skipped[] = array_merge(
501-
array(
502-
'handle' => $handle,
503-
'repo' => $repo,
504-
'branch' => $branch,
505-
'path' => $wt_path,
506-
'reason_code' => 'probe_timeout',
507-
'reason' => $signal['reason'],
508-
'created_at' => $created_at,
509-
'metadata' => $metadata,
510-
), $disk_fields
511-
);
512-
continue;
513-
}
480+
$classification = WorktreeCleanupCandidateClassifier::classify_merge_signal_path(
481+
array(
482+
'handle' => $handle,
483+
'repo' => $repo,
484+
'branch' => $branch,
485+
'path' => $wt_path,
486+
'dirty_count' => $dirty_count,
487+
'created_at' => $created_at,
488+
'liveness' => $liveness,
489+
'metadata' => $metadata,
490+
'disk_fields' => $disk_fields,
491+
),
492+
$signal,
493+
$age_filter,
494+
fn() => $this->build_no_merge_signal_evidence($primary_path, $branch, $skip_github),
495+
$this->build_active_no_signal_next_commands(25, 0)
496+
);
514497

515-
if ( 'github-unknown' === $signal['signal'] ) {
516-
$skipped[] = array_merge(
517-
array(
518-
'handle' => $handle,
519-
'repo' => $repo,
520-
'branch' => $branch,
521-
'path' => $wt_path,
522-
'reason_code' => 'github_unknown',
523-
'reason' => $signal['reason'],
524-
'merge_signal_evidence' => array(
525-
'classification' => 'github_signal_unavailable',
526-
'github_signal' => 'unavailable',
527-
'reason' => $signal['reason'],
528-
),
529-
'active_review_command' => 'studio wp datamachine-code workspace worktree active-no-signal-report --limit=25 --offset=0 --format=json',
530-
'active_review_commands' => $this->build_active_no_signal_next_commands(25, 0),
531-
'created_at' => $created_at,
532-
'metadata' => $metadata,
533-
), $disk_fields
534-
);
498+
if ( 'candidate' === $classification['type'] ) {
499+
$candidates[] = $classification['row'];
535500
continue;
536501
}
537502

538-
$age_decision = null;
539-
if ( null !== $age_filter ) {
540-
$age_decision = WorktreeAgeFilter::decide($created_at, $age_filter);
541-
if ( 'unknown_age' === $age_decision['decision'] ) {
542-
$skipped[] = array_merge(
543-
array(
544-
'handle' => $handle,
545-
'repo' => $repo,
546-
'branch' => $branch,
547-
'path' => $wt_path,
548-
'created_at' => $created_at,
549-
'metadata' => $metadata,
550-
),
551-
WorktreeAgeFilter::skip_fields($age_decision),
552-
$disk_fields
553-
);
554-
continue;
555-
}
556-
557-
if ( 'excluded' === $age_decision['decision'] ) {
558-
$skipped[] = array_merge(
559-
array(
560-
'handle' => $handle,
561-
'repo' => $repo,
562-
'branch' => $branch,
563-
'path' => $wt_path,
564-
'created_at' => $created_at,
565-
'metadata' => $metadata,
566-
),
567-
WorktreeAgeFilter::skip_fields($age_decision),
568-
$disk_fields
569-
);
570-
continue;
571-
}
572-
}
573-
574-
$candidate = array_merge(
575-
array(
576-
'handle' => $wt['handle'],
577-
'repo' => $repo,
578-
'branch' => $branch,
579-
'path' => $wt_path,
580-
'dirty' => $dirty_count,
581-
'cleanup_reasons' => array_values(array_filter(array( $signal['signal'], $signal['reason'] ))),
582-
'created_at' => $created_at,
583-
'liveness' => $liveness,
584-
'metadata' => $metadata,
585-
), WorktreeCleanupSignal::candidate_fields($signal, true), $disk_fields
586-
);
587-
if ( null !== $age_decision ) {
588-
$candidate['age_filter'] = $age_decision['age_filter'];
589-
}
590-
$candidates[] = $candidate;
503+
$skipped[] = $classification['row'];
591504
}
592505

593506
$candidates = $this->dedupe_worktree_cleanup_rows($candidates);
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
/**
3+
* Worktree cleanup candidate classification.
4+
*
5+
* @package DataMachineCode\Workspace
6+
*/
7+
8+
namespace DataMachineCode\Workspace;
9+
10+
defined('ABSPATH') || exit;
11+
12+
final class WorktreeCleanupCandidateClassifier {
13+
14+
/**
15+
* Classify one clean, non-primary worktree after safety probes and signal detection.
16+
*
17+
* @param array<string,mixed> $context Normalized worktree context.
18+
* @param array<string,mixed>|null $signal Cleanup signal, if any.
19+
* @param array<string,mixed>|null $age_filter Mutable age filter summary.
20+
* @param callable $no_merge_signal_evidence Lazy evidence callback for no-signal rows.
21+
* @param array<string,string> $active_review_commands Review/apply commands for no-signal rows.
22+
* @return array{type:string,row:array<string,mixed>}
23+
*/
24+
public static function classify_merge_signal_path( array $context, ?array $signal, ?array &$age_filter, callable $no_merge_signal_evidence, array $active_review_commands ): array {
25+
$base = self::base_row($context);
26+
$disk_fields = is_array($context['disk_fields'] ?? null) ? $context['disk_fields'] : array();
27+
28+
if ( null === $signal ) {
29+
return self::skip(
30+
array_merge(
31+
$base,
32+
array(
33+
'reason_code' => 'no_merge_signal',
34+
'reason' => 'no merge signal — leaving in place',
35+
'merge_signal_evidence' => $no_merge_signal_evidence(),
36+
'active_review_command' => 'studio wp datamachine-code workspace worktree active-no-signal-report --limit=25 --offset=0 --format=json',
37+
'active_review_commands' => $active_review_commands,
38+
),
39+
$disk_fields
40+
)
41+
);
42+
}
43+
44+
if ( 'probe-timeout' === (string) ( $signal['signal'] ?? '' ) ) {
45+
return self::skip(
46+
array_merge(
47+
$base,
48+
array(
49+
'reason_code' => 'probe_timeout',
50+
'reason' => (string) ( $signal['reason'] ?? '' ),
51+
),
52+
$disk_fields
53+
)
54+
);
55+
}
56+
57+
if ( 'github-unknown' === (string) ( $signal['signal'] ?? '' ) ) {
58+
return self::skip(
59+
array_merge(
60+
$base,
61+
array(
62+
'reason_code' => 'github_unknown',
63+
'reason' => (string) ( $signal['reason'] ?? '' ),
64+
'merge_signal_evidence' => array(
65+
'classification' => 'github_signal_unavailable',
66+
'github_signal' => 'unavailable',
67+
'reason' => (string) ( $signal['reason'] ?? '' ),
68+
),
69+
'active_review_command' => 'studio wp datamachine-code workspace worktree active-no-signal-report --limit=25 --offset=0 --format=json',
70+
'active_review_commands' => $active_review_commands,
71+
),
72+
$disk_fields
73+
)
74+
);
75+
}
76+
77+
$age_decision = null;
78+
if ( null !== $age_filter ) {
79+
$age_decision = WorktreeAgeFilter::decide($context['created_at'] ?? null, $age_filter);
80+
if ( in_array( (string) ( $age_decision['decision'] ?? '' ), array( 'unknown_age', 'excluded' ), true) ) {
81+
return self::skip(
82+
array_merge(
83+
$base,
84+
WorktreeAgeFilter::skip_fields($age_decision),
85+
$disk_fields
86+
)
87+
);
88+
}
89+
}
90+
91+
$candidate = array_merge(
92+
$base,
93+
array(
94+
'dirty' => (int) ( $context['dirty_count'] ?? 0 ),
95+
'cleanup_reasons' => array_values(array_filter(array( $signal['signal'] ?? '', $signal['reason'] ?? '' ))),
96+
'liveness' => (string) ( $context['liveness'] ?? '' ),
97+
),
98+
WorktreeCleanupSignal::candidate_fields($signal, true),
99+
$disk_fields
100+
);
101+
if ( null !== $age_decision ) {
102+
$candidate['age_filter'] = $age_decision['age_filter'];
103+
}
104+
105+
return array(
106+
'type' => 'candidate',
107+
'row' => $candidate,
108+
);
109+
}
110+
111+
/**
112+
* Build common row fields used by this cleanup path.
113+
*
114+
* @param array<string,mixed> $context Normalized worktree context.
115+
* @return array<string,mixed>
116+
*/
117+
private static function base_row( array $context ): array {
118+
return array(
119+
'handle' => (string) ( $context['handle'] ?? '' ),
120+
'repo' => (string) ( $context['repo'] ?? '' ),
121+
'branch' => (string) ( $context['branch'] ?? '' ),
122+
'path' => (string) ( $context['path'] ?? '' ),
123+
'created_at' => $context['created_at'] ?? null,
124+
'metadata' => $context['metadata'] ?? null,
125+
);
126+
}
127+
128+
/**
129+
* Wrap a skip row in the classifier result shape.
130+
*
131+
* @param array<string,mixed> $row Skip row.
132+
* @return array{type:string,row:array<string,mixed>}
133+
*/
134+
private static function skip( array $row ): array {
135+
return array(
136+
'type' => 'skip',
137+
'row' => $row,
138+
);
139+
}
140+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if ( ! defined('ABSPATH') ) {
6+
define('ABSPATH', __DIR__ . '/fixtures/');
7+
}
8+
9+
require_once dirname(__DIR__) . '/inc/Workspace/WorktreeAgeFilter.php';
10+
require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupSignal.php';
11+
require_once dirname(__DIR__) . '/inc/Workspace/WorktreeCleanupCandidateClassifier.php';
12+
13+
use DataMachineCode\Workspace\WorktreeAgeFilter;
14+
use DataMachineCode\Workspace\WorktreeCleanupCandidateClassifier;
15+
16+
function worktree_cleanup_candidate_assert_same( mixed $expected, mixed $actual, string $message ): void {
17+
if ( $expected !== $actual ) {
18+
throw new RuntimeException(sprintf('%s Expected %s, got %s.', $message, var_export($expected, true), var_export($actual, true)));
19+
}
20+
}
21+
22+
$context = array(
23+
'handle' => 'repo@merged-branch',
24+
'repo' => 'repo',
25+
'branch' => 'merged-branch',
26+
'path' => '/tmp/repo@merged-branch',
27+
'dirty_count' => 0,
28+
'created_at' => '2026-01-01T00:00:00+00:00',
29+
'liveness' => 'stale',
30+
'metadata' => array( 'created_at' => '2026-01-01T00:00:00+00:00' ),
31+
'disk_fields' => array( 'size_bytes' => 123 ),
32+
);
33+
34+
$age_filter = null;
35+
$evidence_called = false;
36+
$candidate_result = WorktreeCleanupCandidateClassifier::classify_merge_signal_path(
37+
$context,
38+
array(
39+
'signal' => 'github-merged-pr',
40+
'reason' => 'GitHub reports merged PR',
41+
'pr_url' => 'https://example.com/pr/1',
42+
),
43+
$age_filter,
44+
function () use ( &$evidence_called ): array {
45+
$evidence_called = true;
46+
return array( 'classification' => 'no_cleanup_signal' );
47+
},
48+
array( 'review_command' => 'review' )
49+
);
50+
51+
worktree_cleanup_candidate_assert_same('candidate', $candidate_result['type'], 'merged signal is a candidate');
52+
worktree_cleanup_candidate_assert_same('github-merged-pr', $candidate_result['row']['signal'], 'candidate signal is preserved');
53+
worktree_cleanup_candidate_assert_same('github-merged-pr', $candidate_result['row']['reason_code'], 'candidate reason_code matches signal');
54+
worktree_cleanup_candidate_assert_same(false, $evidence_called, 'no-signal evidence stays lazy for candidates');
55+
56+
$no_signal_filter = null;
57+
$no_signal = WorktreeCleanupCandidateClassifier::classify_merge_signal_path(
58+
$context,
59+
null,
60+
$no_signal_filter,
61+
function (): array {
62+
return array( 'classification' => 'no_cleanup_signal' );
63+
},
64+
array( 'review_command' => 'review' )
65+
);
66+
67+
worktree_cleanup_candidate_assert_same('skip', $no_signal['type'], 'missing signal is skipped');
68+
worktree_cleanup_candidate_assert_same('no_merge_signal', $no_signal['row']['reason_code'], 'missing signal reason_code matches cleanup contract');
69+
worktree_cleanup_candidate_assert_same(array( 'classification' => 'no_cleanup_signal' ), $no_signal['row']['merge_signal_evidence'], 'missing signal includes evidence');
70+
71+
$recent_context = $context;
72+
$recent_context['created_at'] = '2026-06-16T00:00:00+00:00';
73+
$recent_age_filter = WorktreeAgeFilter::build('30d', 30 * 24 * 60 * 60, strtotime('2026-06-17T00:00:00+00:00'));
74+
$age_skip = WorktreeCleanupCandidateClassifier::classify_merge_signal_path(
75+
$recent_context,
76+
array(
77+
'signal' => 'upstream-gone',
78+
'reason' => 'upstream branch is gone',
79+
),
80+
$recent_age_filter,
81+
fn(): array => array(),
82+
array()
83+
);
84+
85+
worktree_cleanup_candidate_assert_same('skip', $age_skip['type'], 'recent worktree is skipped by age filter');
86+
worktree_cleanup_candidate_assert_same('age_filter', $age_skip['row']['reason_code'], 'age skip reason_code matches cleanup contract');
87+
worktree_cleanup_candidate_assert_same(1, $recent_age_filter['excluded'], 'age filter excluded counter is updated');
88+
89+
echo "worktree-cleanup-candidate-classifier: ok\n";

0 commit comments

Comments
 (0)