Skip to content

Commit 9702c22

Browse files
committed
fix: treat patch-equivalence to default as a merged cleanup signal
Squash merges create a new commit on the default branch, so the original branch tip is never an ancestor of origin/main. The cleanup classifier already computed patch-equivalence via git cherry but treated it as a skip reason. - detect_local_merged_signal() falls back to git cherry patch-equivalence and emits patch-equivalent-merged, preserving the local branch. - WorktreeCleanupCandidateClassifier passes preserve_local_branch through so removal skips local branch deletion. - active-no-signal merged_apply accepts patch_equivalent_default rows, with open-PR/dirty/unpushed guards evaluated first. - Tests verify squash-equivalent branches are removable and guards still block dirty/open-PR rows. Closes #882
1 parent b6a9525 commit 9702c22

4 files changed

Lines changed: 304 additions & 24 deletions

File tree

inc/Workspace/WorkspaceActiveNoSignalCleanup.php

Lines changed: 142 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -266,20 +266,29 @@ public function worktree_active_no_signal_merged_apply( array $opts = array() ):
266266
return array( 'report_action_counts' => $report['summary']['by_suggested_action'] ?? array() );
267267
},
268268
'prepare_row' => function ( array $row ): array|\WP_Error {
269-
$is_merged_to_default = 'merged_to_default' === (string) ( $row['suggested_action'] ?? '' )
270-
|| (
271-
0 === (int) ( $row['dirty'] ?? -1 )
272-
&& 0 === (int) ( $row['unpushed'] ?? -1 )
273-
&& 0 === (int) ( $row['commits_outside_default'] ?? -1 )
274-
);
275-
276-
if ( ! $is_merged_to_default ) {
277-
return new \WP_Error('not_merged_to_default', 'row is not a clean merged-to-default candidate');
269+
$action = (string) ( $row['suggested_action'] ?? '' );
270+
if ( in_array($action, array( 'merged_to_default', 'patch_equivalent_default' ), true) ) {
271+
return $row;
278272
}
279273

280-
return $row;
274+
if (
275+
0 === (int) ( $row['dirty'] ?? -1 )
276+
&& 0 === (int) ( $row['unpushed'] ?? -1 )
277+
&& 0 === (int) ( $row['commits_outside_default'] ?? -1 )
278+
) {
279+
return $row;
280+
}
281+
282+
return new \WP_Error('not_merged_to_default', 'row is not a clean merged-to-default candidate');
283+
},
284+
'build_metadata' => function ( array $row ): array|\WP_Error {
285+
$action = (string) ( $row['suggested_action'] ?? '' );
286+
if ( 'patch_equivalent_default' === $action ) {
287+
return $this->build_active_no_signal_patch_equivalent_to_default_metadata($row);
288+
}
289+
290+
return $this->build_active_no_signal_merged_to_default_metadata($row);
281291
},
282-
'build_metadata' => fn( array $row ): array|\WP_Error => $this->build_active_no_signal_merged_to_default_metadata($row),
283292
'build_planned' => static function ( array $row, array $metadata ): array {
284293
return array(
285294
'handle' => (string) ( $row['handle'] ?? '' ),
@@ -710,6 +719,39 @@ private function build_active_no_signal_merged_to_default_metadata( array $row )
710719
return $metadata;
711720
}
712721

722+
/**
723+
* Build cleanup metadata from one clean patch-equivalent-to-default evidence row.
724+
*
725+
* @param array<string,mixed> $row Evidence row.
726+
* @return array<string,mixed>|\WP_Error
727+
*/
728+
private function build_active_no_signal_patch_equivalent_to_default_metadata( array $row ): array|\WP_Error {
729+
$handle = (string) ( $row['handle'] ?? '' );
730+
$repo = (string) ( $row['repo'] ?? '' );
731+
$branch = (string) ( $row['branch'] ?? '' );
732+
$path = (string) ( $row['path'] ?? '' );
733+
734+
$evidence = $this->build_current_patch_equivalent_to_default_cleanup_evidence($handle, $repo, $branch, $path);
735+
if ( is_wp_error($evidence) ) {
736+
return $evidence;
737+
}
738+
739+
$metadata = $this->build_active_no_signal_cleanup_metadata(
740+
$row,
741+
$handle,
742+
$repo,
743+
$branch,
744+
(string) ( $evidence['path'] ?? $path ),
745+
WorktreeContextInjector::STATE_MERGED
746+
);
747+
$metadata['auto_finalized_by'] = 'active_no_signal_merged_apply';
748+
$metadata['auto_finalized_signal'] = 'patch-equivalent-to-default';
749+
$metadata['auto_finalized_reason'] = sprintf('active/no-signal report found branch patch-equivalent to %s', (string) ( $evidence['default_ref'] ?? 'remote default' ));
750+
$metadata['cleanup_eligibility_evidence'] = $evidence;
751+
752+
return $metadata;
753+
}
754+
713755
/**
714756
* Build cleanup metadata from one clean remote-tracking evidence row.
715757
*
@@ -971,6 +1013,87 @@ private function build_current_merged_to_default_cleanup_evidence( string $handl
9711013
);
9721014
}
9731015

1016+
/**
1017+
* Recompute patch-equivalent-to-default evidence for the current worktree state.
1018+
*
1019+
* @param string $handle Workspace handle.
1020+
* @param string $repo Repository name.
1021+
* @param string $branch Branch name.
1022+
* @param string $path Worktree path.
1023+
* @return array<string,mixed>|\WP_Error
1024+
*/
1025+
private function build_current_patch_equivalent_to_default_cleanup_evidence( string $handle, string $repo, string $branch, string $path ): array|\WP_Error {
1026+
foreach (
1027+
array(
1028+
'handle' => $handle,
1029+
'repo' => $repo,
1030+
'branch' => $branch,
1031+
'path' => $path,
1032+
) as $field => $value
1033+
) {
1034+
if ( '' === $value ) {
1035+
return new \WP_Error('missing_identity', 'missing required identity field: ' . $field);
1036+
}
1037+
}
1038+
1039+
$facts = $this->validate_current_cleanup_worktree(
1040+
$repo,
1041+
$path,
1042+
$branch,
1043+
array(
1044+
'require_clean' => true,
1045+
'dirty_error_message' => 'refusing to mark dirty worktree cleanup_eligible from patch-equivalent-to-default evidence',
1046+
'unpushed_error_message' => 'refusing to mark worktree with unpushed commits cleanup_eligible from patch-equivalent-to-default evidence',
1047+
)
1048+
);
1049+
if ( is_wp_error($facts) ) {
1050+
return $facts;
1051+
}
1052+
1053+
$real_path = (string) $facts['real_path'];
1054+
$primary_path = (string) $facts['primary_path'];
1055+
$dirty = (int) $facts['dirty'];
1056+
$unpushed = (int) $facts['unpushed'];
1057+
1058+
$default_ref = $this->resolve_remote_default_ref($primary_path, self::CLEANUP_GIT_PROBE_TIMEOUT);
1059+
if ( ! is_string($default_ref) || '' === $default_ref ) {
1060+
return new \WP_Error('missing_default_ref', 'primary checkout default ref could not be resolved');
1061+
}
1062+
1063+
$upstream_equivalence = $this->build_clean_upstream_equivalence_evidence($primary_path, $real_path, $default_ref, $branch);
1064+
if ( 'equivalent_clean' !== (string) ( $upstream_equivalence['effective_status'] ?? '' ) ) {
1065+
return new \WP_Error('not_patch_equivalent_to_default', 'current branch is not patch-equivalent to remote default');
1066+
}
1067+
1068+
$branch_ref = 'refs/heads/' . $branch;
1069+
$branch_head = $this->run_git($primary_path, sprintf('rev-parse --verify %s', escapeshellarg($branch_ref)), self::CLEANUP_GIT_PROBE_TIMEOUT);
1070+
if ( is_wp_error($branch_head) ) {
1071+
return $branch_head;
1072+
}
1073+
$default_head = $this->run_git($primary_path, sprintf('rev-parse --verify %s', escapeshellarg($default_ref . '^{commit}')), self::CLEANUP_GIT_PROBE_TIMEOUT);
1074+
if ( is_wp_error($default_head) ) {
1075+
return $default_head;
1076+
}
1077+
1078+
return array(
1079+
'signal' => 'patch-equivalent-to-default',
1080+
'finalized_state' => WorktreeContextInjector::STATE_MERGED,
1081+
'reason' => 'branch commits are patch-equivalent to the remote default ref',
1082+
'detected_at' => gmdate('c'),
1083+
'handle' => $handle,
1084+
'repo' => $repo,
1085+
'branch' => $branch,
1086+
'path' => $real_path,
1087+
'default_ref' => $default_ref,
1088+
'branch_ref' => $branch_ref,
1089+
'branch_head' => trim( (string) ( $branch_head['output'] ?? '' )),
1090+
'default_head' => trim( (string) ( $default_head['output'] ?? '' )),
1091+
'upstream_equivalence' => $upstream_equivalence,
1092+
'dirty' => (int) $dirty,
1093+
'unpushed' => (int) $unpushed,
1094+
);
1095+
}
1096+
9741097
/**
9751098
* Revalidate the current worktree state before writing cleanup metadata.
9761099
*
@@ -1710,14 +1833,6 @@ private function suggest_active_no_signal_action( array $row ): string {
17101833
return 'merged_to_default';
17111834
}
17121835

1713-
$effective_status = (string) ( $row['upstream_equivalence']['effective_status'] ?? '' );
1714-
if ( 'equivalent_clean' === $effective_status ) {
1715-
return 'patch_equivalent_default';
1716-
}
1717-
if ( 'contained_non_default_remote' === $effective_status ) {
1718-
return 'contained_non_default_remote';
1719-
}
1720-
17211836
if ( (int) ( $row['dirty'] ?? 0 ) > 0 || (int) ( $row['unpushed'] ?? 0 ) > 0 ) {
17221837
return 'unsafe_dirty_or_unpushed';
17231838
}
@@ -1730,6 +1845,14 @@ private function suggest_active_no_signal_action( array $row ): string {
17301845
return 'active_open_pr';
17311846
}
17321847

1848+
$effective_status = (string) ( $row['upstream_equivalence']['effective_status'] ?? '' );
1849+
if ( 'equivalent_clean' === $effective_status ) {
1850+
return 'patch_equivalent_default';
1851+
}
1852+
if ( 'contained_non_default_remote' === $effective_status ) {
1853+
return 'contained_non_default_remote';
1854+
}
1855+
17331856
if ( true === ( $row['remote_tracking'] ?? null ) ) {
17341857
return 'remote_tracking_clean';
17351858
}

inc/Workspace/WorkspaceWorktreeCleanupEngine.php

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3441,14 +3441,41 @@ private function detect_local_merged_signal( string $primary_path, string $branc
34413441
}
34423442

34433443
$unique_commits = (int) trim( (string) ( $result['output'] ?? '' ));
3444-
if ( 0 !== $unique_commits ) {
3445-
return null;
3444+
if ( 0 === $unique_commits ) {
3445+
return array(
3446+
'signal' => 'local-merged',
3447+
'reason' => sprintf('branch has no commits outside remote default (%s)', $default_ref),
3448+
);
34463449
}
34473450

3448-
return array(
3449-
'signal' => 'local-merged',
3450-
'reason' => sprintf('branch has no commits outside remote default (%s)', $default_ref),
3451+
// Squash merges create a new commit on the default branch, so the
3452+
// original branch tip is never an ancestor of origin/main. Fall back
3453+
// to patch-equivalence detection: if every local patch already exists
3454+
// on the default branch, removing the clean worktree is safe.
3455+
$cherry = $this->run_git(
3456+
$primary_path,
3457+
sprintf('cherry %s %s', escapeshellarg($default_ref), escapeshellarg($branch_ref)),
3458+
self::CLEANUP_GIT_PROBE_TIMEOUT
34513459
);
3460+
if ( is_wp_error($cherry) && $this->is_git_timeout_error($cherry) ) {
3461+
return array(
3462+
'signal' => 'probe-timeout',
3463+
'reason' => $cherry->get_error_message(),
3464+
);
3465+
}
3466+
if ( ! is_wp_error($cherry) ) {
3467+
$counts = $this->parse_git_cherry_counts( (string) ( $cherry['output'] ?? '' ) );
3468+
if ( 0 < (int) $counts['total'] && 0 === (int) $counts['unmatched'] && 0 === (int) $counts['unknown'] ) {
3469+
return array(
3470+
'signal' => 'patch-equivalent-merged',
3471+
'reason' => sprintf('branch commits are patch-equivalent to remote default (%s) — squash merge detected', $default_ref),
3472+
'preserve_local_branch' => true,
3473+
'patch_equivalence_count' => (int) $counts['total'],
3474+
);
3475+
}
3476+
}
3477+
3478+
return null;
34523479
}
34533480

34543481
/**

inc/Workspace/WorktreeCleanupCandidateClassifier.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ public static function classify_merge_signal_path( array $context, ?array $signa
121121
if ( null !== $age_decision ) {
122122
$candidate['age_filter'] = $age_decision['age_filter'];
123123
}
124+
if ( ! empty($signal['preserve_local_branch']) ) {
125+
$candidate['preserve_local_branch'] = true;
126+
}
124127

125128
return array(
126129
'type' => 'candidate',

0 commit comments

Comments
 (0)