Skip to content

Commit c80943a

Browse files
authored
Merge pull request #602 from Extra-Chill/fix/stale-clean-remote-worktree-cleanup
Fix stale clean remote-backed worktree cleanup
2 parents 496aa44 + fc08e9a commit c80943a

4 files changed

Lines changed: 82 additions & 28 deletions

File tree

inc/Workspace/WorkspaceActiveNoSignalCleanup.php

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,7 @@ private function validate_current_cleanup_worktree( string $repo, string $path,
989989
}
990990

991991
$current_branch = (string) $this->resolve_worktree_branch_from_head_file($real_path);
992-
if ( null !== $expected_branch && $expected_branch !== $current_branch ) {
992+
if ( null !== $expected_branch && ! $this->cleanup_branch_identity_matches($expected_branch, $current_branch) ) {
993993
return new \WP_Error('branch_identity_mismatch', 'worktree branch identity changed before apply');
994994
}
995995
if ( null === $expected_branch && '' === $current_branch ) {
@@ -1027,12 +1027,31 @@ private function validate_current_cleanup_worktree( string $repo, string $path,
10271027
return array(
10281028
'real_path' => $real_path,
10291029
'primary_path' => $primary_path,
1030-
'branch' => null !== $expected_branch ? $expected_branch : $current_branch,
1030+
'branch' => '' !== $current_branch ? $current_branch : (string) $expected_branch,
10311031
'dirty' => (int) $dirty,
10321032
'unpushed' => (int) $unpushed,
10331033
);
10341034
}
10351035

1036+
/**
1037+
* Compare a report branch identity against the live worktree branch.
1038+
*
1039+
* Active/no-signal rows may originate from stale metadata or handle slugs where
1040+
* `fix/foo` is represented as `fix-foo`. Treat that as the same branch while
1041+
* still rejecting detached/missing heads and unrelated branch changes.
1042+
*
1043+
* @param string $expected_branch Branch carried by the report row.
1044+
* @param string $current_branch Branch resolved from the live worktree HEAD.
1045+
* @return bool Whether the identities are compatible.
1046+
*/
1047+
private function cleanup_branch_identity_matches( string $expected_branch, string $current_branch ): bool {
1048+
if ( '' === $expected_branch || '' === $current_branch ) {
1049+
return false;
1050+
}
1051+
1052+
return $expected_branch === $current_branch || $expected_branch === $this->slugify_branch($current_branch);
1053+
}
1054+
10361055
/**
10371056
* Build a skip row for finalized active/no-signal apply.
10381057
*

inc/Workspace/WorkspaceCoreUtilities.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,8 @@ private function is_git_timeout_error( mixed $result ): bool {
519519
return false;
520520
}
521521

522-
return 'git_command_timeout' === $result->get_error_code();
522+
$code = method_exists($result, 'get_error_code') ? $result->get_error_code() : ( $result->code ?? '' );
523+
return 'git_command_timeout' === $code;
523524
}
524525

525526
/**

inc/Workspace/WorkspaceWorktreeCleanupEngine.php

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,17 @@ trait WorkspaceWorktreeCleanupEngine {
2525
* - It is not the primary checkout
2626
* - Its branch is not main/master/trunk/develop/HEAD
2727
* - It has no uncommitted changes (unless $force)
28-
* - At least one merge signal is present:
28+
* - At least one cleanup signal is present:
2929
* a) `git for-each-ref` reports upstream status "gone" (branch
3030
* was deleted on the remote — typical after GitHub
3131
* "auto-delete head branches" fires on PR merge), OR
3232
* b) GitHub API reports a closed+merged PR whose head
33-
* branch matches this worktree's branch.
33+
* branch matches this worktree's branch, OR
34+
* c) the clean local worktree has no unpushed commits and is backed
35+
* by an existing remote branch, so removing the local checkout does
36+
* not delete recoverable Git state.
3437
*
35-
* Signal (a) is local and fast; signal (b) requires a PAT + network
38+
* Signals (a) and (c) are local and fast; signal (b) requires a PAT + network
3639
* but catches the case where the remote branch still exists (e.g.
3740
* manual merge without branch deletion).
3841
*
@@ -848,7 +851,7 @@ private function classify_worktree_git_probe_failure( string $handle, string $re
848851
* @return string
849852
*/
850853
private function get_wp_error_code( \WP_Error $error ): string {
851-
return (string) $error->get_error_code();
854+
return method_exists($error, 'get_error_code') ? (string) $error->get_error_code() : (string) ( $error->code ?? '' );
852855
}
853856

854857
/**
@@ -858,7 +861,7 @@ private function get_wp_error_code( \WP_Error $error ): string {
858861
* @return mixed
859862
*/
860863
private function get_wp_error_data( \WP_Error $error ): mixed {
861-
return $error->get_error_data();
864+
return method_exists($error, 'get_error_data') ? $error->get_error_data() : ( $error->data ?? null );
862865
}
863866

864867
/**
@@ -2500,7 +2503,12 @@ private function classify_dirty_obsolete_on_default_branch(
25002503
* Signal priority:
25012504
* 1. `upstream-gone` — local branch's upstream tracking ref is gone.
25022505
* Typical after GitHub auto-deletes the head branch on PR merge.
2503-
* 2. `pr-merged` — GitHub API reports a closed+merged PR for this
2506+
* 2. `local-merged` — branch has no commits outside remote default.
2507+
* 3. `remote-tracking-clean` — branch still exists on origin, while the
2508+
* local worktree is clean and has no unpushed commits. Age gates are
2509+
* enforced by the caller before removal when retention cleanup passes
2510+
* `older_than`.
2511+
* 4. `pr-merged` — GitHub API reports a closed+merged PR for this
25042512
* branch. Requires $skip_github = false and a configured PAT.
25052513
*
25062514
* @param string $primary_path Path to the primary git checkout.
@@ -2537,6 +2545,11 @@ private function detect_merge_signal( string $primary_path, string $repo, string
25372545
return $local_merged;
25382546
}
25392547

2548+
$remote_tracking_clean = $this->detect_remote_tracking_clean_signal($primary_path, $branch);
2549+
if ( null !== $remote_tracking_clean ) {
2550+
return $remote_tracking_clean;
2551+
}
2552+
25402553
if ( $skip_github ) {
25412554
return null;
25422555
}
@@ -2624,6 +2637,33 @@ private function detect_local_merged_signal( string $primary_path, string $branc
26242637
);
26252638
}
26262639

2640+
/**
2641+
* Detect clean local-only checkouts whose work is preserved by a remote branch.
2642+
*
2643+
* This is intentionally less strict than a merge signal: the remote branch may
2644+
* still be open or unmerged, but the local checkout is only a disposable copy
2645+
* when it is clean, has no unpushed commits, and `origin/<branch>` exists.
2646+
* Removing the local worktree and local branch does not delete the remote
2647+
* branch or close any PR, so the work can be picked up from Git later.
2648+
*
2649+
* @param string $primary_path Path to the primary git checkout.
2650+
* @param string $branch Branch name.
2651+
* @return array{signal: string, reason: string, remote_ref: string}|null
2652+
*/
2653+
private function detect_remote_tracking_clean_signal( string $primary_path, string $branch ): ?array {
2654+
$remote_ref = 'refs/remotes/origin/' . $branch;
2655+
$result = $this->run_git($primary_path, sprintf('rev-parse --verify --quiet %s', escapeshellarg($remote_ref)), self::CLEANUP_GIT_PROBE_TIMEOUT);
2656+
if ( is_wp_error($result) ) {
2657+
return null;
2658+
}
2659+
2660+
return array(
2661+
'signal' => 'remote-tracking-clean',
2662+
'reason' => 'clean local worktree has no unpushed commits and the branch exists on origin; removing the local checkout preserves remote Git state',
2663+
'remote_ref' => $remote_ref,
2664+
);
2665+
}
2666+
26272667
/**
26282668
* Extract owner/repo slug from a primary checkout's origin remote.
26292669
*

tests/smoke-worktree-cleanup.php

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -450,42 +450,35 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
450450
$mixed_dirty_row = array_values($mixed_dirty_skips)[0] ?? array();
451451
$assert('dirty_worktree', $mixed_dirty_row['reason_code'] ?? '', 'mixed source/artifact dirt stays protected as generic dirty worktree');
452452

453-
// unmerged-feature should be skipped (no merge signal)
454-
$unmerged = array_filter($plan['skipped'] ?? array(), fn( $s ) => ( $s['handle'] ?? '' ) === 'demo@unmerged-feature');
455-
$assert(1, count($unmerged), 'unmerged worktree skipped with exactly one entry');
456-
$unmerged_row = array_values($unmerged)[0] ?? array();
457-
$assert('no_merge_signal', $unmerged_row['reason_code'] ?? '', 'unmerged skip exposes stable reason code');
453+
$assert_contains($plan['candidates'] ?? array(), 'demo@unmerged-feature', 'clean remote-backed unmerged worktree flagged as local-only cleanup candidate');
454+
$unmerged_candidate = array_values(array_filter($plan['candidates'] ?? array(), fn( $s ) => ( $s['handle'] ?? '' ) === 'demo@unmerged-feature'))[0] ?? array();
455+
$assert('remote-tracking-clean', $unmerged_candidate['reason_code'] ?? '', 'unmerged remote-backed candidate exposes stable reason code');
456+
$assert('remote-tracking-clean', $unmerged_candidate['signal'] ?? '', 'unmerged remote-backed candidate records cleanup signal');
458457
$detached = array_values(array_filter($plan['skipped'] ?? array(), fn( $s ) => ( $s['handle'] ?? '' ) === 'demo@feature-detached-stored'))[0] ?? array();
459458
$assert('detached_worktree', $detached['reason_code'] ?? '', 'detached worktree with stored branch metadata is classified explicitly');
460459
$assert('feature/detached-stored', $detached['branch'] ?? '', 'detached worktree recovers branch from stored metadata');
461460
$assert(array( 'branch' ), $detached['hydrated_fields'] ?? array(), 'detached worktree reports hydrated branch field');
462461
$protected = array_values(array_filter($plan['skipped'] ?? array(), fn( $s ) => ( $s['handle'] ?? '' ) === 'demo@develop'))[0] ?? array();
463462
$assert('protected_base_branch_worktree', $protected['reason_code'] ?? '', 'protected branch worktree gets actionable protected-base diagnostic');
464-
$assert('remote_branch_still_exists', $unmerged_row['merge_signal_evidence']['classification'] ?? '', 'unmerged skip distinguishes existing remote branch');
465-
$assert('still_exists', $unmerged_row['merge_signal_evidence']['remote_branch'] ?? '', 'unmerged skip records remote branch evidence');
466-
$assert(true, str_contains($unmerged_row['active_review_command'] ?? '', 'active-no-signal-report'), 'unmerged skip includes active/no-signal review command');
467-
$assert(true, str_contains($unmerged_row['active_review_commands']['finalized_apply_dry_run'] ?? '', 'active-no-signal-finalized-apply --dry-run'), 'unmerged skip includes finalized PR dry-run apply command');
463+
$assert(true, str_contains($unmerged_candidate['reason'] ?? '', 'origin'), 'unmerged remote-backed candidate explains remote preservation');
468464

469465
$external_rows = array_values(array_filter($plan['skipped'] ?? array(), fn( $s ) => ( $s['reason_code'] ?? '' ) === 'external_worktree'));
470466
$external_row = $external_rows[0] ?? array();
471467
$assert('external_worktree', $external_row['reason_code'] ?? '', 'external worktree exposes stable reason code');
472468
$assert(true, str_contains($external_row['hint'] ?? '', 'outside the DMC workspace'), 'external worktree includes remediation hint');
473469

474-
$assert(4, (int) ( $plan['summary']['would_remove'] ?? 0 ), 'summary counts cleanup candidates');
470+
$assert(6, (int) ( $plan['summary']['would_remove'] ?? 0 ), 'summary counts cleanup candidates');
475471
$assert(2, (int) ( $plan['summary']['skipped_by_reason']['dirty_worktree'] ?? 0 ), 'summary counts dirty skips by reason');
476472
$assert(1, (int) ( $plan['summary']['skipped_by_reason']['artifact_only_dirty_worktree'] ?? 0 ), 'summary counts artifact-only dirty skips separately');
477473
$assert(1, (int) ( $plan['summary']['cleanup_buckets']['artifact_only_dirty_worktree'] ?? 0 ), 'cleanup buckets expose artifact-only dirty count');
478474
$assert(true, in_array('artifact_only_dirty_worktree', array_column($plan['summary']['skipped_next_commands'] ?? array(), 'reason_code'), true), 'summary exposes artifact-only dirty next command');
479-
$assert(true, isset($plan['summary']['skipped_by_reason']['no_merge_signal']), 'summary includes no_merge_signal bucket');
475+
$assert(false, isset($plan['summary']['skipped_by_reason']['no_merge_signal']), 'summary has no no_merge_signal bucket when clean remote-backed worktrees are removable');
480476
$assert(4096, (int) ( $plan['summary']['total_size_bytes'] ?? -1 ), 'summary counts artifact-only dirty worktree size bytes');
481477
$assert(4096, (int) ( $plan['summary']['artifact_size_bytes'] ?? -1 ), 'summary counts artifact-only dirty artifact size bytes');
482478
$top_by_size = (array) ( $plan['summary']['top_by_size'] ?? array() );
483479
$assert('demo@artifact-only-dirty', $top_by_size[0]['handle'] ?? '', 'summary top-by-size includes artifact-only dirty worktree');
484480
$next_commands = (array) ( $plan['summary']['skipped_next_commands'] ?? array() );
485-
$assert(true, in_array('no_merge_signal', array_column($next_commands, 'reason_code'), true), 'summary includes no_merge_signal next command');
486-
$no_merge_commands = array_values(array_filter($next_commands, fn( $row ) => ( $row['reason_code'] ?? '' ) === 'no_merge_signal'))[0] ?? array();
487-
$assert(true, str_contains($no_merge_commands['command'] ?? '', 'active-no-signal-report'), 'no_merge_signal next command routes to active/no-signal evidence report');
488-
$assert(true, str_contains($no_merge_commands['commands']['equivalent_clean_apply_dry_run'] ?? '', 'active-no-signal-equivalent-clean-apply --dry-run'), 'no_merge_signal next commands include equivalent-clean dry-run apply');
481+
$assert(false, in_array('no_merge_signal', array_column($next_commands, 'reason_code'), true), 'summary omits no_merge_signal next command when no rows need active/no-signal review');
489482
$assert(true, array_key_exists('total_size_bytes', $plan['summary'] ?? array()), 'summary exposes total worktree size bytes field');
490483
$assert(true, array_key_exists('artifact_size_bytes', $plan['summary'] ?? array()), 'summary exposes artifact size bytes field');
491484
$assert(true, array_key_exists('top_by_size', $plan['summary'] ?? array()), 'summary exposes top worktrees by size field');
@@ -670,10 +663,11 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
670663
)
671664
);
672665
$assert(true, ! is_wp_error($age_plan) && ( $age_plan['success'] ?? false ), 'age-filtered dry_run returns success');
673-
$assert(1, (int) ( $age_plan['summary']['would_remove'] ?? 0 ), 'older_than keeps only old cleanup candidate');
666+
$assert(2, (int) ( $age_plan['summary']['would_remove'] ?? 0 ), 'older_than keeps old merged and remote-backed cleanup candidates');
674667
$assert(1, (int) ( $age_plan['summary']['age_filter']['excluded'] ?? 0 ), 'age filter summary counts newer candidate exclusion');
675-
$assert(2, (int) ( $age_plan['summary']['age_filter']['unknown_age'] ?? 0 ), 'age filter summary counts unknown-age candidate exclusions');
668+
$assert(3, (int) ( $age_plan['summary']['age_filter']['unknown_age'] ?? 0 ), 'age filter summary counts unknown-age candidate exclusions');
676669
$assert_contains($age_plan['candidates'] ?? array(), 'demo@merged-autodelete', 'older_than keeps old merged worktree');
670+
$assert_contains($age_plan['candidates'] ?? array(), 'demo@unmerged-feature', 'older_than keeps old clean remote-backed worktree');
677671
$recent_age_rows = array_values(array_filter($age_plan['skipped'] ?? array(), fn( $s ) => ( $s['handle'] ?? '' ) === 'demo@merged-recent'));
678672
$assert('age_filter', $recent_age_rows[0]['reason_code'] ?? '', 'newer merged worktree is skipped by age_filter');
679673
$assert('excluded', $recent_age_rows[0]['age_filter']['decision'] ?? '', 'age-filter skip row exposes excluded decision');
@@ -820,8 +814,8 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
820814
// Primary survives.
821815
$assert(true, is_dir($primary . '/.git'), 'primary .git survives cleanup');
822816

823-
// Protected branches: unmerged/dirty worktrees survive.
824-
$assert(true, is_dir($tmp . '/demo@unmerged-feature'), 'unmerged worktree survives cleanup');
817+
// Dirty/external worktrees survive; clean remote-backed worktrees are local-only cleanup candidates.
818+
$assert(false, is_dir($tmp . '/demo@unmerged-feature'), 'clean remote-backed unmerged worktree is removed locally');
825819
$assert(true, is_dir($tmp . '/demo@dirty-branch'), 'dirty worktree survives cleanup');
826820
$assert(true, is_dir($tmp . '/demo@merged-stale-plan'), 'dirty stale-plan worktree survives cleanup');
827821
$assert(true, is_dir($tmp . '-external/demo-external'), 'external worktree survives cleanup');

0 commit comments

Comments
 (0)