diff --git a/inc/Workspace/WorkspaceActiveNoSignalCleanup.php b/inc/Workspace/WorkspaceActiveNoSignalCleanup.php index 3bd0741..b2fc4ea 100644 --- a/inc/Workspace/WorkspaceActiveNoSignalCleanup.php +++ b/inc/Workspace/WorkspaceActiveNoSignalCleanup.php @@ -266,20 +266,29 @@ public function worktree_active_no_signal_merged_apply( array $opts = array() ): return array( 'report_action_counts' => $report['summary']['by_suggested_action'] ?? array() ); }, 'prepare_row' => function ( array $row ): array|\WP_Error { - $is_merged_to_default = 'merged_to_default' === (string) ( $row['suggested_action'] ?? '' ) - || ( - 0 === (int) ( $row['dirty'] ?? -1 ) - && 0 === (int) ( $row['unpushed'] ?? -1 ) - && 0 === (int) ( $row['commits_outside_default'] ?? -1 ) - ); - - if ( ! $is_merged_to_default ) { - return new \WP_Error('not_merged_to_default', 'row is not a clean merged-to-default candidate'); + $action = (string) ( $row['suggested_action'] ?? '' ); + if ( in_array($action, array( 'merged_to_default', 'patch_equivalent_default' ), true) ) { + return $row; } - return $row; + if ( + 0 === (int) ( $row['dirty'] ?? -1 ) + && 0 === (int) ( $row['unpushed'] ?? -1 ) + && 0 === (int) ( $row['commits_outside_default'] ?? -1 ) + ) { + return $row; + } + + return new \WP_Error('not_merged_to_default', 'row is not a clean merged-to-default candidate'); + }, + 'build_metadata' => function ( array $row ): array|\WP_Error { + $action = (string) ( $row['suggested_action'] ?? '' ); + if ( 'patch_equivalent_default' === $action ) { + return $this->build_active_no_signal_patch_equivalent_to_default_metadata($row); + } + + return $this->build_active_no_signal_merged_to_default_metadata($row); }, - 'build_metadata' => fn( array $row ): array|\WP_Error => $this->build_active_no_signal_merged_to_default_metadata($row), 'build_planned' => static function ( array $row, array $metadata ): array { return array( 'handle' => (string) ( $row['handle'] ?? '' ), @@ -710,6 +719,39 @@ private function build_active_no_signal_merged_to_default_metadata( array $row ) return $metadata; } + /** + * Build cleanup metadata from one clean patch-equivalent-to-default evidence row. + * + * @param array $row Evidence row. + * @return array|\WP_Error + */ + private function build_active_no_signal_patch_equivalent_to_default_metadata( array $row ): array|\WP_Error { + $handle = (string) ( $row['handle'] ?? '' ); + $repo = (string) ( $row['repo'] ?? '' ); + $branch = (string) ( $row['branch'] ?? '' ); + $path = (string) ( $row['path'] ?? '' ); + + $evidence = $this->build_current_patch_equivalent_to_default_cleanup_evidence($handle, $repo, $branch, $path); + if ( is_wp_error($evidence) ) { + return $evidence; + } + + $metadata = $this->build_active_no_signal_cleanup_metadata( + $row, + $handle, + $repo, + $branch, + (string) ( $evidence['path'] ?? $path ), + WorktreeContextInjector::STATE_MERGED + ); + $metadata['auto_finalized_by'] = 'active_no_signal_merged_apply'; + $metadata['auto_finalized_signal'] = 'patch-equivalent-to-default'; + $metadata['auto_finalized_reason'] = sprintf('active/no-signal report found branch patch-equivalent to %s', (string) ( $evidence['default_ref'] ?? 'remote default' )); + $metadata['cleanup_eligibility_evidence'] = $evidence; + + return $metadata; + } + /** * Build cleanup metadata from one clean remote-tracking evidence row. * @@ -971,6 +1013,87 @@ private function build_current_merged_to_default_cleanup_evidence( string $handl ); } + /** + * Recompute patch-equivalent-to-default evidence for the current worktree state. + * + * @param string $handle Workspace handle. + * @param string $repo Repository name. + * @param string $branch Branch name. + * @param string $path Worktree path. + * @return array|\WP_Error + */ + private function build_current_patch_equivalent_to_default_cleanup_evidence( string $handle, string $repo, string $branch, string $path ): array|\WP_Error { + foreach ( + array( + 'handle' => $handle, + 'repo' => $repo, + 'branch' => $branch, + 'path' => $path, + ) as $field => $value + ) { + if ( '' === $value ) { + return new \WP_Error('missing_identity', 'missing required identity field: ' . $field); + } + } + + $facts = $this->validate_current_cleanup_worktree( + $repo, + $path, + $branch, + array( + 'require_clean' => true, + 'dirty_error_message' => 'refusing to mark dirty worktree cleanup_eligible from patch-equivalent-to-default evidence', + 'unpushed_error_message' => 'refusing to mark worktree with unpushed commits cleanup_eligible from patch-equivalent-to-default evidence', + ) + ); + if ( is_wp_error($facts) ) { + return $facts; + } + + $real_path = (string) $facts['real_path']; + $primary_path = (string) $facts['primary_path']; + $dirty = (int) $facts['dirty']; + $unpushed = (int) $facts['unpushed']; + + $default_ref = $this->resolve_remote_default_ref($primary_path, self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( ! is_string($default_ref) || '' === $default_ref ) { + return new \WP_Error('missing_default_ref', 'primary checkout default ref could not be resolved'); + } + + $upstream_equivalence = $this->build_clean_upstream_equivalence_evidence($primary_path, $real_path, $default_ref, $branch); + if ( 'equivalent_clean' !== (string) ( $upstream_equivalence['effective_status'] ?? '' ) ) { + return new \WP_Error('not_patch_equivalent_to_default', 'current branch is not patch-equivalent to remote default'); + } + + $branch_ref = 'refs/heads/' . $branch; + $branch_head = $this->run_git($primary_path, sprintf('rev-parse --verify %s', escapeshellarg($branch_ref)), self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( is_wp_error($branch_head) ) { + return $branch_head; + } + $default_head = $this->run_git($primary_path, sprintf('rev-parse --verify %s', escapeshellarg($default_ref . '^{commit}')), self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( is_wp_error($default_head) ) { + return $default_head; + } + + return array( + 'signal' => 'patch-equivalent-to-default', + 'finalized_state' => WorktreeContextInjector::STATE_MERGED, + 'reason' => 'branch commits are patch-equivalent to the remote default ref', + 'detected_at' => gmdate('c'), + 'handle' => $handle, + 'repo' => $repo, + 'branch' => $branch, + 'path' => $real_path, + 'default_ref' => $default_ref, + 'branch_ref' => $branch_ref, + 'branch_head' => trim( (string) ( $branch_head['output'] ?? '' )), + 'default_head' => trim( (string) ( $default_head['output'] ?? '' )), + 'upstream_equivalence' => $upstream_equivalence, + 'dirty' => (int) $dirty, + 'unpushed' => (int) $unpushed, + ); + } + /** * Revalidate the current worktree state before writing cleanup metadata. * @@ -1710,14 +1833,6 @@ private function suggest_active_no_signal_action( array $row ): string { return 'merged_to_default'; } - $effective_status = (string) ( $row['upstream_equivalence']['effective_status'] ?? '' ); - if ( 'equivalent_clean' === $effective_status ) { - return 'patch_equivalent_default'; - } - if ( 'contained_non_default_remote' === $effective_status ) { - return 'contained_non_default_remote'; - } - if ( (int) ( $row['dirty'] ?? 0 ) > 0 || (int) ( $row['unpushed'] ?? 0 ) > 0 ) { return 'unsafe_dirty_or_unpushed'; } @@ -1730,6 +1845,14 @@ private function suggest_active_no_signal_action( array $row ): string { return 'active_open_pr'; } + $effective_status = (string) ( $row['upstream_equivalence']['effective_status'] ?? '' ); + if ( 'equivalent_clean' === $effective_status ) { + return 'patch_equivalent_default'; + } + if ( 'contained_non_default_remote' === $effective_status ) { + return 'contained_non_default_remote'; + } + if ( true === ( $row['remote_tracking'] ?? null ) ) { return 'remote_tracking_clean'; } diff --git a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php index 7de5bfc..9e9febf 100644 --- a/inc/Workspace/WorkspaceWorktreeCleanupEngine.php +++ b/inc/Workspace/WorkspaceWorktreeCleanupEngine.php @@ -3441,14 +3441,41 @@ private function detect_local_merged_signal( string $primary_path, string $branc } $unique_commits = (int) trim( (string) ( $result['output'] ?? '' )); - if ( 0 !== $unique_commits ) { - return null; + if ( 0 === $unique_commits ) { + return array( + 'signal' => 'local-merged', + 'reason' => sprintf('branch has no commits outside remote default (%s)', $default_ref), + ); } - return array( - 'signal' => 'local-merged', - 'reason' => sprintf('branch has no commits outside remote default (%s)', $default_ref), + // Squash merges create a new commit on the default branch, so the + // original branch tip is never an ancestor of origin/main. Fall back + // to patch-equivalence detection: if every local patch already exists + // on the default branch, removing the clean worktree is safe. + $cherry = $this->run_git( + $primary_path, + sprintf('cherry %s %s', escapeshellarg($default_ref), escapeshellarg($branch_ref)), + self::CLEANUP_GIT_PROBE_TIMEOUT ); + if ( is_wp_error($cherry) && $this->is_git_timeout_error($cherry) ) { + return array( + 'signal' => 'probe-timeout', + 'reason' => $cherry->get_error_message(), + ); + } + if ( ! is_wp_error($cherry) ) { + $counts = $this->parse_git_cherry_counts( (string) ( $cherry['output'] ?? '' ) ); + if ( 0 < (int) $counts['total'] && 0 === (int) $counts['unmatched'] && 0 === (int) $counts['unknown'] ) { + return array( + 'signal' => 'patch-equivalent-merged', + 'reason' => sprintf('branch commits are patch-equivalent to remote default (%s) — squash merge detected', $default_ref), + 'preserve_local_branch' => true, + 'patch_equivalence_count' => (int) $counts['total'], + ); + } + } + + return null; } /** diff --git a/inc/Workspace/WorktreeCleanupCandidateClassifier.php b/inc/Workspace/WorktreeCleanupCandidateClassifier.php index a304e40..32ed814 100644 --- a/inc/Workspace/WorktreeCleanupCandidateClassifier.php +++ b/inc/Workspace/WorktreeCleanupCandidateClassifier.php @@ -121,6 +121,9 @@ public static function classify_merge_signal_path( array $context, ?array $signa if ( null !== $age_decision ) { $candidate['age_filter'] = $age_decision['age_filter']; } + if ( ! empty($signal['preserve_local_branch']) ) { + $candidate['preserve_local_branch'] = true; + } return array( 'type' => 'candidate', diff --git a/tests/worktree-cleanup-patch-equivalence.php b/tests/worktree-cleanup-patch-equivalence.php index 9c8b12a..d9bde71 100644 --- a/tests/worktree-cleanup-patch-equivalence.php +++ b/tests/worktree-cleanup-patch-equivalence.php @@ -106,12 +106,14 @@ function worktree_cleanup_patch_equivalence_run( string $cwd, string $command ): worktree_cleanup_patch_equivalence_run($primary, 'git add recipe.txt'); worktree_cleanup_patch_equivalence_run($primary, 'git commit -m squash-feature-change'); worktree_cleanup_patch_equivalence_run($primary, 'git push origin main'); +worktree_cleanup_patch_equivalence_run($primary, 'git remote set-head origin -a'); $cleanup = new class { use WorkspaceCoreUtilities; use WorkspaceActiveNoSignalCleanup; use WorkspaceWorktreeCleanupEngine { WorkspaceWorktreeCleanupEngine::classify_unpushed_patch_equivalent_to_default as public classifyPatchEquivalent; + WorkspaceWorktreeCleanupEngine::detect_local_merged_signal as public detectLocalMerged; } protected const CLEANUP_GIT_PROBE_TIMEOUT = 5; @@ -154,6 +156,11 @@ protected function resolve_remote_default_ref( string $primary_path, int $timeou ); worktree_cleanup_patch_equivalence_assert_same('merged_to_default', $merged_action, 'exact clean containment in the remote default branch should win over patch-equivalence and remote-tracking labels'); +// Simulate the real squash-merge workflow: the feature branch was pushed to +// origin before being squash-merged into main. After the merge the local branch +// is still clean and fully pushed relative to its remote tracking ref. +worktree_cleanup_patch_equivalence_run($wt, 'git push -u origin feature'); + $merged = $root . '/merged-worktree'; worktree_cleanup_patch_equivalence_run($primary, 'git worktree add -b merged-feature ../merged-worktree origin/main'); worktree_cleanup_patch_equivalence_run($merged, 'git config user.email test@example.com'); @@ -236,4 +243,124 @@ public function worktree_active_no_signal_report( array $opts = array() ): array worktree_cleanup_patch_equivalence_assert_same(1, $merged_apply['summary']['planned'], 'merged apply should plan clean rows with zero commits outside default even when an older report label was remote_tracking_clean'); worktree_cleanup_patch_equivalence_assert_same('repo@merged-feature', $merged_apply['planned'][0]['handle'], 'merged apply should preserve the planned handle'); +$local_merged_signal = $cleanup->detectLocalMerged($primary, 'feature'); +worktree_cleanup_patch_equivalence_assert_same(true, is_array($local_merged_signal), 'squash-merged branch should produce a local merge signal via patch equivalence'); +worktree_cleanup_patch_equivalence_assert_same('patch-equivalent-merged', $local_merged_signal['signal'], 'signal name should reflect patch-equivalent squash-merge detection'); +worktree_cleanup_patch_equivalence_assert_same(true, $local_merged_signal['preserve_local_branch'], 'patch-equivalent cleanup should preserve the local branch'); + +$patch_equivalent_action = $suggest_action->invoke( + $cleanup, + array( + 'dirty' => 0, + 'unpushed' => 0, + 'commits_outside_default' => 1, + 'upstream_equivalence' => array( + 'effective_status' => 'equivalent_clean', + ), + ) +); +worktree_cleanup_patch_equivalence_assert_same('patch_equivalent_default', $patch_equivalent_action, 'clean patch-equivalent branch with no open PR should suggest patch_equivalent_default'); + +$open_pr_action = $suggest_action->invoke( + $cleanup, + array( + 'dirty' => 0, + 'unpushed' => 0, + 'commits_outside_default' => 1, + 'upstream_equivalence' => array( + 'effective_status' => 'equivalent_clean', + ), + 'pr' => array( + 'state' => 'open', + ), + ) +); +worktree_cleanup_patch_equivalence_assert_same('active_open_pr', $open_pr_action, 'open PR must block patch_equivalent_default and keep the row as active_open_pr'); + +$dirty_action = $suggest_action->invoke( + $cleanup, + array( + 'dirty' => 1, + 'unpushed' => 0, + 'commits_outside_default' => 1, + 'upstream_equivalence' => array( + 'effective_status' => 'equivalent_clean', + ), + ) +); +worktree_cleanup_patch_equivalence_assert_same('unsafe_dirty_or_unpushed', $dirty_action, 'dirty patch-equivalent branch must stay blocked'); + +$patch_cleanup = new class($root) { + use WorkspaceCoreUtilities; + use WorkspaceActiveNoSignalCleanup; + use WorkspaceWorktreeCleanupEngine; + + protected const CLEANUP_GIT_PROBE_TIMEOUT = 5; + + protected string $workspace_path; + + public function __construct( string $workspace_path ) { + $this->workspace_path = $workspace_path; + } + + public function get_primary_path( string $repo ): string { + return $this->workspace_path . '/primary'; + } + + protected function resolve_remote_default_ref( string $primary_path, int $timeout_seconds = 0 ): string|WP_Error|null { + return 'refs/remotes/origin/main'; + } + + /** @return array */ + protected function protected_base_branch_names(): array { + return array( 'main', 'master', 'trunk', 'develop' ); + } + + protected function resolve_worktree_branch_from_head_file( string $wt_path ): ?string { + $result = $this->run_git($wt_path, 'branch --show-current', self::CLEANUP_GIT_PROBE_TIMEOUT); + if ( is_wp_error($result) ) { + return null; + } + + $branch = trim((string) ( $result['output'] ?? '' )); + return '' === $branch ? null : $branch; + } + + protected function probe_worktree_dirty_count( string $path, int $timeout_seconds = 0 ): int|WP_Error { + $result = $this->run_git($path, 'status --porcelain', $timeout_seconds); + if ( is_wp_error($result) ) { + return $result; + } + + $lines = array_filter(array_map('trim', explode("\n", (string) ( $result['output'] ?? '' )))); + return count($lines); + } + + /** @return array */ + public function worktree_active_no_signal_report( array $opts = array() ): array { + return array( + 'success' => true, + 'rows' => array( + array( + 'handle' => 'repo@feature', + 'repo' => 'repo', + 'branch' => 'feature', + 'path' => $this->workspace_path . '/feature-worktree', + 'dirty' => 0, + 'unpushed' => 0, + 'commits_outside_default' => 1, + 'suggested_action' => 'patch_equivalent_default', + ), + ), + 'summary' => array( 'inspected' => 1 ), + ); + } +}; + +$patch_apply = $patch_cleanup->worktree_active_no_signal_merged_apply(array( 'dry_run' => true )); +worktree_cleanup_patch_equivalence_assert_same(true, is_array($patch_apply), 'patch-equivalent apply should return a dry-run result'); +worktree_cleanup_patch_equivalence_assert_same(1, $patch_apply['summary']['planned'], 'patch-equivalent apply should plan clean patch_equivalent_default rows'); +worktree_cleanup_patch_equivalence_assert_same('repo@feature', $patch_apply['planned'][0]['handle'], 'patch-equivalent apply should preserve the planned handle'); +worktree_cleanup_patch_equivalence_assert_same('patch-equivalent-to-default', $patch_apply['planned'][0]['metadata']['auto_finalized_signal'], 'patch-equivalent metadata should record the correct signal'); + echo "worktree-cleanup-patch-equivalence: ok\n";