Skip to content

Commit ce771f6

Browse files
fix: verify worktree add persistence (#753)
Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent a2c895d commit ce771f6

2 files changed

Lines changed: 274 additions & 1 deletion

File tree

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,33 @@ public function worktree_add( string $repo, string $branch, ?string $from = null
160160
$response['bootstrap'] = WorktreeBootstrapper::bootstrap($wt_path);
161161
}
162162

163-
$this->worktree_inventory()->upsert($this->build_worktree_inventory_row_from_handle($wt_handle));
163+
if ( ! is_dir($wt_path) || ! file_exists($wt_path . '/.git') ) {
164+
return new \WP_Error(
165+
'worktree_not_materialized',
166+
sprintf('Git reported worktree "%s" was added at %s, but the checkout is not accessible after creation.', $wt_handle, $wt_path),
167+
array(
168+
'status' => 500,
169+
'handle' => $wt_handle,
170+
'path' => $wt_path,
171+
)
172+
);
173+
}
174+
175+
$persisted = $this->worktree_inventory()->upsert($this->build_worktree_inventory_row_from_handle($wt_handle));
176+
if ( ! $persisted ) {
177+
$this->rollback_rejected_worktree($primary_path, $wt_path, $branch, ! empty($response['created_branch']));
178+
WorktreeContextInjector::forget_metadata($wt_handle);
179+
180+
return new \WP_Error(
181+
'worktree_inventory_persist_failed',
182+
sprintf('Worktree "%s" was created but could not be persisted to the workspace inventory; rolled back the checkout instead of reporting success.', $wt_handle),
183+
array(
184+
'status' => 500,
185+
'handle' => $wt_handle,
186+
'path' => $wt_path,
187+
)
188+
);
189+
}
164190

165191
$this->emit_workspace_changed('worktree_add', $repo, $wt_handle, $wt_path);
166192

tests/worktree-add-lifecycle.php

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
const ARRAY_A = 'ARRAY_A';
6+
7+
if ( ! defined('ABSPATH') ) {
8+
define('ABSPATH', __DIR__ . '/fixtures/');
9+
}
10+
11+
$temp_root = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
12+
$workspace_root = rtrim($temp_root, '/') . '/datamachine-code-worktree-add-' . getmypid();
13+
if ( ! defined('DATAMACHINE_WORKSPACE_PATH') ) {
14+
define('DATAMACHINE_WORKSPACE_PATH', $workspace_root);
15+
}
16+
17+
final class WP_Error {
18+
private string $code;
19+
private string $message;
20+
private mixed $data;
21+
22+
public function __construct( string $code = '', string $message = '', mixed $data = null ) {
23+
$this->code = $code;
24+
$this->message = $message;
25+
$this->data = $data;
26+
}
27+
28+
public function get_error_code(): string {
29+
return $this->code;
30+
}
31+
32+
public function get_error_message(): string {
33+
return $this->message;
34+
}
35+
36+
public function get_error_data(): mixed {
37+
return $this->data;
38+
}
39+
}
40+
41+
function is_wp_error( mixed $value ): bool {
42+
return $value instanceof WP_Error;
43+
}
44+
45+
function apply_filters( string $hook_name, mixed $value, mixed ...$args ): mixed {
46+
return $value;
47+
}
48+
49+
function current_time( string $type, bool $gmt = false ): string {
50+
return gmdate('Y-m-d H:i:s');
51+
}
52+
53+
function wp_json_encode( mixed $value, int $flags = 0, int $depth = 512 ): string|false {
54+
return json_encode($value, $flags, $depth);
55+
}
56+
57+
$GLOBALS['datamachine_code_test_options'] = array();
58+
function get_option( string $name, mixed $default = false ): mixed {
59+
return $GLOBALS['datamachine_code_test_options'][ $name ] ?? $default;
60+
}
61+
62+
function update_option( string $name, mixed $value, mixed $autoload = null ): bool {
63+
$GLOBALS['datamachine_code_test_options'][ $name ] = $value;
64+
return true;
65+
}
66+
67+
function home_url(): string {
68+
return 'https://example.test';
69+
}
70+
71+
function get_bloginfo( string $show = '' ): string {
72+
return 'DMC Test';
73+
}
74+
75+
function dbDelta( string $sql ): array {
76+
return array();
77+
}
78+
79+
final class Datamachine_Code_Test_Wpdb {
80+
public string $prefix = 'wp_';
81+
public bool $fail_replace = false;
82+
public string $last_error = '';
83+
public int $insert_id = 0;
84+
public int $rows_affected = 0;
85+
86+
/** @var array<string,array<string,mixed>> */
87+
public array $rows = array();
88+
89+
/** @var array<int,array<string,mixed>> */
90+
public array $lock_rows = array();
91+
92+
public function get_charset_collate(): string {
93+
return '';
94+
}
95+
96+
public function replace( string $table, array $data ): int|false {
97+
if ( $this->fail_replace ) {
98+
return false;
99+
}
100+
101+
$this->rows[ (string) $data['handle'] ] = $data;
102+
$this->rows_affected = 1;
103+
return 1;
104+
}
105+
106+
public function insert( string $table, array $data, array $format = array() ): int|false {
107+
++$this->insert_id;
108+
$data['id'] = $this->insert_id;
109+
$this->lock_rows[ $this->insert_id ] = $data;
110+
$this->rows_affected = 1;
111+
return 1;
112+
}
113+
114+
public function delete( string $table, array $where ): int|false {
115+
unset($this->rows[ (string) ( $where['handle'] ?? '' ) ]);
116+
return 1;
117+
}
118+
119+
public function update( string $table, array $data, array $where ): int|false {
120+
$handle = (string) ( $where['handle'] ?? '' );
121+
if ( isset($this->rows[ $handle ]) ) {
122+
$this->rows[ $handle ] = array_merge($this->rows[ $handle ], $data);
123+
}
124+
if ( isset($where['id'], $this->lock_rows[ (int) $where['id'] ]) ) {
125+
$this->lock_rows[ (int) $where['id'] ] = array_merge($this->lock_rows[ (int) $where['id'] ], $data);
126+
}
127+
$this->rows_affected = 1;
128+
return 1;
129+
}
130+
131+
public function get_results( string $sql, string $output = ARRAY_A ): array {
132+
return array_values($this->rows);
133+
}
134+
135+
public function prepare( string $query, mixed ...$args ): string {
136+
foreach ( $args as $arg ) {
137+
$query = preg_replace('/%s/', addslashes((string) $arg), $query, 1) ?? $query;
138+
}
139+
return $query;
140+
}
141+
142+
public function query( string $sql ): int|false {
143+
$this->rows_affected = 0;
144+
return 1;
145+
}
146+
147+
public function get_var( string $sql ): string|int|null {
148+
if ( str_contains($sql, 'SHOW TABLES LIKE') ) {
149+
return str_contains($sql, 'datamachine_code_locks') ? $this->prefix . 'datamachine_code_locks' : $this->prefix . 'datamachine_code_worktrees';
150+
}
151+
return 0;
152+
}
153+
154+
public function get_col( string $sql ): array {
155+
return array();
156+
}
157+
}
158+
159+
require_once dirname(__DIR__) . '/vendor/autoload.php';
160+
require_once dirname(__DIR__) . '/inc/Workspace/Workspace.php';
161+
162+
use DataMachineCode\Workspace\Workspace;
163+
164+
function run_command( string $command, ?string $cwd = null ): string {
165+
$prefix = null === $cwd ? '' : 'cd ' . escapeshellarg($cwd) . ' && ';
166+
$output = array();
167+
$code = 0;
168+
exec($prefix . $command . ' 2>&1', $output, $code);
169+
if ( 0 !== $code ) {
170+
throw new RuntimeException(sprintf("Command failed (%d): %s\n%s", $code, $command, implode("\n", $output)));
171+
}
172+
return implode("\n", $output);
173+
}
174+
175+
function remove_tree( string $path ): void {
176+
if ( ! file_exists($path) ) {
177+
return;
178+
}
179+
$iterator = new RecursiveIteratorIterator(
180+
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
181+
RecursiveIteratorIterator::CHILD_FIRST
182+
);
183+
foreach ( $iterator as $item ) {
184+
$item->isDir() && ! $item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname());
185+
}
186+
rmdir($path);
187+
}
188+
189+
function assert_true( bool $condition, string $message ): void {
190+
if ( ! $condition ) {
191+
throw new RuntimeException($message);
192+
}
193+
}
194+
195+
function create_primary_checkout( string $workspace_root ): void {
196+
$source = $workspace_root . '/source';
197+
$origin = $workspace_root . '/origin.git';
198+
mkdir($workspace_root, 0777, true);
199+
mkdir($source, 0777, true);
200+
run_command('git init -b main', $source);
201+
run_command('git config user.email test@example.test', $source);
202+
run_command('git config user.name "DMC Test"', $source);
203+
file_put_contents($source . '/README.md', "fixture\n");
204+
run_command('git add README.md', $source);
205+
run_command('git commit -m initial', $source);
206+
run_command('git init --bare ' . escapeshellarg($origin));
207+
run_command('git remote add origin ' . escapeshellarg($origin), $source);
208+
run_command('git push -u origin main', $source);
209+
run_command('git clone ' . escapeshellarg($origin) . ' ' . escapeshellarg($workspace_root . '/homeboy'));
210+
run_command('git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main', $workspace_root . '/homeboy');
211+
}
212+
213+
remove_tree($workspace_root);
214+
215+
try {
216+
create_primary_checkout($workspace_root);
217+
$wpdb = new Datamachine_Code_Test_Wpdb();
218+
$GLOBALS['wpdb'] = $wpdb;
219+
220+
$workspace = new Workspace();
221+
$result = $workspace->worktree_add('homeboy', 'audit-primitives-20260616', 'origin/main', false, false, false, false, true);
222+
assert_true(! is_wp_error($result), is_wp_error($result) ? $result->get_error_message() : 'worktree_add failed');
223+
assert_true(is_dir($result['path']), 'successful worktree_add path is not accessible');
224+
assert_true(isset($wpdb->rows['homeboy@audit-primitives-20260616']), 'successful worktree_add was not persisted');
225+
226+
$show = $workspace->show_repo('homeboy@audit-primitives-20260616');
227+
assert_true(! is_wp_error($show), 'persisted worktree is not visible to show_repo');
228+
229+
$list = $workspace->worktree_list('homeboy', null, array( 'include_status' => false, 'include_disk' => false ));
230+
$handles = array_map(static fn( array $row ): string => (string) $row['handle'], $list['worktrees'] ?? array());
231+
assert_true(in_array('homeboy@audit-primitives-20260616', $handles, true), 'persisted worktree is not visible to worktree_list');
232+
233+
$failure_wpdb = new Datamachine_Code_Test_Wpdb();
234+
$failure_wpdb->fail_replace = true;
235+
$GLOBALS['wpdb'] = $failure_wpdb;
236+
$failed = $workspace->worktree_add('homeboy', 'audit-primitives-persist-fails', 'origin/main', false, false, false, false, true);
237+
assert_true(is_wp_error($failed), 'inventory persistence failure reported success');
238+
assert_true('worktree_inventory_persist_failed' === $failed->get_error_code(), 'unexpected persistence failure error code');
239+
assert_true(! is_dir($workspace_root . '/homeboy@audit-primitives-persist-fails'), 'failed persistence left a worktree directory behind');
240+
241+
remove_tree($workspace_root);
242+
fwrite(STDOUT, "worktree-add-lifecycle ok\n");
243+
} catch (Throwable $e) {
244+
remove_tree($workspace_root);
245+
fwrite(STDERR, $e->getMessage() . "\n");
246+
exit(1);
247+
}

0 commit comments

Comments
 (0)