From aec3d3680afd5e716fe3eee8ea779254446f05ab Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 28 Jul 2026 20:06:52 -0400 Subject: [PATCH] fix: reuse compatible worktree handles --- inc/Workspace/WorkspaceWorktreeLifecycle.php | 97 +++++++++++++++++++- tests/worktree-add-lifecycle.php | 27 ++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index f9a2062a..532c732b 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -116,7 +116,7 @@ public function worktree_add( string $repo, string $branch, ?string $from = null $wt_path = $this->workspace_path . '/' . $wt_handle; if ( is_dir($wt_path) ) { - return new \WP_Error('worktree_exists', sprintf('Worktree handle "%s" already exists.', $wt_handle), array( 'status' => 400 )); + return $this->reuse_existing_worktree($wt_handle, $branch, $from, $inject_context, $bootstrap, $task); } return WorkspaceMutationLock::with_repo( @@ -196,7 +196,7 @@ private function worktree_add_with_capacity_lock( ): array|\WP_Error { $operation_deadline = microtime(true) + self::worktree_capacity_operation_timeout_seconds($bootstrap); if ( is_dir($wt_path) ) { - return new \WP_Error('worktree_exists', sprintf('Worktree handle "%s" already exists.', $wt_handle), array( 'status' => 400 )); + return $this->reuse_existing_worktree($wt_handle, $branch, $from, $inject_context, $bootstrap, $task); } $fetch = WorktreeStalenessProbe::fetch($primary_path); @@ -294,6 +294,7 @@ static function ( $row ): string { $wt_handle, $wt_path, $primary_path, + $bootstrap, $task, $allow_unverified_freshness, array( @@ -412,6 +413,7 @@ static function ( $row ): string { * @param string $wt_handle Worktree handle. * @param string $wt_path Worktree path. * @param string $primary_path Primary checkout path. + * @param bool $bootstrap Whether dependency bootstrap was requested. * @param array $task Optional task metadata recorded on the worktree. * @param bool $allow_unverified_freshness Bypass fetch-failure freshness verification. * @return array|\WP_Error @@ -427,6 +429,7 @@ private function worktree_add_locked( string $wt_handle, string $wt_path, string $primary_path, + bool $bootstrap, array $task = array(), bool $allow_unverified_freshness = false, array $preflight = array() @@ -629,6 +632,12 @@ private function worktree_add_locked( 'task_ref' => isset( $task['task_ref'] ) ? (string) $task['task_ref'] : '', ) ); + $lifecycle_metadata['reuse_contract'] = array( + 'branch' => $branch, + 'base_ref' => $created_branch ? $resolved_base : 'existing_local_branch', + 'inject_context' => $inject_context, + 'bootstrap' => $bootstrap, + ); $metadata_stored = WorktreeContextInjector::store_lifecycle_metadata( $wt_handle, $lifecycle_metadata ); if ( is_wp_error( $metadata_stored ) ) { $this->rollback_rejected_worktree( $primary_path, $wt_path, $branch, $created_branch ); @@ -669,6 +678,90 @@ private function worktree_add_locked( return $response; } + /** + * Reuse an exact managed handle only when its persisted contract proves it is + * safe. This intentionally does not search for equivalent task candidates or + * recycle terminal worktrees; those need explicit caller policy. + */ + private function reuse_existing_worktree( string $handle, string $branch, ?string $from, bool $inject_context, bool $bootstrap, array $task ): array|\WP_Error { + $inspection = $this->worktree_get($handle, array( 'include_status' => true, 'include_disk' => false )); + if ( is_wp_error($inspection) || empty($inspection['worktrees'][0]) ) { + return $this->worktree_reuse_refused($handle, 'inspection_failed', array( + 'error_code' => is_wp_error($inspection) ? $inspection->get_error_code() : 'worktree_not_found', + )); + } + + $existing = $inspection['worktrees'][0]; + $evidence = array( + 'handle' => $handle, + 'branch' => $existing['branch'] ?? null, + 'head' => $existing['head'] ?? null, + 'dirty' => $existing['dirty'] ?? null, + 'unpushed' => $existing['unpushed'] ?? null, + 'liveness' => $existing['liveness'] ?? null, + 'task' => $existing['task'] ?? null, + 'metadata' => $existing['metadata'] ?? null, + ); + if ( $branch !== ( $existing['branch'] ?? null ) ) { + return $this->worktree_reuse_refused($handle, 'branch_mismatch', $evidence + array( 'requested_branch' => $branch )); + } + if ( (int) ( $existing['dirty'] ?? 0 ) > 0 ) { + return $this->worktree_reuse_refused($handle, 'dirty_worktree', $evidence); + } + if ( (int) ( $existing['unpushed'] ?? 0 ) > 0 ) { + return $this->worktree_reuse_refused($handle, 'unpushed_commits', $evidence); + } + if ( WorktreeContextInjector::LIVENESS_LIVE === ( $existing['liveness'] ?? null ) ) { + return $this->worktree_reuse_refused($handle, 'live_worktree', $evidence); + } + + $metadata = is_array($existing['metadata'] ?? null) ? $existing['metadata'] : array(); + $contract = is_array($metadata['reuse_contract'] ?? null) ? $metadata['reuse_contract'] : array(); + if ( array() === $contract ) { + return $this->worktree_reuse_refused($handle, 'reuse_contract_missing', $evidence); + } + $requested_base = null !== $from && '' !== trim($from) ? trim($from) : ( $contract['base_ref'] ?? null ); + if ( $requested_base !== ( $contract['base_ref'] ?? null ) ) { + return $this->worktree_reuse_refused($handle, 'base_mismatch', $evidence + array( 'requested_base_ref' => $requested_base, 'stored_base_ref' => $contract['base_ref'] ?? null )); + } + if ( $inject_context !== (bool) ( $contract['inject_context'] ?? null ) || $bootstrap !== (bool) ( $contract['bootstrap'] ?? null ) ) { + return $this->worktree_reuse_refused($handle, 'runtime_incompatible', $evidence + array( + 'requested_runtime' => array( 'inject_context' => $inject_context, 'bootstrap' => $bootstrap ), + 'stored_runtime' => array( 'inject_context' => $contract['inject_context'] ?? null, 'bootstrap' => $contract['bootstrap'] ?? null ), + )); + } + if ( $this->worktree_reuse_task_identity($task) !== $this->worktree_reuse_task_identity((array) ($existing['task'] ?? array())) ) { + return $this->worktree_reuse_refused($handle, 'task_mismatch', $evidence + array( 'requested_task' => $task )); + } + + return array( + 'success' => true, + 'handle' => $handle, + 'path' => $existing['path'], + 'branch' => $branch, + 'slug' => $this->slugify_branch($branch), + 'created_branch' => false, + 'reused' => true, + 'reuse' => array( 'status' => 'accepted', 'reason_code' => 'exact_compatible_handle' ) + $evidence, + 'metadata' => $metadata, + 'message' => sprintf('Reused clean compatible worktree "%s" at %s.', $handle, $existing['path']), + ); + } + + /** @return \WP_Error Typed evidence for a non-reusable exact handle. */ + private function worktree_reuse_refused( string $handle, string $reason_code, array $evidence ): \WP_Error { + return new \WP_Error( + 'worktree_reuse_refused', + sprintf('Refusing to reuse worktree "%s": %s.', $handle, str_replace('_', ' ', $reason_code)), + array( 'status' => 409, 'reuse' => array( 'status' => 'refused', 'reason_code' => $reason_code ) + $evidence ) + ); + } + + /** @return string Stable task identity used only to guard exact-handle reuse. */ + private function worktree_reuse_task_identity( array $task ): string { + return (string) ( $task['task_url'] ?? $task['task_ref'] ?? '' ); + } + /** * Attach lifecycle finalizer metadata to a worktree record. * diff --git a/tests/worktree-add-lifecycle.php b/tests/worktree-add-lifecycle.php index d92a1e44..a528efad 100644 --- a/tests/worktree-add-lifecycle.php +++ b/tests/worktree-add-lifecycle.php @@ -385,6 +385,33 @@ function create_primary_checkout( string $workspace_root ): void { assert_true('https://example.test/issues/environment' === ( $wpdb->rows['homeboy@audit-primitives-environment-tracker']['task_url'] ?? '' ), 'environment tracker metadata was not persisted'); putenv('DATAMACHINE_TASK_URL'); + $reusable = $workspace->worktree_add('homeboy', 'idempotent-reuse', 'origin/main', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/reuse' )); + assert_true(! is_wp_error($reusable), is_wp_error($reusable) ? $reusable->get_error_message() : 'reuse fixture creation failed'); + $reuse_handle = 'homeboy@idempotent-reuse'; + $reuse_created_at = $wpdb->rows[$reuse_handle]['created_at'] ?? null; + $live_reuse_refusal = $workspace->worktree_add('homeboy', 'idempotent-reuse', 'origin/main', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/reuse' )); + assert_true(is_wp_error($live_reuse_refusal) && 'worktree_reuse_refused' === $live_reuse_refusal->get_error_code(), 'live worktree reuse did not fail closed'); + assert_true('live_worktree' === ( $live_reuse_refusal->get_error_data()['reuse']['reason_code'] ?? null ), 'live worktree refusal lacked typed reuse evidence'); + \DataMachineCode\Workspace\WorktreeContextInjector::store_lifecycle_metadata($reuse_handle, array( 'last_seen_at' => gmdate('c', time() - 90000) )); + $reused = $workspace->worktree_add('homeboy', 'idempotent-reuse', 'origin/main', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/reuse' )); + assert_true(! is_wp_error($reused), is_wp_error($reused) ? $reused->get_error_message() : 'clean compatible worktree was not reused'); + assert_true(true === ( $reused['reused'] ?? false ) && 'exact_compatible_handle' === ( $reused['reuse']['reason_code'] ?? null ), 'exact reuse did not return accepted evidence'); + assert_true($reuse_created_at === ( $wpdb->rows[$reuse_handle]['created_at'] ?? null ), 'reuse rewrote durable lifecycle metadata'); + assert_true('https://example.test/issues/reuse' === ( $wpdb->rows[$reuse_handle]['task_url'] ?? '' ), 'reuse rewrote durable task metadata'); + file_put_contents($reusable['path'] . '/reuse-dirty.txt', "dirty\n"); + $dirty_reuse_refusal = $workspace->worktree_add('homeboy', 'idempotent-reuse', 'origin/main', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/reuse' )); + assert_true(is_wp_error($dirty_reuse_refusal) && 'dirty_worktree' === ( $dirty_reuse_refusal->get_error_data()['reuse']['reason_code'] ?? null ), 'dirty worktree reuse did not fail closed'); + unlink($reusable['path'] . '/reuse-dirty.txt'); + $runtime_reuse_refusal = $workspace->worktree_add('homeboy', 'idempotent-reuse', 'origin/main', true, false, false, false, true, array( 'task_url' => 'https://example.test/issues/reuse' )); + assert_true(is_wp_error($runtime_reuse_refusal) && 'runtime_incompatible' === ( $runtime_reuse_refusal->get_error_data()['reuse']['reason_code'] ?? null ), 'incompatible context runtime reuse did not fail closed'); + $base_reuse_refusal = $workspace->worktree_add('homeboy', 'idempotent-reuse', 'origin/other-base', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/reuse' )); + assert_true(is_wp_error($base_reuse_refusal) && 'base_mismatch' === ( $base_reuse_refusal->get_error_data()['reuse']['reason_code'] ?? null ), 'mismatched base reuse did not fail closed'); + run_command('git push -u origin idempotent-reuse', $reusable['path']); + file_put_contents($reusable['path'] . '/reuse-commit.txt', "unpushed\n"); + run_command('git add reuse-commit.txt && git commit -m reuse-unpushed', $reusable['path']); + $unpushed_reuse_refusal = $workspace->worktree_add('homeboy', 'idempotent-reuse', 'origin/main', false, false, false, false, true, array( 'task_url' => 'https://example.test/issues/reuse' )); + assert_true(is_wp_error($unpushed_reuse_refusal) && 'unpushed_commits' === ( $unpushed_reuse_refusal->get_error_data()['reuse']['reason_code'] ?? null ), 'unpushed worktree reuse did not fail closed'); + $handle = 'homeboy@audit-primitives-20260616'; file_put_contents($result['path'] . '/untracked.txt', "untracked\n");