Skip to content

Commit 6fceb8d

Browse files
Add patch-equivalent worktree cleanup path (#878)
* Add patch-equivalent worktree cleanup path * Fix cleanup evidence assignment alignment --------- Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent 5f802cf commit 6fceb8d

2 files changed

Lines changed: 243 additions & 0 deletions

File tree

inc/Workspace/WorkspaceWorktreeCleanupEngine.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,55 @@ public function worktree_cleanup_merged( array $opts = array() ): array|\WP_Erro
437437
continue;
438438
}
439439
if ( $unpushed > 0 ) {
440+
$primary_path = $this->get_primary_path($repo);
441+
if ( ! is_dir($primary_path . '/.git') ) {
442+
$skipped[] = array_merge(
443+
array(
444+
'handle' => $handle,
445+
'repo' => $repo,
446+
'branch' => $branch,
447+
'path' => $wt_path,
448+
'reason_code' => 'missing_metadata',
449+
'reason' => 'primary checkout missing',
450+
'hint' => 'Run workspace worktree prune if this is a stale registry entry; inspect manually if the path still exists.',
451+
'created_at' => $created_at,
452+
'metadata' => $metadata,
453+
), $disk_fields
454+
);
455+
continue;
456+
}
457+
458+
$patch_equivalence = $this->classify_unpushed_patch_equivalent_to_default($primary_path, $wt_path, $repo, $fetched, $fetch_timeouts);
459+
if ( is_wp_error($patch_equivalence) ) {
460+
$skipped[] = $this->build_worktree_probe_failure_skip($handle, $repo, $branch, $wt_path, $created_at, $metadata, $disk_fields, $patch_equivalence);
461+
continue;
462+
}
463+
464+
if ( is_array($patch_equivalence) ) {
465+
$candidates[] = array_merge(
466+
array(
467+
'handle' => $handle,
468+
'repo' => $repo,
469+
'branch' => $branch,
470+
'path' => $wt_path,
471+
'dirty' => $dirty_count,
472+
'unpushed' => $unpushed,
473+
'signal' => 'patch-equivalent-to-default',
474+
'reason_code' => 'patch-equivalent-to-default',
475+
'reason' => sprintf('%d unpushed commit(s) are patch-equivalent to the remote default branch; removing the clean worktree is safe without discarding unique work', $unpushed),
476+
'hint' => 'Cleanup removes only the clean worktree for this reviewed class and preserves the local branch because commit containment did not prove a normal merge.',
477+
'cleanup_reasons' => array( 'patch-equivalent-to-default', 'git cherry proved every local patch exists on the remote default branch' ),
478+
'patch_equivalence_evidence' => $patch_equivalence,
479+
'preserve_local_branch' => true,
480+
'created_at' => $created_at,
481+
'liveness' => $liveness,
482+
'metadata' => $metadata,
483+
),
484+
$disk_fields
485+
);
486+
continue;
487+
}
488+
440489
$skipped[] = array_merge(
441490
array(
442491
'handle' => $handle,
@@ -574,6 +623,12 @@ function () use ( $cand, $force, $remove_timeout_seconds ) {
574623
return $remove;
575624
}
576625

626+
if ( ! empty($cand['preserve_local_branch']) ) {
627+
$remove['local_branch_preserved'] = true;
628+
$remove['branch_delete_skipped'] = 'patch-equivalent cleanup preserves the local branch instead of force-deleting commits whose containment was not proven';
629+
return $remove;
630+
}
631+
577632
// Delete the now-detached local branch while the repo lock still covers
578633
// shared git metadata.
579634
$primary_path = $this->get_primary_path($cand['repo']);
@@ -670,6 +725,54 @@ private function build_worktree_cleanup_review_plan_command( ?int $limit, int $o
670725
return implode(' ', array_values(array_filter($parts, fn( $part ) => null !== $part)));
671726
}
672727

728+
/**
729+
* Prove that a clean worktree's unpushed commits are already present on default by patch-id.
730+
*
731+
* @param string $primary_path Primary checkout path.
732+
* @param string $wt_path Worktree path.
733+
* @param string $repo Workspace repo name.
734+
* @param array<string,bool> $fetched Repo fetch cache.
735+
* @param array<string,\WP_Error> $fetch_timeouts Repo fetch timeout cache.
736+
* @return array<string,mixed>|\WP_Error|null Evidence when safe, timeout error, or null when not proven.
737+
*/
738+
private function classify_unpushed_patch_equivalent_to_default( string $primary_path, string $wt_path, string $repo, array &$fetched, array &$fetch_timeouts ): array|\WP_Error|null {
739+
if ( isset($fetch_timeouts[ $repo ]) ) {
740+
return $fetch_timeouts[ $repo ];
741+
}
742+
743+
if ( empty($fetched[ $repo ]) ) {
744+
$fetch = $this->run_git($primary_path, 'fetch --prune --quiet origin', self::CLEANUP_GIT_PROBE_TIMEOUT);
745+
if ( is_wp_error($fetch) && $this->is_git_timeout_error($fetch) ) {
746+
$fetch_timeouts[ $repo ] = $fetch;
747+
return $fetch;
748+
}
749+
$fetched[ $repo ] = true;
750+
}
751+
752+
$default_ref = $this->resolve_remote_default_ref($primary_path, self::CLEANUP_GIT_PROBE_TIMEOUT);
753+
if ( is_wp_error($default_ref) ) {
754+
return $default_ref;
755+
}
756+
if ( null === $default_ref || '' === $default_ref ) {
757+
return null;
758+
}
759+
760+
$evidence = $this->build_clean_upstream_equivalence_evidence($primary_path, $wt_path, $default_ref, '');
761+
$evidence['compared_refs'] = array(
762+
'left' => $default_ref,
763+
'right' => 'HEAD',
764+
'tool' => 'git cherry',
765+
);
766+
$evidence['safe_cleanup_reason'] = 'git cherry reported every local commit with a patch-equivalent match on the remote default branch; no unique patch would be lost by removing the clean worktree';
767+
$evidence['local_branch_handling'] = 'preserve_local_branch';
768+
769+
if ( 'equivalent_clean' !== (string) ( $evidence['effective_status'] ?? '' ) ) {
770+
return null;
771+
}
772+
773+
return $evidence;
774+
}
775+
673776
/**
674777
* Collapse duplicate cleanup rows emitted by overlapping inventory/git sources.
675778
*
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if ( ! defined('ABSPATH') ) {
6+
define('ABSPATH', __DIR__ . '/fixtures/');
7+
}
8+
9+
if ( ! class_exists('WP_Error') ) {
10+
class WP_Error {
11+
private string $code;
12+
private string $message;
13+
private array $data;
14+
15+
public function __construct( string $code = '', string $message = '', array $data = array() ) {
16+
$this->code = $code;
17+
$this->message = $message;
18+
$this->data = $data;
19+
}
20+
21+
public function get_error_code(): string {
22+
return $this->code;
23+
}
24+
25+
public function get_error_message(): string {
26+
return $this->message;
27+
}
28+
29+
public function get_error_data(): array {
30+
return $this->data;
31+
}
32+
}
33+
}
34+
35+
if ( ! function_exists('is_wp_error') ) {
36+
function is_wp_error( mixed $thing ): bool {
37+
return $thing instanceof WP_Error;
38+
}
39+
}
40+
41+
require_once dirname(__DIR__) . '/inc/Support/CommandSpec.php';
42+
require_once dirname(__DIR__) . '/inc/Support/RuntimeCapabilities.php';
43+
require_once dirname(__DIR__) . '/inc/Support/ProcessRunner.php';
44+
require_once dirname(__DIR__) . '/inc/Support/GitRunner.php';
45+
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceHandle.php';
46+
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceCoreUtilities.php';
47+
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceActiveNoSignalCleanup.php';
48+
require_once dirname(__DIR__) . '/inc/Workspace/WorkspaceWorktreeCleanupEngine.php';
49+
50+
use DataMachineCode\Workspace\WorkspaceActiveNoSignalCleanup;
51+
use DataMachineCode\Workspace\WorkspaceCoreUtilities;
52+
use DataMachineCode\Workspace\WorkspaceWorktreeCleanupEngine;
53+
54+
function worktree_cleanup_patch_equivalence_assert_same( mixed $expected, mixed $actual, string $message ): void {
55+
if ( $expected !== $actual ) {
56+
throw new RuntimeException(sprintf('%s Expected %s, got %s.', $message, var_export($expected, true), var_export($actual, true)));
57+
}
58+
}
59+
60+
function worktree_cleanup_patch_equivalence_run( string $cwd, string $command ): void {
61+
$descriptor_spec = array(
62+
0 => array( 'pipe', 'r' ),
63+
1 => array( 'pipe', 'w' ),
64+
2 => array( 'pipe', 'w' ),
65+
);
66+
$process = proc_open($command, $descriptor_spec, $pipes, $cwd);
67+
if ( ! is_resource($process) ) {
68+
throw new RuntimeException('Failed to start command: ' . $command);
69+
}
70+
fclose($pipes[0]);
71+
$output = stream_get_contents($pipes[1]);
72+
$error = stream_get_contents($pipes[2]);
73+
fclose($pipes[1]);
74+
fclose($pipes[2]);
75+
$status = proc_close($process);
76+
if ( 0 !== $status ) {
77+
throw new RuntimeException(sprintf("Command failed (%d): %s\n%s\n%s", $status, $command, $output, $error));
78+
}
79+
}
80+
81+
$root = sys_get_temp_dir() . '/dmc-patch-equivalence-' . bin2hex(random_bytes(4));
82+
$origin = $root . '/origin.git';
83+
$primary = $root . '/primary';
84+
$wt = $root . '/feature-worktree';
85+
86+
mkdir($root, 0700, true);
87+
worktree_cleanup_patch_equivalence_run($root, 'git init --bare origin.git');
88+
worktree_cleanup_patch_equivalence_run($root, 'git clone origin.git primary');
89+
worktree_cleanup_patch_equivalence_run($primary, 'git config user.email test@example.com');
90+
worktree_cleanup_patch_equivalence_run($primary, 'git config user.name Test');
91+
worktree_cleanup_patch_equivalence_run($primary, 'git checkout -b main');
92+
file_put_contents($primary . '/recipe.txt', "base\n");
93+
worktree_cleanup_patch_equivalence_run($primary, 'git add recipe.txt');
94+
worktree_cleanup_patch_equivalence_run($primary, 'git commit -m base');
95+
worktree_cleanup_patch_equivalence_run($primary, 'git push -u origin main');
96+
worktree_cleanup_patch_equivalence_run($origin, 'git symbolic-ref HEAD refs/heads/main');
97+
worktree_cleanup_patch_equivalence_run($primary, 'git worktree add -b feature ../feature-worktree origin/main');
98+
worktree_cleanup_patch_equivalence_run($wt, 'git config user.email test@example.com');
99+
worktree_cleanup_patch_equivalence_run($wt, 'git config user.name Test');
100+
file_put_contents($wt . '/recipe.txt', "base\nsquash-equivalent\n");
101+
worktree_cleanup_patch_equivalence_run($wt, 'git add recipe.txt');
102+
worktree_cleanup_patch_equivalence_run($wt, 'git commit -m feature-change');
103+
file_put_contents($primary . '/recipe.txt', "base\nsquash-equivalent\n");
104+
worktree_cleanup_patch_equivalence_run($primary, 'git add recipe.txt');
105+
worktree_cleanup_patch_equivalence_run($primary, 'git commit -m squash-feature-change');
106+
worktree_cleanup_patch_equivalence_run($primary, 'git push origin main');
107+
108+
$cleanup = new class {
109+
use WorkspaceCoreUtilities;
110+
use WorkspaceActiveNoSignalCleanup;
111+
use WorkspaceWorktreeCleanupEngine {
112+
WorkspaceWorktreeCleanupEngine::classify_unpushed_patch_equivalent_to_default as public classifyPatchEquivalent;
113+
}
114+
115+
protected const CLEANUP_GIT_PROBE_TIMEOUT = 5;
116+
117+
protected function resolve_remote_default_ref( string $primary_path, int $timeout_seconds = 0 ): string|WP_Error|null {
118+
$result = $this->run_git($primary_path, 'symbolic-ref --quiet refs/remotes/origin/HEAD', $timeout_seconds);
119+
if ( is_wp_error($result) ) {
120+
return null;
121+
}
122+
123+
$ref = trim( (string) ( $result['output'] ?? '' ));
124+
return '' === $ref ? null : $ref;
125+
}
126+
};
127+
128+
$fetched = array();
129+
$fetch_timeouts = array();
130+
$evidence = $cleanup->classifyPatchEquivalent($primary, $wt, 'repo', $fetched, $fetch_timeouts);
131+
132+
worktree_cleanup_patch_equivalence_assert_same(true, is_array($evidence), 'squash-equivalent commit should be promoted with evidence');
133+
worktree_cleanup_patch_equivalence_assert_same('equivalent_clean', $evidence['effective_status'], 'git cherry proves patch equivalence');
134+
worktree_cleanup_patch_equivalence_assert_same('refs/remotes/origin/main', $evidence['compared_refs']['left'], 'evidence records default ref');
135+
worktree_cleanup_patch_equivalence_assert_same('HEAD', $evidence['compared_refs']['right'], 'evidence records worktree ref');
136+
worktree_cleanup_patch_equivalence_assert_same(1, $evidence['git_cherry']['equivalent'], 'squash-equivalent commit is counted as equivalent');
137+
worktree_cleanup_patch_equivalence_assert_same(0, $evidence['git_cherry']['unmatched'], 'no unmatched commits are promoted');
138+
worktree_cleanup_patch_equivalence_assert_same('preserve_local_branch', $evidence['local_branch_handling'], 'cleanup preserves non-contained local branch');
139+
140+
echo "worktree-cleanup-patch-equivalence: ok\n";

0 commit comments

Comments
 (0)