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
3 changes: 3 additions & 0 deletions inc/Cli/Commands/WorkspaceCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4081,6 +4081,9 @@ public function worktree( array $args, array $assoc_args ): void {
break;

case 'cleanup':
if ( ! empty($args[1]) ) {
$input['repo'] = (string) $args[1];
}
$input['dry_run'] = ! empty($assoc_args['dry-run']);
$input['force'] = ! empty($assoc_args['force']);
$input['skip_github'] = ! empty($assoc_args['skip-github']);
Expand Down
17 changes: 12 additions & 5 deletions inc/Workspace/WorkspaceActiveNoSignalCleanup.php
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,14 @@ 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 {
if ( 'merged_to_default' !== (string) ( $row['suggested_action'] ?? '' ) ) {
$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');
}

Expand Down Expand Up @@ -1699,6 +1706,10 @@ private function is_generated_or_artifact_path( string $path ): bool {
* @return string
*/
private function suggest_active_no_signal_action( array $row ): string {
if ( 0 === (int) ( $row['dirty'] ?? -1 ) && 0 === (int) ( $row['unpushed'] ?? -1 ) && 0 === (int) ( $row['commits_outside_default'] ?? -1 ) ) {
return 'merged_to_default';
}

$effective_status = (string) ( $row['upstream_equivalence']['effective_status'] ?? '' );
if ( 'equivalent_clean' === $effective_status ) {
return 'patch_equivalent_default';
Expand All @@ -1719,10 +1730,6 @@ private function suggest_active_no_signal_action( array $row ): string {
return 'active_open_pr';
}

if ( 0 === (int) ( $row['commits_outside_default'] ?? -1 ) ) {
return 'merged_to_default';
}

if ( true === ( $row['remote_tracking'] ?? null ) ) {
return 'remote_tracking_clean';
}
Expand Down
25 changes: 25 additions & 0 deletions tests/worktree-cleanup-cli-scope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

$source = file_get_contents(dirname(__DIR__) . '/inc/Cli/Commands/WorkspaceCommand.php');
if ( false === $source ) {
throw new RuntimeException('Unable to read WorkspaceCommand.php');
}

$cleanup_case_start = strpos($source, "case 'cleanup':");
if ( false === $cleanup_case_start ) {
throw new RuntimeException('cleanup CLI case was not found');
}

$cleanup_case_end = strpos($source, "case 'reconcile-metadata':", $cleanup_case_start);
if ( false === $cleanup_case_end ) {
throw new RuntimeException('cleanup CLI case end was not found');
}

$cleanup_case = substr($source, $cleanup_case_start, $cleanup_case_end - $cleanup_case_start);
if ( ! str_contains($cleanup_case, '$input[\'repo\'] = (string) $args[1];') ) {
throw new RuntimeException('workspace worktree cleanup must forward its positional repo/worktree scope into ability input');
}

echo "worktree-cleanup-cli-scope: ok\n";
99 changes: 99 additions & 0 deletions tests/worktree-cleanup-patch-equivalence.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ function is_wp_error( mixed $thing ): bool {

require_once dirname(__DIR__) . '/inc/Support/CommandSpec.php';
require_once dirname(__DIR__) . '/inc/Support/RuntimeCapabilities.php';
require_once dirname(__DIR__) . '/inc/Support/PathSecurity.php';
require_once dirname(__DIR__) . '/inc/Support/ProcessRunner.php';
require_once dirname(__DIR__) . '/inc/Support/GitRunner.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceHandle.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorktreeContextInjector.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceCoreUtilities.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceActiveNoSignalCleanup.php';
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeCleanupEngine.php';
Expand Down Expand Up @@ -137,4 +139,101 @@ protected function resolve_remote_default_ref( string $primary_path, int $timeou
worktree_cleanup_patch_equivalence_assert_same(0, $evidence['git_cherry']['unmatched'], 'no unmatched commits are promoted');
worktree_cleanup_patch_equivalence_assert_same('preserve_local_branch', $evidence['local_branch_handling'], 'cleanup preserves non-contained local branch');

$suggest_action = new ReflectionMethod($cleanup, 'suggest_active_no_signal_action');
$merged_action = $suggest_action->invoke(
$cleanup,
array(
'dirty' => 0,
'unpushed' => 0,
'commits_outside_default' => 0,
'remote_tracking' => true,
'upstream_equivalence' => array(
'effective_status' => 'equivalent_clean',
),
)
);
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');

$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');
worktree_cleanup_patch_equivalence_run($merged, 'git config user.name Test');
file_put_contents($merged . '/merged.txt', "merged\n");
worktree_cleanup_patch_equivalence_run($merged, 'git add merged.txt');
worktree_cleanup_patch_equivalence_run($merged, 'git commit -m merged-feature');
worktree_cleanup_patch_equivalence_run($primary, 'git merge --ff-only merged-feature');
worktree_cleanup_patch_equivalence_run($primary, 'git push origin main');

$merged_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<int,string> */
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<string,mixed> */
public function worktree_active_no_signal_report( array $opts = array() ): array {
return array(
'success' => true,
'rows' => array(
array(
'handle' => 'repo@merged-feature',
'repo' => 'repo',
'branch' => 'merged-feature',
'path' => $this->workspace_path . '/merged-worktree',
'dirty' => 0,
'unpushed' => 0,
'commits_outside_default' => 0,
'suggested_action' => 'remote_tracking_clean',
),
),
'summary' => array( 'inspected' => 1 ),
);
}
};

$merged_apply = $merged_cleanup->worktree_active_no_signal_merged_apply(array( 'dry_run' => true ));
worktree_cleanup_patch_equivalence_assert_same(true, is_array($merged_apply), 'merged apply should return a dry-run result');
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');

echo "worktree-cleanup-patch-equivalence: ok\n";
Loading