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
97 changes: 95 additions & 2 deletions inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -294,6 +294,7 @@ static function ( $row ): string {
$wt_handle,
$wt_path,
$primary_path,
$bootstrap,
$task,
$allow_unverified_freshness,
array(
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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 );
Expand Down Expand Up @@ -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.
*
Expand Down
27 changes: 27 additions & 0 deletions tests/worktree-add-lifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down