From 76472fe9aec2d2906dc2aeaa01b3a55acef7f2f2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 30 Aug 2026 13:14:26 -0400 Subject: [PATCH 1/3] feat: refresh primaries without WordPress --- bin/dmc-worktree-provider | 8 +- inc/Workspace/StandalonePrimaryRefresher.php | 744 ++++++++++++++++++ inc/Workspace/StandaloneWorktreeProvider.php | 34 +- tests/standalone-primary-refresh.php | 215 +++++ .../standalone-worktree-provider-command.php | 5 + tests/standalone-worktree-provider.php | 4 +- 6 files changed, 1004 insertions(+), 6 deletions(-) create mode 100644 inc/Workspace/StandalonePrimaryRefresher.php create mode 100644 tests/standalone-primary-refresh.php diff --git a/bin/dmc-worktree-provider b/bin/dmc-worktree-provider index 7022eb6d..cd69ab01 100755 --- a/bin/dmc-worktree-provider +++ b/bin/dmc-worktree-provider @@ -13,8 +13,8 @@ $workspace = (string) ( $argv[2] ?? '' ); $value = (string) ( $argv[3] ?? '' ); $base_sha = (string) ( $argv[4] ?? '' ); -if ( ! in_array($operation, array( 'capabilities', 'identity', 'task', 'safety', 'converge', 'plan' ), true) || ( 'capabilities' !== $operation && ( '' === $workspace || '' === $value ) ) || ( 'converge' === $operation && '' === $base_sha ) ) { - fwrite(STDERR, "Usage: dmc-worktree-provider capabilities | [base-sha]\n"); +if ( ! in_array($operation, array( 'capabilities', 'identity', 'task', 'safety', 'converge', 'plan', 'primary-refresh' ), true) || ( 'capabilities' !== $operation && ( '' === $workspace || '' === $value ) ) || ( 'converge' === $operation && '' === $base_sha ) || ( 'primary-refresh' === $operation && '' !== $base_sha ) ) { + fwrite(STDERR, "Usage: dmc-worktree-provider capabilities | [base-sha]\n"); exit(2); } @@ -33,8 +33,10 @@ if ( 'plan' === $operation ) { 'task' => $provider->resolve_task($workspace, $value), 'safety' => $provider->attest_safety($workspace, $value), 'converge' => $provider->converge($workspace, $value, $base_sha), + 'primary-refresh' => $provider->refresh_primary($workspace, $value), }; } fwrite(STDOUT, json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"); -exit('error' === ( $result['status'] ?? '' ) ? 1 : 0); +$failed = 'error' === ( $result['status'] ?? '' ) || ( 'primary-refresh' === $operation && 'refused' === ( $result['status'] ?? '' ) ); +exit($failed ? 1 : 0); diff --git a/inc/Workspace/StandalonePrimaryRefresher.php b/inc/Workspace/StandalonePrimaryRefresher.php new file mode 100644 index 00000000..0515397d --- /dev/null +++ b/inc/Workspace/StandalonePrimaryRefresher.php @@ -0,0 +1,744 @@ + */ + public function refresh( string $workspace, string $repo, string $remote = 'origin' ): array { + $started = microtime(true); + $workspace_real = realpath($workspace); + $repo = trim($repo); + $remote = trim($remote); + $context = $this->context($repo, '', $remote); + + if ( false === $workspace_real || ! is_dir($workspace_real) ) { + return $this->result('refused', 'workspace_not_found', $context, $started); + } + $parsed = WorkspaceHandle::parse($repo)->to_array(); + if ( '' === $repo || $repo !== $parsed['dir_name'] || $parsed['is_worktree'] ) { + return $this->result('refused', 'invalid_primary_handle', $context, $started); + } + if ( 1 !== preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*$/D', $remote) ) { + return $this->result('refused', 'invalid_remote', $context, $started); + } + if ( 'origin' !== $remote ) { + return $this->result('refused', 'unsupported_freshness_remote', $context, $started); + } + + $path = $workspace_real . DIRECTORY_SEPARATOR . $repo; + $real = realpath($path); + $context['path'] = false === $real ? $path : $real; + if ( false === $real || dirname($real) !== $workspace_real || basename($real) !== $repo || ! is_dir($real . '/.git') ) { + return $this->result('refused', 'authoritative_primary_not_found', $context, $started); + } + $top = $this->git($real, array( 'rev-parse', '--show-toplevel' )); + if ( ! $top['success'] || realpath(trim($top['stdout'])) !== $real ) { + return $this->result('refused', 'authoritative_primary_invalid', $context, $started); + } + if ( ! $this->git($real, array( 'remote', 'get-url', $remote ))['success'] ) { + return $this->result('refused', 'remote_not_configured', $context, $started); + } + + $lock = $this->acquire_lock($workspace_real, $repo); + if ( null === $lock ) { + return $this->result('refused', 'primary_refresh_lock_unavailable', $context, $started); + } + + try { + return $this->refresh_locked($workspace_real, $real, $repo, $remote, $context, $started); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + /** + * @param array $context + * @return array + */ + private function refresh_locked( string $workspace, string $path, string $repo, string $remote, array $context, float $started ): array { + $top = $this->git($path, array( 'rev-parse', '--show-toplevel' )); + if ( ! $top['success'] || realpath(trim($top['stdout'])) !== $path || ! is_dir($path . '/.git') ) { + return $this->result('refused', 'primary_identity_changed', $context, $started); + } + + $status = $this->git($path, array( 'status', '--porcelain=v1', '--untracked-files=normal' )); + if ( ! $status['success'] ) { + return $this->result('error', $status['timed_out'] ? 'primary_status_timeout' : 'primary_status_failed', $context, $started); + } + if ( '' !== trim($status['stdout']) ) { + return $this->result('refused', 'dirty_working_tree', $context, $started); + } + + $before = $this->head($path); + if ( null === $before ) { + return $this->result('error', 'primary_head_unknown', $context, $started); + } + $context['old_sha'] = $before; + $context['new_sha'] = $before; + $branch = $this->branch($path); + + if ( null === $branch ) { + $repair = $this->repair_detached($path, $repo, $remote); + if ( ! $repair['success'] ) { + $context['fetched'] = $repair['fetched']; + return $this->result('refused', $repair['reason'], $context, $started); + } + $branch = $repair['branch']; + $context['branch'] = $branch; + $context['upstream'] = $remote . '/' . $branch; + $context['detached_repair'] = $repair['receipt']; + $context['fetched'] = $repair['fetched']; + } else { + $context['branch'] = $branch; + $default = $this->verified_default($path, $remote, 'primary'); + if ( ! $default['success'] ) { + return $this->result('refused', $default['reason'], $context, $started); + } + $context['fetched'] = $default['fetched']; + if ( $branch === $default['branch'] ) { + $counts = $this->ahead_behind($path, $before, $default['sha']); + if ( null === $counts ) { + return $this->result('error', 'primary_divergence_unverified', $context, $started); + } + if ( 0 < $counts['ahead'] && 0 < $counts['behind'] ) { + $recovery = $this->recover_divergence($workspace, $path, $repo, $remote, $branch, $before, $default, $counts); + $context['recovery'] = $recovery['recovery']; + $context['fetched'] = $recovery['fetched']; + if ( ! $recovery['success'] ) { + return $this->result('refused', $recovery['reason'], $context, $started); + } + $context['new_sha'] = $recovery['sha']; + } + } + } + + $upstream = $this->ensure_upstream($path, $remote, $branch); + if ( ! $upstream['success'] ) { + return $this->result('refused', $upstream['reason'], $context, $started); + } + $context['upstream'] = $upstream['upstream']; + + $fetch = $this->git($path, array( 'fetch', '--no-tags', '--prune', $remote )); + if ( ! $fetch['success'] ) { + return $this->result('refused', $fetch['timed_out'] ? 'primary_refresh_fetch_timeout' : 'primary_refresh_fetch_failed', $context, $started); + } + $context['fetched'] = array_merge($context['fetched'], $this->remote_evidence($path, $remote)); + + $upstream_sha = $this->rev_parse($path, '@{upstream}^{commit}'); + $current = $this->head($path); + if ( null === $upstream_sha || null === $current ) { + return $this->result('refused', 'primary_refresh_upstream_unavailable', $context, $started); + } + $counts = $this->ahead_behind($path, $current, $upstream_sha); + if ( null === $counts ) { + return $this->result('error', 'primary_refresh_state_unverified', $context, $started); + } + $context['fetched']['ahead'] = $counts['ahead']; + $context['fetched']['behind'] = $counts['behind']; + if ( 0 < $counts['ahead'] ) { + $reason = 0 < $counts['behind'] ? 'primary_refresh_diverged' : 'primary_refresh_ahead'; + return $this->result('refused', $reason, $context, $started); + } + + $pull = $this->git($path, array( 'pull', '--ff-only' )); + if ( ! $pull['success'] ) { + return $this->result('error', $pull['timed_out'] ? 'primary_refresh_timeout' : 'primary_refresh_failed', $context, $started); + } + + $after = $this->head($path); + $post_branch = $this->branch($path); + $post_status = $this->git($path, array( 'status', '--porcelain=v1', '--untracked-files=normal' )); + $post_target = $this->rev_parse($path, '@{upstream}^{commit}'); + $context['new_sha'] = $after; + if ( null === $after || $branch !== $post_branch || null === $post_target || $after !== $post_target || ! $post_status['success'] || '' !== trim($post_status['stdout']) ) { + return $this->result('error', 'primary_refresh_postcondition_failed', $context, $started); + } + + $origin = $this->remote_evidence($path, 'origin'); + if ( ! isset($origin['remote_refs_digest'], $origin['ref_heads']) ) { + return $this->result('error', 'freshness_evidence_unavailable', $context, $started); + } + $evidence = array( + 'version' => 2, + 'remote_refs_digest' => $origin['remote_refs_digest'], + 'ref_heads' => $origin['ref_heads'], + 'observed_at' => gmdate('c'), + ); + if ( ! WorktreeFreshnessEvidence::store($path, $evidence) ) { + return $this->result('error', 'freshness_evidence_store_failed', $context, $started); + } + $context['freshness_evidence'] = WorktreeFreshnessEvidence::read($path); + $context['changed'] = $before !== $after; + + return $this->result($context['changed'] ? 'refreshed' : 'current', null, $context, $started); + } + + /** @return array{success:bool,reason:?string,branch:string,fetched:array,receipt:?array} */ + private function repair_detached( string $path, string $repo, string $remote ): array { + $before = $this->head($path); + $default = $this->detached_default($path, $remote); + if ( null === $before ) { + return $this->repair_failure('detached_primary_head_unknown'); + } + if ( ! $default['success'] ) { + return $this->repair_failure($default['reason'], $default['fetched']); + } + + $fetch = $this->fetch_ref($path, $remote, $default['branch']); + if ( ! $fetch['success'] ) { + return $this->repair_failure($fetch['timed_out'] ? 'detached_primary_fetch_timeout' : 'detached_primary_fetch_failed', $default['fetched']); + } + $target = $this->rev_parse($path, $default['remote_ref'] . '^{commit}'); + if ( null === $target ) { + return $this->repair_failure('detached_primary_default_ref_missing', $default['fetched']); + } + $fetched = array_merge($default['fetched'], array( + 'default_branch' => $default['branch'], + 'default_ref' => $default['remote_ref'], + 'default_sha' => $target, + )); + + $preservation = null; + if ( ! $this->is_ancestor($path, $before, $target) ) { + $preserved = $this->find_detached_preservation($path, $remote, $before); + if ( ! $preserved['success'] ) { + return $this->repair_failure($preserved['reason'], $fetched); + } + $preservation = $preserved['preservation']; + if ( null === $preservation ) { + return $this->repair_failure('detached_primary_diverged', $fetched); + } + } + + $local_ref = 'refs/heads/' . $default['branch']; + $local = $this->rev_parse($path, $local_ref . '^{commit}'); + $repointed = false; + if ( null !== $local && $before !== $local ) { + if ( ! $this->is_ancestor($path, $local, $target) ) { + return $this->repair_failure('detached_primary_local_branch_diverged', $fetched); + } + $holder = $this->branch_holder($path, $default['branch']); + if ( null !== $holder ) { + $fetched['holder'] = $holder; + return $this->repair_failure('detached_primary_default_branch_held', $fetched); + } + $update = $this->git($path, array( 'update-ref', $local_ref, $target, $local )); + if ( ! $update['success'] ) { + return $this->repair_failure('detached_primary_local_branch_repoint_failed', $fetched); + } + $repointed = true; + } + + $checkout = null === $local + ? $this->git($path, array( 'checkout', '--track', '-b', $default['branch'], $remote . '/' . $default['branch'] )) + : $this->git($path, array( 'checkout', $default['branch'] )); + if ( ! $checkout['success'] ) { + return $this->repair_failure('detached_primary_checkout_failed', $fetched); + } + + return array( + 'success' => true, + 'reason' => null, + 'branch' => $default['branch'], + 'fetched' => $fetched, + 'receipt' => array( + 'head_before' => $before, + 'head_after' => $this->head($path), + 'branch' => $default['branch'], + 'upstream' => $remote . '/' . $default['branch'], + 'branch_repointed' => $repointed, + 'default_branch_sources' => $default['sources'], + 'preservation' => $preservation, + ), + ); + } + + /** @return array{success:bool,reason:?string,branch:string,remote_ref:string,sources:array>,fetched:array} */ + private function detached_default( string $path, string $remote ): array { + $sources = array(); + $prefix = 'refs/remotes/' . $remote . '/'; + $remote_ref = $this->git($path, array( 'symbolic-ref', '--quiet', 'refs/remotes/' . $remote . '/HEAD' )); + $ref = $remote_ref['success'] ? trim($remote_ref['stdout']) : ''; + if ( '' !== $ref && str_starts_with($ref, $prefix) ) { + $branch = substr($ref, strlen($prefix)); + if ( '' !== $branch && $this->valid_branch($path, $branch) && null !== $this->rev_parse($path, $ref . '^{commit}') ) { + $sources[] = array( 'source' => 'local_symbolic_ref', 'status' => 'validated' ); + return array( 'success' => true, 'reason' => null, 'branch' => $branch, 'remote_ref' => $ref, 'sources' => $sources, 'fetched' => array() ); + } + $sources[] = array( 'source' => 'local_symbolic_ref', 'status' => 'stale' ); + } else { + $sources[] = array( 'source' => 'local_symbolic_ref', 'status' => '' === $ref ? 'unavailable' : 'malformed' ); + } + + $live = $this->git($path, array( 'ls-remote', '--symref', $remote, 'HEAD' )); + if ( ! $live['success'] ) { + $sources[] = array( 'source' => 'remote_symref', 'status' => 'unavailable' ); + return array( 'success' => false, 'reason' => 'detached_primary_default_branch_unavailable', 'branch' => '', 'remote_ref' => '', 'sources' => $sources, 'fetched' => array( 'default_branch_sources' => $sources ) ); + } + if ( 1 !== preg_match('/^ref: refs\/heads\/([^\s]+)\s+HEAD$/m', $live['stdout'], $matches) || ! $this->valid_branch($path, $matches[1]) ) { + $sources[] = array( 'source' => 'remote_symref', 'status' => 'ambiguous' ); + return array( 'success' => false, 'reason' => 'detached_primary_default_branch_ambiguous', 'branch' => '', 'remote_ref' => '', 'sources' => $sources, 'fetched' => array( 'default_branch_sources' => $sources ) ); + } + $sources[] = array( 'source' => 'remote_symref', 'status' => 'validated' ); + return array( + 'success' => true, + 'reason' => null, + 'branch' => $matches[1], + 'remote_ref' => $prefix . $matches[1], + 'sources' => $sources, + 'fetched' => array(), + ); + } + + /** @return array{success:bool,reason:?string,branch:string,sha:string,fetched:array} */ + private function verified_default( string $path, string $remote, string $prefix ): array { + $live = $this->git($path, array( 'ls-remote', '--symref', $remote, 'HEAD' )); + if ( ! $live['success'] ) { + return array( 'success' => false, 'reason' => $prefix . '_default_branch_unavailable', 'branch' => '', 'sha' => '', 'fetched' => array() ); + } + if ( 1 !== preg_match('/^ref: refs\/heads\/([^\s]+)\s+HEAD$/m', $live['stdout'], $matches) || ! $this->valid_branch($path, $matches[1]) ) { + return array( 'success' => false, 'reason' => $prefix . '_default_branch_ambiguous', 'branch' => '', 'sha' => '', 'fetched' => array() ); + } + $branch = $matches[1]; + $fetch = $this->fetch_ref($path, $remote, $branch); + if ( ! $fetch['success'] ) { + return array( 'success' => false, 'reason' => $prefix . '_fetch_failed', 'branch' => $branch, 'sha' => '', 'fetched' => array( 'default_branch' => $branch ) ); + } + $ref = 'refs/remotes/' . $remote . '/' . $branch; + $sha = $this->rev_parse($path, $ref . '^{commit}'); + if ( null === $sha ) { + return array( 'success' => false, 'reason' => $prefix . '_default_ref_missing', 'branch' => $branch, 'sha' => '', 'fetched' => array( 'default_branch' => $branch, 'default_ref' => $ref ) ); + } + return array( + 'success' => true, + 'reason' => null, + 'branch' => $branch, + 'sha' => $sha, + 'fetched' => array( 'default_branch' => $branch, 'default_ref' => $ref, 'default_sha' => $sha ), + ); + } + + /** + * @param array $default + * @param array{ahead:int,behind:int} $counts + * @return array{success:bool,reason:?string,sha:string,recovery:?array,fetched:array} + */ + private function recover_divergence( string $workspace, string $path, string $repo, string $remote, string $branch, string $local_sha, array $default, array $counts ): array { + $holder = $this->branch_holder($path, $branch); + if ( null !== $holder ) { + return array( 'success' => false, 'reason' => 'primary_divergence_default_branch_held', 'sha' => $local_sha, 'recovery' => array( 'holder' => $holder ), 'fetched' => $default['fetched'] ); + } + $preserved = $this->preserve_divergence($workspace, $path, $repo, $branch, $local_sha); + if ( ! $preserved['success'] ) { + return array( 'success' => false, 'reason' => $preserved['reason'], 'sha' => $local_sha, 'recovery' => $preserved['recovery'], 'fetched' => $default['fetched'] ); + } + + $recovery = array( + 'branch' => $branch, + 'local_sha' => $local_sha, + 'remote_sha' => $default['sha'], + 'ahead' => $counts['ahead'], + 'behind' => $counts['behind'], + 'preservation' => $preserved['recovery'], + ); + $verified = $this->verified_default($path, $remote, 'primary_divergence'); + if ( ! $verified['success'] ) { + return array( 'success' => false, 'reason' => 'primary_divergence_verification_failed', 'sha' => $local_sha, 'recovery' => $recovery, 'fetched' => $verified['fetched'] ); + } + $status = $this->git($path, array( 'status', '--porcelain=v1', '--untracked-files=normal' )); + if ( $verified['branch'] !== $branch || $verified['sha'] !== $default['sha'] || $this->head($path) !== $local_sha || ! $status['success'] || '' !== trim($status['stdout']) ) { + return array( 'success' => false, 'reason' => 'primary_divergence_changed_during_recovery', 'sha' => $local_sha, 'recovery' => $recovery, 'fetched' => $verified['fetched'] ); + } + + $update = $this->git($path, array( 'update-ref', 'refs/heads/' . $branch, $verified['sha'], $local_sha )); + if ( ! $update['success'] ) { + return array( 'success' => false, 'reason' => 'primary_divergence_changed_during_recovery', 'sha' => $local_sha, 'recovery' => $recovery, 'fetched' => $verified['fetched'] ); + } + $reset = $this->git($path, array( 'reset', '--hard', $verified['sha'] )); + if ( ! $reset['success'] ) { + return array( 'success' => false, 'reason' => 'primary_divergence_refresh_failed', 'sha' => $this->head($path) ?? $local_sha, 'recovery' => $recovery, 'fetched' => $verified['fetched'] ); + } + + return array( 'success' => true, 'reason' => null, 'sha' => $verified['sha'], 'recovery' => $recovery, 'fetched' => $verified['fetched'] ); + } + + /** @return array{success:bool,reason:?string,recovery:?array} */ + private function preserve_divergence( string $workspace, string $primary, string $repo, string $default_branch, string $sha ): array { + $slug = 'primary-recovery-' . substr($sha, 0, 12); + $branch = 'recovery/' . $slug; + $ref = 'refs/heads/' . $branch; + $handle = $repo . '@' . $slug; + $path = $workspace . '/' . $handle; + $existing = $this->rev_parse($primary, $ref . '^{commit}'); + $created = false; + $partial = array( 'branch' => $branch, 'ref' => $ref, 'commit' => $sha, 'handle' => $handle, 'path' => $path ); + + if ( null !== $existing && $existing !== $sha ) { + return array( 'success' => false, 'reason' => 'primary_divergence_recovery_branch_conflict', 'recovery' => $partial ); + } + if ( null === $existing ) { + $create = $this->git($primary, array( 'branch', $branch, $sha )); + if ( ! $create['success'] ) { + return array( 'success' => false, 'reason' => 'primary_divergence_unpreservable', 'recovery' => $partial ); + } + $created = true; + } + + $listed = $this->recovery_worktree($primary, $path, $ref); + if ( 'conflict' === $listed ) { + if ( $created ) { + $this->git($primary, array( 'branch', '-D', $branch )); + } + $partial['rolled_back'] = $created; + return array( 'success' => false, 'reason' => 'primary_divergence_recovery_path_conflict', 'recovery' => $partial ); + } + if ( 'missing' === $listed && is_dir($path) ) { + if ( $created ) { + $this->git($primary, array( 'branch', '-D', $branch )); + } + $partial['rolled_back'] = $created; + return array( 'success' => false, 'reason' => 'primary_divergence_recovery_path_conflict', 'recovery' => $partial ); + } + if ( 'missing' === $listed ) { + $add = $this->git($primary, array( 'worktree', 'add', $path, $branch )); + if ( ! $add['success'] ) { + if ( $created ) { + $this->git($primary, array( 'branch', '-D', $branch )); + } + $partial['rolled_back'] = $created; + return array( 'success' => false, 'reason' => 'primary_divergence_unpreservable', 'recovery' => $partial ); + } + $listed = $this->recovery_worktree($primary, $path, $ref); + } + if ( 'expected' !== $listed || $this->head($path) !== $sha ) { + return array( 'success' => false, 'reason' => 'primary_divergence_recovery_unverified', 'recovery' => $partial ); + } + $metadata = $this->store_recovery_metadata($path, array( + 'repo' => $repo, + 'handle' => $handle, + 'path' => $path, + 'branch' => $branch, + 'base_ref' => $default_branch, + 'commit' => $sha, + 'state' => 'preserved', + 'cleanup_policy' => 'manual', + )); + if ( null === $metadata ) { + return array( 'success' => false, 'reason' => 'primary_divergence_recovery_metadata_failed', 'recovery' => $partial ); + } + + $partial['status'] = 'preserved'; + $partial['reused'] = ! $created; + $partial['metadata'] = $metadata; + return array( 'success' => true, 'reason' => null, 'recovery' => $partial ); + } + + /** @param array $metadata @return array|null */ + private function store_recovery_metadata( string $path, array $metadata ): ?array { + $git_dir = $this->git($path, array( 'rev-parse', '--absolute-git-dir' )); + $git_dir = $git_dir['success'] ? realpath(trim($git_dir['stdout'])) : false; + if ( false === $git_dir || ! is_dir($git_dir) ) { + return null; + } + $record = array_merge( + array( + 'schema' => 'datamachine-code/primary-recovery/v1', + 'observed_at' => gmdate('c'), + ), + $metadata + ); + $encoded = json_encode($record, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ( ! is_string($encoded) ) { + return null; + } + $target = $git_dir . '/datamachine-code-primary-recovery.json'; + $temp = $target . '.tmp-' . getmypid() . '-' . bin2hex(random_bytes(4)); + if ( false === @file_put_contents($temp, $encoded, LOCK_EX) || ! @rename($temp, $target) ) { + @unlink($temp); + return null; + } + return $record; + } + + private function recovery_worktree( string $primary, string $expected_path, string $expected_ref ): string { + $listing = $this->git($primary, array( 'worktree', 'list', '--porcelain' )); + if ( ! $listing['success'] ) { + return 'conflict'; + } + foreach ( preg_split('/\n\n+/', trim($listing['stdout'])) ?: array() as $block ) { + $path = null; + $ref = null; + foreach ( explode("\n", $block) as $line ) { + if ( str_starts_with($line, 'worktree ') ) { + $path = substr($line, 9); + } elseif ( str_starts_with($line, 'branch ') ) { + $ref = substr($line, 7); + } + } + if ( $path === $expected_path ) { + return $ref === $expected_ref ? 'expected' : 'conflict'; + } + if ( $ref === $expected_ref ) { + return 'conflict'; + } + } + return 'missing'; + } + + /** @return array{success:bool,reason:?string,preservation:?array} */ + private function find_detached_preservation( string $path, string $remote, string $head ): array { + $fetch = $this->git($path, array( 'fetch', '--no-tags', '--prune', $remote )); + if ( ! $fetch['success'] ) { + return array( 'success' => false, 'reason' => 'detached_primary_preservation_fetch_failed', 'preservation' => null ); + } + $refs = $this->git($path, array( 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/remotes/' . $remote )); + if ( ! $refs['success'] ) { + return array( 'success' => false, 'reason' => 'detached_primary_preservation_refs_unavailable', 'preservation' => null ); + } + foreach ( explode("\n", trim($refs['stdout'])) as $line ) { + if ( 1 !== preg_match('#^(refs/remotes/' . preg_quote($remote, '#') . '/(.+)) ([a-f0-9]{40,64})$#i', $line, $matches) || str_ends_with($matches[1], '/HEAD') || ! $this->is_ancestor($path, $head, $matches[1]) ) { + continue; + } + $live = $this->git($path, array( 'ls-remote', '--heads', $remote, $matches[2] )); + if ( ! $live['success'] || 1 !== preg_match('/^' . preg_quote($matches[3], '/') . '\s+refs\/heads\/' . preg_quote($matches[2], '/') . '$/mi', $live['stdout']) ) { + return array( 'success' => false, 'reason' => 'detached_primary_preservation_ref_changed', 'preservation' => null ); + } + return array( 'success' => true, 'reason' => null, 'preservation' => array( 'remote' => $remote, 'ref' => 'refs/heads/' . $matches[2], 'commit' => $head ) ); + } + + $tags = $this->git($path, array( 'ls-remote', '--tags', $remote )); + if ( ! $tags['success'] ) { + return array( 'success' => false, 'reason' => 'detached_primary_preservation_tags_unavailable', 'preservation' => null ); + } + foreach ( explode("\n", trim($tags['stdout'])) as $line ) { + if ( 1 !== preg_match('/^([a-f0-9]{40,64})\s+(refs\/tags\/.+)$/i', $line, $matches) || str_ends_with($matches[2], '^{}') ) { + continue; + } + $fetched = $this->git($path, array( 'fetch', '--no-tags', $remote, $matches[2] )); + $commit = $fetched['success'] ? $this->rev_parse($path, 'FETCH_HEAD^{commit}') : null; + if ( $commit !== $head ) { + continue; + } + $live = $this->git($path, array( 'ls-remote', '--tags', $remote, $matches[2] )); + if ( ! $live['success'] || ! str_contains($live['stdout'], $matches[2]) ) { + return array( 'success' => false, 'reason' => 'detached_primary_preservation_ref_changed', 'preservation' => null ); + } + return array( 'success' => true, 'reason' => null, 'preservation' => array( 'remote' => $remote, 'ref' => $matches[2], 'commit' => $head ) ); + } + return array( 'success' => true, 'reason' => null, 'preservation' => null ); + } + + /** @return array{success:bool,reason:?string,upstream:string} */ + private function ensure_upstream( string $path, string $remote, string $branch ): array { + $upstream = $this->git($path, array( 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}' )); + if ( $upstream['success'] && '' !== trim($upstream['stdout']) ) { + return array( 'success' => true, 'reason' => null, 'upstream' => trim($upstream['stdout']) ); + } + $live = $this->git($path, array( 'ls-remote', '--heads', $remote, $branch )); + if ( ! $live['success'] || 1 !== preg_match('/^[a-f0-9]{40,64}\s+refs\/heads\/' . preg_quote($branch, '/') . '$/mi', $live['stdout']) ) { + return array( 'success' => false, 'reason' => 'primary_refresh_upstream_missing', 'upstream' => '' ); + } + $fetch = $this->fetch_ref($path, $remote, $branch); + $set = $fetch['success'] ? $this->git($path, array( 'branch', '--set-upstream-to=' . $remote . '/' . $branch, $branch )) : $fetch; + return $set['success'] + ? array( 'success' => true, 'reason' => null, 'upstream' => $remote . '/' . $branch ) + : array( 'success' => false, 'reason' => 'primary_refresh_upstream_setup_failed', 'upstream' => '' ); + } + + /** @return array|null */ + private function branch_holder( string $primary, string $branch ): ?array { + $listing = $this->git($primary, array( 'worktree', 'list', '--porcelain' )); + if ( ! $listing['success'] ) { + return array( 'path' => null, 'branch' => $branch, 'unverified' => true ); + } + foreach ( preg_split('/\n\n+/', trim($listing['stdout'])) ?: array() as $block ) { + $path = null; + $ref = null; + foreach ( explode("\n", $block) as $line ) { + if ( str_starts_with($line, 'worktree ') ) { + $path = substr($line, 9); + } elseif ( str_starts_with($line, 'branch ') ) { + $ref = substr($line, 7); + } + } + if ( null !== $path && $path !== $primary && $ref === 'refs/heads/' . $branch ) { + return array( 'path' => $path, 'handle' => basename($path), 'branch' => $branch ); + } + } + return null; + } + + /** @return array{ahead:int,behind:int}|null */ + private function ahead_behind( string $path, string $left, string $right ): ?array { + $result = $this->git($path, array( 'rev-list', '--left-right', '--count', $left . '...' . $right )); + if ( ! $result['success'] || 1 !== preg_match('/^(\d+)\s+(\d+)$/D', trim($result['stdout']), $matches) ) { + return null; + } + return array( 'ahead' => (int) $matches[1], 'behind' => (int) $matches[2] ); + } + + /** @return array */ + private function remote_evidence( string $path, string $remote ): array { + $refs = $this->git($path, array( 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/remotes/' . $remote )); + if ( ! $refs['success'] ) { + return array(); + } + $heads = array(); + foreach ( explode("\n", $refs['stdout']) as $line ) { + $parts = preg_split('/\s+/', trim($line), 2); + if ( 2 === count($parts) && preg_match('/^[0-9a-f]{40,64}$/i', $parts[1]) ) { + $heads[ $parts[0] ] = $parts[1]; + } + } + return array( 'remote_refs_digest' => hash('sha256', rtrim($refs['stdout'], "\r\n")), 'ref_heads' => $heads ); + } + + /** @return array{success:bool,stdout:string,stderr:string,timed_out:bool,exit_code:int} */ + private function fetch_ref( string $path, string $remote, string $branch ): array { + return $this->git($path, array( 'fetch', '--no-tags', $remote, 'refs/heads/' . $branch . ':refs/remotes/' . $remote . '/' . $branch )); + } + + private function valid_branch( string $path, string $branch ): bool { + return $this->git($path, array( 'check-ref-format', 'refs/heads/' . $branch ))['success']; + } + + private function is_ancestor( string $path, string $ancestor, string $descendant ): bool { + return $this->git($path, array( 'merge-base', '--is-ancestor', $ancestor, $descendant ))['success']; + } + + private function branch( string $path ): ?string { + $result = $this->git($path, array( 'symbolic-ref', '--quiet', '--short', 'HEAD' )); + $branch = trim($result['stdout']); + return $result['success'] && '' !== $branch ? $branch : null; + } + + private function head( string $path ): ?string { + return $this->rev_parse($path, 'HEAD^{commit}'); + } + + private function rev_parse( string $path, string $ref ): ?string { + $result = $this->git($path, array( 'rev-parse', '--verify', $ref )); + $sha = trim($result['stdout']); + return $result['success'] && 1 === preg_match('/^[0-9a-f]{40,64}$/D', $sha) ? $sha : null; + } + + /** @return resource|null */ + private function acquire_lock( string $workspace, string $repo ) { + $directory = $workspace . '/.locks'; + if ( is_link($directory) || ( ! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory) ) ) { + return null; + } + $handle = @fopen($directory . '/worktree-' . $repo . '.lock', 'c'); + if ( false === $handle ) { + return null; + } + $started = microtime(true); + do { + if ( flock($handle, LOCK_EX | LOCK_NB) ) { + return $handle; + } + usleep(100000); + } while ( microtime(true) - $started < self::LOCK_TIMEOUT ); + fclose($handle); + return null; + } + + /** @return array{success:bool,stdout:string,stderr:string,timed_out:bool,exit_code:int} */ + private function git( string $path, array $arguments ): array { + $command = array_merge(array( 'git', '--no-optional-locks', '-C', $path ), $arguments); + $env = getenv(); + $env = is_array($env) ? $env : array(); + $env['GIT_TERMINAL_PROMPT'] = '0'; + $process = proc_open($command, array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes, null, $env); + if ( ! is_resource($process) ) { + return array( 'success' => false, 'stdout' => '', 'stderr' => 'Could not start Git.', 'timed_out' => false, 'exit_code' => -1 ); + } + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $started = microtime(true); + $stdout = ''; + $stderr = ''; + $exit = -1; + $timeout = false; + while ( true ) { + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + $status = proc_get_status($process); + if ( ! $status['running'] ) { + $exit = (int) $status['exitcode']; + break; + } + if ( microtime(true) - $started >= self::COMMAND_TIMEOUT ) { + $timeout = true; + proc_terminate($process, 15); + usleep(50000); + $status = proc_get_status($process); + if ( $status['running'] ) { + proc_terminate($process, 9); + } + break; + } + usleep(10000); + } + $stdout .= stream_get_contents($pipes[1]); + $stderr .= stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + return array( 'success' => ! $timeout && 0 === $exit, 'stdout' => $stdout, 'stderr' => $stderr, 'timed_out' => $timeout, 'exit_code' => $exit ); + } + + /** @return array */ + private function context( string $repo, string $path, string $remote ): array { + return array( + 'repo' => $repo, + 'path' => $path, + 'remote' => $remote, + 'old_sha' => null, + 'new_sha' => null, + 'branch' => null, + 'upstream' => null, + 'changed' => false, + 'fetched' => array(), + 'recovery' => null, + 'detached_repair' => null, + 'freshness_evidence' => null, + ); + } + + /** @param array $context @return array */ + private function result( string $status, ?string $reason, array $context, float $started ): array { + return array_merge( + array( + 'schema' => self::SCHEMA, + 'status' => $status, + 'reason' => $reason, + 'latency_ms' => (int) round(( microtime(true) - $started ) * 1000), + ), + $context + ); + } + + /** @param array $fetched @return array{success:bool,reason:string,branch:string,fetched:array,receipt:null} */ + private function repair_failure( string $reason, array $fetched = array() ): array { + return array( 'success' => false, 'reason' => $reason, 'branch' => '', 'fetched' => $fetched, 'receipt' => null ); + } +} diff --git a/inc/Workspace/StandaloneWorktreeProvider.php b/inc/Workspace/StandaloneWorktreeProvider.php index 728be020..caa68623 100644 --- a/inc/Workspace/StandaloneWorktreeProvider.php +++ b/inc/Workspace/StandaloneWorktreeProvider.php @@ -27,6 +27,9 @@ if ( ! class_exists(WorktreeDiskBudget::class) ) { require_once __DIR__ . '/WorktreeDiskBudget.php'; } +if ( ! class_exists(StandalonePrimaryRefresher::class) ) { + require_once __DIR__ . '/StandalonePrimaryRefresher.php'; +} final class StandaloneWorktreeProvider { @@ -45,7 +48,7 @@ final class StandaloneWorktreeProvider { public function capabilities(): array { return array( 'schema' => self::CAPABILITIES_SCHEMA, - 'operations' => array( 'capabilities', 'identity', 'task', 'safety', 'converge', 'plan' ), + 'operations' => array( 'capabilities', 'identity', 'task', 'safety', 'converge', 'plan', 'primary-refresh' ), 'identity_schema' => self::IDENTITY_SCHEMA, 'task_resolution_schema' => self::TASK_SCHEMA, 'plan_schema' => WorktreePlanEnvelope::SCHEMA, @@ -63,9 +66,18 @@ public function capabilities(): array { 'attachment_apply_receipt' => true, 'attachment_standalone' => false, 'authorization_bearing' => false, + 'primary_refresh_schema' => StandalonePrimaryRefresher::SCHEMA, + 'primary_refresh_statuses' => array( 'current', 'refreshed', 'refused', 'error' ), + 'primary_refresh_mutating' => true, + 'primary_refresh_remote' => 'origin', ); } + /** @return array */ + public function refresh_primary( string $workspace, string $repo, string $remote = 'origin' ): array { + return ( new StandalonePrimaryRefresher() )->refresh($workspace, $repo, $remote); + } + /** * Produce a non-mutating, digest-addressed allocation plan without WordPress. * @@ -481,7 +493,8 @@ private function plan_create( $identity = $this->freshness_identity($primary_path, $target_ref, $remote_refs); $evidence = is_string($remote_refs) ? WorktreeFreshnessEvidence::matching($primary_path, $remote_refs) : null; if ( null === $evidence || null === $identity ) { - $refresh_command = sprintf('wp datamachine-code workspace git pull %s --allow-primary-refresh', $repo); + $refresh_action = $this->primary_refresh_action($workspace, $repo); + $refresh_command = $refresh_action['command']; return array_merge( $this->error( 'freshness_refresh_required', @@ -490,6 +503,7 @@ private function plan_create( ), array( 'refresh_command' => $refresh_command, + 'refresh_action' => $refresh_action, 'freshness' => array( 'status' => 'refresh_required', 'verified' => false, @@ -546,6 +560,22 @@ private function plan_create( return $plan; } + /** @return array{executable:string,arguments:array,command:string} */ + private function primary_refresh_action( string $workspace, string $repo ): array { + $script = realpath(dirname(__DIR__, 2) . '/bin/dmc-worktree-provider'); + $executable = false === $script ? dirname(__DIR__, 2) . '/bin/dmc-worktree-provider' : $script; + $arguments = array( 'primary-refresh', $workspace, $repo ); + if ( ! is_executable($executable) ) { + array_unshift($arguments, $executable); + $executable = PHP_BINARY; + } + return array( + 'executable' => $executable, + 'arguments' => $arguments, + 'command' => implode(' ', array_map('escapeshellarg', array_merge(array( $executable ), $arguments))), + ); + } + private function default_base( string $primary_path ): string { $result = $this->run_git($primary_path, array( 'symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD' )); $ref = $result['success'] ? trim($result['stdout']) : ''; diff --git a/tests/standalone-primary-refresh.php b/tests/standalone-primary-refresh.php new file mode 100644 index 00000000..3ba54003 --- /dev/null +++ b/tests/standalone-primary-refresh.php @@ -0,0 +1,215 @@ + array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes); + standalone_refresh_assert(is_resource($process), 'Could not start fixture process.'); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return array( 'status' => proc_close($process), 'stdout' => $stdout, 'stderr' => $stderr ); +} + +function standalone_refresh_git( string $path, array $arguments ): string { + $result = standalone_refresh_run(array_merge(array( 'git', '-C', $path ), $arguments)); + standalone_refresh_assert(0 === $result['status'], 'Git fixture command failed: ' . implode(' ', $arguments) . "\n" . $result['stderr']); + return trim($result['stdout']); +} + +/** @return array{workspace:string,primary:string,seed:string,remote:string,branch:string} */ +function standalone_refresh_fixture( string $case, string $branch = 'main' ): array { + $workspace = $case . '/workspace'; + $primary = $workspace . '/fixture'; + $seed = $case . '/seed'; + $remote = $case . '/origin.git'; + mkdir($workspace, 0777, true); + mkdir($seed, 0777, true); + standalone_refresh_assert(0 === standalone_refresh_run(array( 'git', 'init', '--bare', '-b', $branch, $remote ))['status'], 'Could not initialize fixture remote.'); + standalone_refresh_git($seed, array( 'init', '-b', $branch )); + standalone_refresh_git($seed, array( 'config', 'user.name', 'Fixture' )); + standalone_refresh_git($seed, array( 'config', 'user.email', 'fixture@example.test' )); + file_put_contents($seed . '/base.txt', "base\n"); + standalone_refresh_git($seed, array( 'add', 'base.txt' )); + standalone_refresh_git($seed, array( 'commit', '-m', 'base' )); + standalone_refresh_git($seed, array( 'remote', 'add', 'origin', $remote )); + standalone_refresh_git($seed, array( 'push', '-u', 'origin', $branch )); + standalone_refresh_assert(0 === standalone_refresh_run(array( 'git', 'clone', $remote, $primary ))['status'], 'Could not clone fixture primary.'); + standalone_refresh_git($primary, array( 'config', 'user.name', 'Fixture' )); + standalone_refresh_git($primary, array( 'config', 'user.email', 'fixture@example.test' )); + return compact('workspace', 'primary', 'seed', 'remote', 'branch'); +} + +function standalone_refresh_advance( array $fixture, string $name ): string { + file_put_contents($fixture['seed'] . '/' . $name . '.txt', $name . "\n"); + standalone_refresh_git($fixture['seed'], array( 'add', $name . '.txt' )); + standalone_refresh_git($fixture['seed'], array( 'commit', '-m', $name )); + standalone_refresh_git($fixture['seed'], array( 'push', 'origin', $fixture['branch'] )); + return standalone_refresh_git($fixture['seed'], array( 'rev-parse', 'HEAD' )); +} + +/** @return array{process:array{status:int,stdout:string,stderr:string},payload:array} */ +function standalone_refresh_invoke( string $script, string $workspace, string $repo = 'fixture' ): array { + $command = array( PHP_BINARY, $script, 'primary-refresh', $workspace, $repo ); + $process = standalone_refresh_run($command); + standalone_refresh_assert(json_validate($process['stdout']), 'Refresh emitted invalid JSON: ' . $process['stdout'] . $process['stderr']); + return array( 'process' => $process, 'payload' => json_decode($process['stdout'], true, 512, JSON_THROW_ON_ERROR) ); +} + +function standalone_refresh_remove( string $path ): void { + if ( ! is_dir($path) ) { + return; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ( $iterator as $item ) { + $item->isDir() && ! $item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($path); +} + +$root = sys_get_temp_dir() . '/dmc-standalone-refresh-' . bin2hex(random_bytes(6)); +$script = dirname(__DIR__) . '/bin/dmc-worktree-provider'; +mkdir($root, 0777, true); + +try { + $fixture = standalone_refresh_fixture($root . '/refresh-plan'); + $marker = $root . '/wordpress-bootstrap-marker'; + file_put_contents($fixture['workspace'] . '/wp-load.php', ' 'fixture', + 'branch' => 'fix/standalone-plan', + 'from' => 'origin/main', + 'task_ref' => 'generic-task-1314', + ); + $missing = standalone_refresh_run(array( PHP_BINARY, $script, 'plan', $fixture['workspace'], json_encode($intent, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) )); + $missing_payload = json_decode($missing['stdout'], true, 512, JSON_THROW_ON_ERROR); + standalone_refresh_assert(1 === $missing['status'] && 'freshness_refresh_required' === ($missing_payload['code'] ?? null), 'Planning without standalone freshness did not fail closed.'); + standalone_refresh_assert(! str_contains((string) ($missing_payload['refresh_command'] ?? ''), 'wp '), 'Standalone remediation still requires WordPress.'); + standalone_refresh_assert(array( 'primary-refresh', realpath($fixture['workspace']), 'fixture' ) === ($missing_payload['refresh_action']['arguments'] ?? null), 'Standalone remediation arguments are not canonical and executable as returned.'); + standalone_refresh_assert($script === ($missing_payload['refresh_action']['executable'] ?? null), 'Standalone remediation selected the wrong installed executable.'); + + $old_sha = standalone_refresh_git($fixture['primary'], array( 'rev-parse', 'HEAD' )); + $remote_sha = standalone_refresh_advance($fixture, 'remote-behind'); + $refresh = standalone_refresh_invoke($script, $fixture['workspace']); + $receipt = $refresh['payload']; + standalone_refresh_assert(0 === $refresh['process']['status'] && 'refreshed' === ($receipt['status'] ?? null), 'Behind primary did not refresh: ' . $refresh['process']['stderr'] . var_export($receipt, true)); + standalone_refresh_assert('datamachine-code/primary-refresh/v1' === ($receipt['schema'] ?? null), 'Refresh receipt schema mismatch.'); + standalone_refresh_assert($old_sha === ($receipt['old_sha'] ?? null) && $remote_sha === ($receipt['new_sha'] ?? null), 'Refresh receipt omitted old/new commit identity.'); + standalone_refresh_assert('main' === ($receipt['branch'] ?? null) && 'origin/main' === ($receipt['upstream'] ?? null), 'Refresh receipt omitted branch/upstream identity.'); + standalone_refresh_assert($remote_sha === ($receipt['fetched']['default_sha'] ?? null), 'Refresh receipt omitted fetched default-branch evidence.'); + standalone_refresh_assert($remote_sha === ($receipt['freshness_evidence']['ref_heads']['refs/remotes/origin/main'] ?? null), 'Refresh did not persist exact remote-ref freshness evidence.'); + $canonical_refs = standalone_refresh_run(array( 'git', '-C', $fixture['primary'], 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/remotes/origin' )); + standalone_refresh_assert(hash('sha256', rtrim($canonical_refs['stdout'], "\r\n")) === ($receipt['freshness_evidence']['remote_refs_digest'] ?? null), 'Refresh evidence did not use the canonical remote-ref digest.'); + standalone_refresh_assert(! file_exists($marker), 'Standalone refresh loaded the available WordPress bootstrap.'); + + $plan = standalone_refresh_run(array( PHP_BINARY, $script, 'plan', $fixture['workspace'], json_encode($intent, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) )); + $plan_payload = json_decode($plan['stdout'], true, 512, JSON_THROW_ON_ERROR); + standalone_refresh_assert(0 === $plan['status'] && 'create' === ($plan_payload['disposition'] ?? null), 'Plan did not accept standalone refresh evidence.'); + standalone_refresh_assert(true === ($plan_payload['freshness']['verified'] ?? null) && $remote_sha === ($plan_payload['freshness']['target_head'] ?? null), 'Plan freshness identity did not bind the standalone-refreshed target.'); + + $current = standalone_refresh_invoke($script, $fixture['workspace']); + standalone_refresh_assert(0 === $current['process']['status'] && 'current' === ($current['payload']['status'] ?? null) && false === ($current['payload']['changed'] ?? null), 'Idempotent refresh was not a typed no-op.'); + file_put_contents($fixture['primary'] . '/dirty.txt', "dirty\n"); + $dirty = standalone_refresh_invoke($script, $fixture['workspace']); + standalone_refresh_assert(1 === $dirty['process']['status'] && 'dirty_working_tree' === ($dirty['payload']['reason'] ?? null), 'Dirty primary was not refused.'); + unlink($fixture['primary'] . '/dirty.txt'); + + file_put_contents($fixture['primary'] . '/local.txt', "local\n"); + standalone_refresh_git($fixture['primary'], array( 'add', 'local.txt' )); + standalone_refresh_git($fixture['primary'], array( 'commit', '-m', 'local-only' )); + $local_sha = standalone_refresh_git($fixture['primary'], array( 'rev-parse', 'HEAD' )); + $ahead = standalone_refresh_invoke($script, $fixture['workspace']); + standalone_refresh_assert(1 === $ahead['process']['status'] && 'primary_refresh_ahead' === ($ahead['payload']['reason'] ?? null), 'Ahead-only primary was not refused.'); + standalone_refresh_assert($local_sha === standalone_refresh_git($fixture['primary'], array( 'rev-parse', 'HEAD' )) && null === ($ahead['payload']['recovery'] ?? null), 'Ahead refusal mutated or unnecessarily preserved the primary.'); + + $diverged_remote = standalone_refresh_advance($fixture, 'remote-diverged'); + $diverged = standalone_refresh_invoke($script, $fixture['workspace']); + $diverged_receipt = $diverged['payload']; + $recovery_path = (string) ($diverged_receipt['recovery']['preservation']['path'] ?? ''); + standalone_refresh_assert(0 === $diverged['process']['status'] && 'refreshed' === ($diverged_receipt['status'] ?? null), 'Default-branch divergence was not safely recovered: ' . var_export($diverged_receipt, true)); + standalone_refresh_assert($diverged_remote === standalone_refresh_git($fixture['primary'], array( 'rev-parse', 'HEAD' )), 'Divergence recovery did not refresh the authoritative primary.'); + standalone_refresh_assert(is_dir($recovery_path) && $local_sha === standalone_refresh_git($recovery_path, array( 'rev-parse', 'HEAD' )), 'Divergence recovery did not preserve the local tip in a worktree.'); + standalone_refresh_assert('preserved' === ($diverged_receipt['recovery']['preservation']['status'] ?? null), 'Divergence receipt omitted preservation status.'); + standalone_refresh_assert('datamachine-code/primary-recovery/v1' === ($diverged_receipt['recovery']['preservation']['metadata']['schema'] ?? null), 'Divergence recovery did not persist generic recovery metadata before refreshing the primary.'); + $recovery_git_dir = standalone_refresh_git($recovery_path, array( 'rev-parse', '--absolute-git-dir' )); + $recovery_metadata = json_decode((string) file_get_contents($recovery_git_dir . '/datamachine-code-primary-recovery.json'), true, 512, JSON_THROW_ON_ERROR); + standalone_refresh_assert($local_sha === ($recovery_metadata['commit'] ?? null) && 'manual' === ($recovery_metadata['cleanup_policy'] ?? null), 'Durable recovery metadata did not bind the preserved commit and fail-closed cleanup policy.'); + $after_recovery_plan = standalone_refresh_run(array( PHP_BINARY, $script, 'plan', $fixture['workspace'], json_encode(array_merge($intent, array( 'branch' => 'fix/after-recovery' )), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) )); + standalone_refresh_assert(0 === $after_recovery_plan['status'] && 'create' === (json_decode($after_recovery_plan['stdout'], true, 512, JSON_THROW_ON_ERROR)['disposition'] ?? null), 'Planning rejected freshness evidence after divergence recovery.'); + + $collision = standalone_refresh_fixture($root . '/recovery-collision'); + file_put_contents($collision['primary'] . '/local.txt', "local\n"); + standalone_refresh_git($collision['primary'], array( 'add', 'local.txt' )); + standalone_refresh_git($collision['primary'], array( 'commit', '-m', 'local-only' )); + $collision_local = standalone_refresh_git($collision['primary'], array( 'rev-parse', 'HEAD' )); + $collision_path = realpath($collision['workspace']) . '/fixture@primary-recovery-' . substr($collision_local, 0, 12); + mkdir($collision_path); + standalone_refresh_advance($collision, 'remote'); + $collision_refusal = standalone_refresh_invoke($script, $collision['workspace']); + standalone_refresh_assert(1 === $collision_refusal['process']['status'] && 'primary_divergence_recovery_path_conflict' === ($collision_refusal['payload']['reason'] ?? null), 'Occupied deterministic recovery path did not fail closed.'); + standalone_refresh_assert($collision_local === standalone_refresh_git($collision['primary'], array( 'rev-parse', 'HEAD' )), 'Recovery path collision changed the primary.'); + standalone_refresh_assert('' === standalone_refresh_git($collision['primary'], array( 'branch', '--list', 'recovery/primary-recovery-' . substr($collision_local, 0, 12) )), 'Recovery path collision retained a newly-created recovery branch.'); + + $missing_upstream = standalone_refresh_fixture($root . '/missing-upstream'); + standalone_refresh_git($missing_upstream['primary'], array( 'branch', '--unset-upstream' )); + $missing_upstream_sha = standalone_refresh_advance($missing_upstream, 'remote'); + $upstream_refresh = standalone_refresh_invoke($script, $missing_upstream['workspace']); + standalone_refresh_assert(0 === $upstream_refresh['process']['status'] && $missing_upstream_sha === ($upstream_refresh['payload']['new_sha'] ?? null), 'Missing-upstream primary did not refresh.'); + standalone_refresh_assert('origin/main' === standalone_refresh_git($missing_upstream['primary'], array( 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}' )), 'Missing upstream was not restored to the same-named remote branch.'); + + $detached = standalone_refresh_fixture($root . '/detached-default', 'trunk'); + standalone_refresh_git($detached['primary'], array( 'checkout', '--detach' )); + $detached_sha = standalone_refresh_advance($detached, 'remote'); + $detached_refresh = standalone_refresh_invoke($script, $detached['workspace']); + standalone_refresh_assert(0 === $detached_refresh['process']['status'] && 'trunk' === ($detached_refresh['payload']['detached_repair']['branch'] ?? null), 'Detached primary did not resolve the non-main remote default.'); + standalone_refresh_assert('trunk' === standalone_refresh_git($detached['primary'], array( 'branch', '--show-current' )) && $detached_sha === standalone_refresh_git($detached['primary'], array( 'rev-parse', 'HEAD' )), 'Detached primary did not reattach and refresh.'); + + $detached_diverged = standalone_refresh_fixture($root . '/detached-diverged'); + standalone_refresh_git($detached_diverged['primary'], array( 'checkout', '--detach' )); + file_put_contents($detached_diverged['primary'] . '/local.txt', "local\n"); + standalone_refresh_git($detached_diverged['primary'], array( 'add', 'local.txt' )); + standalone_refresh_git($detached_diverged['primary'], array( 'commit', '-m', 'detached-local' )); + $detached_local = standalone_refresh_git($detached_diverged['primary'], array( 'rev-parse', 'HEAD' )); + standalone_refresh_advance($detached_diverged, 'remote'); + $detached_refusal = standalone_refresh_invoke($script, $detached_diverged['workspace']); + standalone_refresh_assert(1 === $detached_refusal['process']['status'] && 'detached_primary_diverged' === ($detached_refusal['payload']['reason'] ?? null), 'Unpreserved detached history was not refused.'); + standalone_refresh_assert('' === standalone_refresh_git($detached_diverged['primary'], array( 'branch', '--show-current' )) && $detached_local === standalone_refresh_git($detached_diverged['primary'], array( 'rev-parse', 'HEAD' )), 'Detached divergence refusal changed the primary.'); + + $non_default = standalone_refresh_fixture($root . '/non-default'); + standalone_refresh_git($non_default['seed'], array( 'checkout', '-b', 'feature' )); + file_put_contents($non_default['seed'] . '/feature.txt', "feature\n"); + standalone_refresh_git($non_default['seed'], array( 'add', 'feature.txt' )); + standalone_refresh_git($non_default['seed'], array( 'commit', '-m', 'feature' )); + standalone_refresh_git($non_default['seed'], array( 'push', '-u', 'origin', 'feature' )); + standalone_refresh_git($non_default['primary'], array( 'fetch', 'origin' )); + standalone_refresh_git($non_default['primary'], array( 'checkout', '-b', 'feature', '--track', 'origin/feature' )); + file_put_contents($non_default['primary'] . '/local.txt', "local\n"); + standalone_refresh_git($non_default['primary'], array( 'add', 'local.txt' )); + standalone_refresh_git($non_default['primary'], array( 'commit', '-m', 'feature-local' )); + $feature_local = standalone_refresh_git($non_default['primary'], array( 'rev-parse', 'HEAD' )); + file_put_contents($non_default['seed'] . '/remote.txt', "remote\n"); + standalone_refresh_git($non_default['seed'], array( 'add', 'remote.txt' )); + standalone_refresh_git($non_default['seed'], array( 'commit', '-m', 'feature-remote' )); + standalone_refresh_git($non_default['seed'], array( 'push', 'origin', 'feature' )); + $feature_refusal = standalone_refresh_invoke($script, $non_default['workspace']); + standalone_refresh_assert(1 === $feature_refusal['process']['status'] && 'primary_refresh_diverged' === ($feature_refusal['payload']['reason'] ?? null), 'Non-default divergence was incorrectly eligible for primary recovery.'); + standalone_refresh_assert(null === ($feature_refusal['payload']['recovery'] ?? null) && $feature_local === standalone_refresh_git($non_default['primary'], array( 'rev-parse', 'HEAD' )), 'Non-default divergence created recovery state or changed HEAD.'); + + $invalid = standalone_refresh_invoke($script, $fixture['workspace'], 'fixture@linked'); + standalone_refresh_assert(1 === $invalid['process']['status'] && 'invalid_primary_handle' === ($invalid['payload']['reason'] ?? null), 'Linked-worktree handle bypassed authoritative-primary validation.'); + + fwrite(STDOUT, "standalone-primary-refresh: ok\n"); +} finally { + standalone_refresh_remove($root); +} diff --git a/tests/standalone-worktree-provider-command.php b/tests/standalone-worktree-provider-command.php index 3874fc27..0978970d 100644 --- a/tests/standalone-worktree-provider-command.php +++ b/tests/standalone-worktree-provider-command.php @@ -32,6 +32,7 @@ function standalone_provider_command_assert( bool $condition, string $message ): standalone_provider_command_assert(false !== $expected, 'Standalone provider fixture is missing its executable.'); standalone_provider_command_assert($expected === $executable, 'Provider command did not resolve the executable from the DMC source contract.'); standalone_provider_command_assert(is_file($executable), 'Provider command returned a non-file executable.'); + standalone_provider_command_assert(is_executable($executable), 'Provider command returned a file without executable packaging permissions.'); $command = new \DataMachineCode\Cli\Commands\WorkspaceCommand(); $command->__worktree_operation('provider', array(), array( 'format' => 'json' )); @@ -39,6 +40,10 @@ function standalone_provider_command_assert( bool $condition, string $message ): standalone_provider_command_assert('datamachine-code/standalone-worktree-provider-command/v1' === ( $payload['schema'] ?? null ), 'Provider command emitted an unexpected schema.'); standalone_provider_command_assert($executable === ( $payload['executable'] ?? null ), 'Provider command did not emit its resolved executable.'); standalone_provider_command_assert(in_array('plan', $payload['capabilities']['operations'] ?? array(), true), 'Provider command capabilities omitted standalone planning.'); + standalone_provider_command_assert(in_array('primary-refresh', $payload['capabilities']['operations'] ?? array(), true), 'Provider command capabilities omitted standalone primary refresh.'); + standalone_provider_command_assert('datamachine-code/primary-refresh/v1' === ($payload['capabilities']['primary_refresh_schema'] ?? null), 'Provider command capabilities omitted the primary refresh schema.'); + standalone_provider_command_assert(true === ($payload['capabilities']['primary_refresh_mutating'] ?? null), 'Provider command did not identify primary refresh as mutating.'); + standalone_provider_command_assert('origin' === ($payload['capabilities']['primary_refresh_remote'] ?? null), 'Provider command did not bind refresh to the canonical freshness remote.'); standalone_provider_command_assert(\DataMachineCode\Workspace\WorktreePlanEnvelope::SCHEMA === ($payload['capabilities']['plan_schema'] ?? null), 'Provider command capabilities omitted the plan schema.'); echo "standalone-worktree-provider-command: ok\n"; diff --git a/tests/standalone-worktree-provider.php b/tests/standalone-worktree-provider.php index 807a3a70..48c50d97 100644 --- a/tests/standalone-worktree-provider.php +++ b/tests/standalone-worktree-provider.php @@ -141,7 +141,9 @@ function standalone_provider_remove( string $path ): void { standalone_provider_assert(json_validate($refresh_required['stdout']), 'Plan emitted invalid JSON: ' . $refresh_required['stdout'] . $refresh_required['stderr']); $refresh_required_payload = json_decode($refresh_required['stdout'], true, 512, JSON_THROW_ON_ERROR); standalone_provider_assert(1 === $refresh_required['status'] && 'freshness_refresh_required' === ($refresh_required_payload['code'] ?? null), 'Plan without freshness evidence did not fail closed.'); - standalone_provider_assert(str_contains((string) ($refresh_required_payload['refresh_command'] ?? ''), 'git pull fixture --allow-primary-refresh'), 'Plan without freshness omitted the exact refresh command.'); + standalone_provider_assert(! str_contains((string) ($refresh_required_payload['refresh_command'] ?? ''), 'wp '), 'Plan without freshness retained a WordPress-only refresh command.'); + standalone_provider_assert(array( 'primary-refresh', realpath($root), 'fixture' ) === ($refresh_required_payload['refresh_action']['arguments'] ?? null), 'Plan without freshness omitted the canonical standalone refresh arguments.'); + standalone_provider_assert($script === ($refresh_required_payload['refresh_action']['executable'] ?? null), 'Plan without freshness did not select the installed provider executable.'); $refs = standalone_provider_run(array( 'git', '-C', $primary, 'for-each-ref', '--format=%(refname) %(objectname)', 'refs/remotes/origin' )); \DataMachineCode\Workspace\WorktreeFreshnessEvidence::store($primary, array( 'version' => 2, From 5a6ec6b04a737b7ef7659adbf244b077b0616690 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 30 Aug 2026 14:42:42 -0400 Subject: [PATCH 2/3] fix: share standalone workspace locking --- inc/Workspace/StandaloneFileLock.php | 36 +++++++++++++++++++ inc/Workspace/StandalonePrimaryRefresher.php | 29 ++++----------- inc/Workspace/StandaloneWorktreeProvider.php | 20 +++-------- tests/standalone-primary-refresh.php | 5 ++- .../standalone-worktree-provider-command.php | 2 +- 5 files changed, 52 insertions(+), 40 deletions(-) create mode 100644 inc/Workspace/StandaloneFileLock.php diff --git a/inc/Workspace/StandaloneFileLock.php b/inc/Workspace/StandaloneFileLock.php new file mode 100644 index 00000000..709fd61d --- /dev/null +++ b/inc/Workspace/StandaloneFileLock.php @@ -0,0 +1,36 @@ +refresh_locked($workspace_real, $real, $repo, $remote, $context, $started); } finally { - flock($lock, LOCK_UN); - fclose($lock); + StandaloneFileLock::release($lock); } } @@ -403,14 +405,7 @@ private function preserve_divergence( string $workspace, string $primary, string } $listed = $this->recovery_worktree($primary, $path, $ref); - if ( 'conflict' === $listed ) { - if ( $created ) { - $this->git($primary, array( 'branch', '-D', $branch )); - } - $partial['rolled_back'] = $created; - return array( 'success' => false, 'reason' => 'primary_divergence_recovery_path_conflict', 'recovery' => $partial ); - } - if ( 'missing' === $listed && is_dir($path) ) { + if ( 'conflict' === $listed || ( 'missing' === $listed && is_dir($path) ) ) { if ( $created ) { $this->git($primary, array( 'branch', '-D', $branch )); } @@ -646,19 +641,7 @@ private function acquire_lock( string $workspace, string $repo ) { if ( is_link($directory) || ( ! is_dir($directory) && ! @mkdir($directory, 0755, true) && ! is_dir($directory) ) ) { return null; } - $handle = @fopen($directory . '/worktree-' . $repo . '.lock', 'c'); - if ( false === $handle ) { - return null; - } - $started = microtime(true); - do { - if ( flock($handle, LOCK_EX | LOCK_NB) ) { - return $handle; - } - usleep(100000); - } while ( microtime(true) - $started < self::LOCK_TIMEOUT ); - fclose($handle); - return null; + return StandaloneFileLock::acquire($directory . '/worktree-' . $repo . '.lock', self::LOCK_TIMEOUT, 100000); } /** @return array{success:bool,stdout:string,stderr:string,timed_out:bool,exit_code:int} */ diff --git a/inc/Workspace/StandaloneWorktreeProvider.php b/inc/Workspace/StandaloneWorktreeProvider.php index caa68623..3f9f2f7c 100644 --- a/inc/Workspace/StandaloneWorktreeProvider.php +++ b/inc/Workspace/StandaloneWorktreeProvider.php @@ -30,6 +30,9 @@ if ( ! class_exists(StandalonePrimaryRefresher::class) ) { require_once __DIR__ . '/StandalonePrimaryRefresher.php'; } +if ( ! class_exists(StandaloneFileLock::class) ) { + require_once __DIR__ . '/StandaloneFileLock.php'; +} final class StandaloneWorktreeProvider { @@ -876,25 +879,12 @@ private function acquire_convergence_lock( string $git_dir ) { } $key = hash('sha256', $git_dir . ':' . $stat['dev'] . ':' . $stat['ino']); $file = sys_get_temp_dir() . '/dmc-worktree-converge-' . $key . '.lock'; - $lock = @fopen($file, 'c'); - if ( false === $lock ) { - return null; - } - $started = microtime(true); - while ( ! flock($lock, LOCK_EX | LOCK_NB) ) { - if ( microtime(true) - $started >= self::LOCK_TIMEOUT ) { - fclose($lock); - return null; - } - usleep(10000); - } - return $lock; + return StandaloneFileLock::acquire($file, self::LOCK_TIMEOUT, 10000); } /** @param resource $lock */ private function release_convergence_lock( $lock ): void { - flock($lock, LOCK_UN); - fclose($lock); + StandaloneFileLock::release($lock); } /** diff --git a/tests/standalone-primary-refresh.php b/tests/standalone-primary-refresh.php index 3ba54003..4e291858 100644 --- a/tests/standalone-primary-refresh.php +++ b/tests/standalone-primary-refresh.php @@ -2,6 +2,9 @@ declare(strict_types=1); +define('DATAMACHINE_CODE_STANDALONE', true); +require_once dirname(__DIR__) . '/vendor/autoload.php'; + function standalone_refresh_assert( bool $condition, string $message ): void { if ( ! $condition ) { throw new RuntimeException($message); @@ -104,7 +107,7 @@ function standalone_refresh_remove( string $path ): void { $refresh = standalone_refresh_invoke($script, $fixture['workspace']); $receipt = $refresh['payload']; standalone_refresh_assert(0 === $refresh['process']['status'] && 'refreshed' === ($receipt['status'] ?? null), 'Behind primary did not refresh: ' . $refresh['process']['stderr'] . var_export($receipt, true)); - standalone_refresh_assert('datamachine-code/primary-refresh/v1' === ($receipt['schema'] ?? null), 'Refresh receipt schema mismatch.'); + standalone_refresh_assert(\DataMachineCode\Workspace\StandalonePrimaryRefresher::SCHEMA === ($receipt['schema'] ?? null), 'Refresh receipt schema mismatch.'); standalone_refresh_assert($old_sha === ($receipt['old_sha'] ?? null) && $remote_sha === ($receipt['new_sha'] ?? null), 'Refresh receipt omitted old/new commit identity.'); standalone_refresh_assert('main' === ($receipt['branch'] ?? null) && 'origin/main' === ($receipt['upstream'] ?? null), 'Refresh receipt omitted branch/upstream identity.'); standalone_refresh_assert($remote_sha === ($receipt['fetched']['default_sha'] ?? null), 'Refresh receipt omitted fetched default-branch evidence.'); diff --git a/tests/standalone-worktree-provider-command.php b/tests/standalone-worktree-provider-command.php index 0978970d..720e91ef 100644 --- a/tests/standalone-worktree-provider-command.php +++ b/tests/standalone-worktree-provider-command.php @@ -41,7 +41,7 @@ function standalone_provider_command_assert( bool $condition, string $message ): standalone_provider_command_assert($executable === ( $payload['executable'] ?? null ), 'Provider command did not emit its resolved executable.'); standalone_provider_command_assert(in_array('plan', $payload['capabilities']['operations'] ?? array(), true), 'Provider command capabilities omitted standalone planning.'); standalone_provider_command_assert(in_array('primary-refresh', $payload['capabilities']['operations'] ?? array(), true), 'Provider command capabilities omitted standalone primary refresh.'); - standalone_provider_command_assert('datamachine-code/primary-refresh/v1' === ($payload['capabilities']['primary_refresh_schema'] ?? null), 'Provider command capabilities omitted the primary refresh schema.'); + standalone_provider_command_assert(\DataMachineCode\Workspace\StandalonePrimaryRefresher::SCHEMA === ($payload['capabilities']['primary_refresh_schema'] ?? null), 'Provider command capabilities omitted the primary refresh schema.'); standalone_provider_command_assert(true === ($payload['capabilities']['primary_refresh_mutating'] ?? null), 'Provider command did not identify primary refresh as mutating.'); standalone_provider_command_assert('origin' === ($payload['capabilities']['primary_refresh_remote'] ?? null), 'Provider command did not bind refresh to the canonical freshness remote.'); standalone_provider_command_assert(\DataMachineCode\Workspace\WorktreePlanEnvelope::SCHEMA === ($payload['capabilities']['plan_schema'] ?? null), 'Provider command capabilities omitted the plan schema.'); From 7b2471423ce40478030a444c5eb3851631374d17 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 30 Aug 2026 15:07:44 -0400 Subject: [PATCH 3/3] fix: share worktree branch holder parsing --- inc/Workspace/StandalonePrimaryRefresher.php | 20 ++++--------- inc/Workspace/WorkspaceGitOperations.php | 21 ++++--------- inc/Workspace/WorktreeBranchHolder.php | 31 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 30 deletions(-) create mode 100644 inc/Workspace/WorktreeBranchHolder.php diff --git a/inc/Workspace/StandalonePrimaryRefresher.php b/inc/Workspace/StandalonePrimaryRefresher.php index f1083d4d..917d5176 100644 --- a/inc/Workspace/StandalonePrimaryRefresher.php +++ b/inc/Workspace/StandalonePrimaryRefresher.php @@ -15,6 +15,9 @@ if ( ! class_exists(StandaloneFileLock::class) ) { require_once __DIR__ . '/StandaloneFileLock.php'; } +if ( ! class_exists(WorktreeBranchHolder::class) ) { + require_once __DIR__ . '/WorktreeBranchHolder.php'; +} final class StandalonePrimaryRefresher { @@ -564,21 +567,8 @@ private function branch_holder( string $primary, string $branch ): ?array { if ( ! $listing['success'] ) { return array( 'path' => null, 'branch' => $branch, 'unverified' => true ); } - foreach ( preg_split('/\n\n+/', trim($listing['stdout'])) ?: array() as $block ) { - $path = null; - $ref = null; - foreach ( explode("\n", $block) as $line ) { - if ( str_starts_with($line, 'worktree ') ) { - $path = substr($line, 9); - } elseif ( str_starts_with($line, 'branch ') ) { - $ref = substr($line, 7); - } - } - if ( null !== $path && $path !== $primary && $ref === 'refs/heads/' . $branch ) { - return array( 'path' => $path, 'handle' => basename($path), 'branch' => $branch ); - } - } - return null; + $path = WorktreeBranchHolder::find($listing['stdout'], $primary, $branch); + return null === $path ? null : array( 'path' => $path, 'handle' => basename($path), 'branch' => $branch ); } /** @return array{ahead:int,behind:int}|null */ diff --git a/inc/Workspace/WorkspaceGitOperations.php b/inc/Workspace/WorkspaceGitOperations.php index 70d02893..5d4f6db8 100644 --- a/inc/Workspace/WorkspaceGitOperations.php +++ b/inc/Workspace/WorkspaceGitOperations.php @@ -7,6 +7,10 @@ namespace DataMachineCode\Workspace; +if ( ! class_exists(WorktreeBranchHolder::class) ) { + require_once __DIR__ . '/WorktreeBranchHolder.php'; +} + use DataMachineCode\Support\GitHubRemote; use DataMachineCode\Support\GitRunner; use DataMachineCode\Support\GitTransportPreflight; @@ -2201,21 +2205,8 @@ private function default_branch_holder( string $repo_path, string $branch, strin return new \WP_Error('detached_primary_worktree_listing_failed', 'Primary refresh is blocked: unable to verify whether another worktree holds the default branch.', array( 'status' => 409 )); } - $blocks = preg_split('/\n\n+/', trim( (string) ( $listing['output'] ?? '' ))); - if ( ! is_array($blocks) ) { - $blocks = array(); - } - foreach ( $blocks as $block ) { - $path = null; - $ref = null; - foreach ( explode("\n", $block) as $line ) { - if ( str_starts_with($line, 'worktree ') ) { - $path = substr($line, 9); } - if ( str_starts_with($line, 'branch ') ) { - $ref = substr($line, 7); } - } - if ( null === $path || $path === $repo_path || 'refs/heads/' . $branch !== $ref ) { - continue; } + $path = WorktreeBranchHolder::find( (string) ( $listing['output'] ?? '' ), $repo_path, $branch ); + if ( null !== $path ) { $basename = basename($path); $handle = str_starts_with($basename, $repo . '@') ? $basename : $path; return array( diff --git a/inc/Workspace/WorktreeBranchHolder.php b/inc/Workspace/WorktreeBranchHolder.php new file mode 100644 index 00000000..537a8c66 --- /dev/null +++ b/inc/Workspace/WorktreeBranchHolder.php @@ -0,0 +1,31 @@ +