From 3cd6862a498f5079a08822bf083d1be96fcf542f Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Mon, 8 Jun 2026 08:21:09 -0400 Subject: [PATCH] fix: guard workspace primary freshness --- inc/Abilities/WorkspaceAbilities.php | 42 +++- inc/Cli/Commands/WorkspaceCommand.php | 35 +++- inc/Runtime/AgentsMdSections.php | 3 +- inc/Workspace/Workspace.php | 5 + inc/Workspace/WorkspaceCoreUtilities.php | 181 ++++++++++++++++++ inc/Workspace/WorkspaceHygieneReport.php | 25 +++ .../WorkspaceRepositoryLifecycle.php | 52 +++++ inc/Workspace/WorkspaceWorktreeLifecycle.php | 20 ++ tests/smoke-workspace-clone-ux.php | 28 +++ 9 files changed, 376 insertions(+), 15 deletions(-) diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index cad8648f..4ac040c1 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -102,7 +102,7 @@ private function registerAbilities(): void { 'datamachine-code/workspace-list', array( 'label' => 'List Workspace Repos', - 'description' => 'List repositories in the agent workspace.', + 'description' => 'List repositories in the agent workspace. Primary rows include local-ref freshness metadata; refresh stale primaries before using them for verification.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', @@ -134,6 +134,7 @@ private function registerAbilities(): void { // Local-only repos have no remote; detached HEAD has no branch. 'remote' => array( 'type' => array( 'string', 'null' ) ), 'branch' => array( 'type' => array( 'string', 'null' ) ), + 'primary_freshness' => self::primaryFreshnessSchema(), ), ), ), @@ -178,7 +179,7 @@ private function registerAbilities(): void { 'datamachine-code/workspace-show', array( 'label' => 'Show Workspace Repo', - 'description' => 'Show detailed info about a workspace repository (branch, remote, latest commit, dirty status).', + 'description' => 'Show detailed info about a workspace repository (branch, remote, latest commit, dirty status, and primary freshness).', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', @@ -204,6 +205,7 @@ private function registerAbilities(): void { 'remote' => array( 'type' => array( 'string', 'null' ) ), 'commit' => array( 'type' => array( 'string', 'null' ) ), 'dirty' => array( 'type' => 'integer' ), + 'primary_freshness' => self::primaryFreshnessSchema(), ), ), 'execute_callback' => array( self::class, 'showRepo' ), @@ -383,7 +385,7 @@ private function registerAbilities(): void { 'datamachine-code/workspace-clone', array( 'label' => 'Clone Workspace Repo', - 'description' => 'Clone a git repository into the workspace as a primary checkout. Worktrees are created separately via `workspace-worktree-add`.', + 'description' => 'Clone a git repository into the workspace as a primary checkout only when no primary for that remote already exists. If the remote exists, refresh/reuse that primary and create a worktree via `workspace-worktree-add`.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', @@ -404,6 +406,10 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Optional environment variable name containing a bearer token for HTTPS clone authentication.', ), + 'allow_duplicate_remote' => array( + 'type' => 'boolean', + 'description' => 'Explicitly allow cloning a second top-level primary for a remote already present in the workspace. Default false; use only for deliberate release/proof checkouts.', + ), ), 'required' => array( 'url' ), ), @@ -2575,6 +2581,31 @@ private static function remote_workspace_guidance( string $operation, array $res return array(); } + /** + * Shared schema for primary checkout freshness metadata. + * + * @return array + */ + private static function primaryFreshnessSchema(): array { + return array( + 'type' => array( 'object', 'null' ), + 'properties' => array( + 'status' => array( + 'type' => 'string', + 'description' => 'One of current, stale, diverged, ahead, detached, no_upstream, or unknown.', + ), + 'branch' => array( 'type' => array( 'string', 'null' ) ), + 'upstream' => array( 'type' => array( 'string', 'null' ) ), + 'behind' => array( 'type' => array( 'integer', 'null' ) ), + 'ahead' => array( 'type' => array( 'integer', 'null' ) ), + 'detached' => array( 'type' => 'boolean' ), + 'local_refs' => array( 'type' => 'boolean' ), + 'fetch_checked' => array( 'type' => 'boolean' ), + 'suggested_command' => array( 'type' => array( 'string', 'null' ) ), + ), + ); + } + /** * Clone a git repository into the workspace. * @@ -2595,8 +2626,9 @@ public static function cloneRepo( array $input ): array|\WP_Error { $input['url'] ?? '', $input['name'] ?? null, array( - 'full' => (bool) ( $input['full'] ?? false ), - 'auth_token_env' => $input['auth_token_env'] ?? '', + 'full' => (bool) ( $input['full'] ?? false ), + 'auth_token_env' => $input['auth_token_env'] ?? '', + 'allow_duplicate_remote' => ! empty($input['allow_duplicate_remote']), ) ); } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 25044504..1b3e025c 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -166,14 +166,17 @@ public function list_repos( array $args, array $assoc_args ): void { $items = array_map( function ( $repo ) { + $freshness = is_array($repo['primary_freshness'] ?? null) ? $repo['primary_freshness'] : null; return array( - 'name' => $repo['name'], - 'kind' => ! empty($repo['is_worktree']) ? 'worktree' : 'primary', - 'repo' => $repo['repo'] ?? $repo['name'], - 'branch' => $repo['branch'] ?? '-', - 'remote' => $repo['remote'] ?? '-', - 'git' => $repo['git'] ? 'yes' : 'no', - 'path' => $repo['path'], + 'name' => $repo['name'], + 'kind' => ! empty($repo['is_worktree']) ? 'worktree' : 'primary', + 'repo' => $repo['repo'] ?? $repo['name'], + 'branch' => $repo['branch'] ?? '-', + 'freshness' => is_array($freshness) ? (string) ( $freshness['status'] ?? '-' ) : '-', + 'behind' => is_array($freshness) && null !== ( $freshness['behind'] ?? null ) ? (string) $freshness['behind'] : '-', + 'remote' => $repo['remote'] ?? '-', + 'git' => $repo['git'] ? 'yes' : 'no', + 'path' => $repo['path'], ); }, $result['repos'] @@ -181,7 +184,7 @@ function ( $repo ) { $this->format_items( $items, - array( 'name', 'kind', 'repo', 'branch', 'remote', 'git' ), + array( 'name', 'kind', 'repo', 'branch', 'freshness', 'behind', 'remote', 'git' ), $assoc_args, 'name' ); @@ -201,6 +204,9 @@ function ( $repo ) { * [--full] * : Disable the default blobless partial clone (`--filter=blob:none`). Useful for servers that do not support partial clone or when all blobs are needed immediately. * + * [--allow-duplicate-remote] + * : Explicitly allow cloning a second top-level primary for a remote already present in the workspace. Use only for deliberate release/proof checkouts. + * * ## EXAMPLES * * # Clone a repo @@ -222,7 +228,8 @@ public function clone_repo( array $args, array $assoc_args ): void { $args[0], $assoc_args['name'] ?? null, array( - 'full' => isset($assoc_args['full']), + 'full' => isset($assoc_args['full']), + 'allow_duplicate_remote' => isset($assoc_args['allow-duplicate-remote']), 'progress_callback' => static function ( array $event ): void { $elapsed = number_format( (float) ( $event['elapsed'] ?? 0 ), 1); WP_CLI::log(sprintf('[clone %ss] %s', $elapsed, (string) ( $event['message'] ?? '' ))); @@ -1092,6 +1099,16 @@ public function show( array $args, array $assoc_args ): void { WP_CLI::log(sprintf('Branch: %s', $result['branch'] ?? '-')); WP_CLI::log(sprintf('Remote: %s', $result['remote'] ?? '-')); WP_CLI::log(sprintf('Latest: %s', $result['commit'] ?? '-')); + if ( empty($result['is_worktree']) && is_array($result['primary_freshness'] ?? null) ) { + $freshness = $result['primary_freshness']; + WP_CLI::log(sprintf('Freshness: %s', (string) ( $freshness['status'] ?? 'unknown' ))); + WP_CLI::log(sprintf('Upstream: %s', (string) ( $freshness['upstream'] ?? '-' ))); + WP_CLI::log(sprintf('Behind: %s', null === ( $freshness['behind'] ?? null ) ? '-' : (string) $freshness['behind'])); + WP_CLI::log(sprintf('Ahead: %s', null === ( $freshness['ahead'] ?? null ) ? '-' : (string) $freshness['ahead'])); + if ( ! empty($freshness['suggested_command']) ) { + WP_CLI::log(sprintf('Refresh: %s', (string) $freshness['suggested_command'])); + } + } $dirty = $result['dirty'] ?? 0; WP_CLI::log(sprintf('Dirty: %s', ( 0 === $dirty ) ? 'no' : "yes ({$dirty} files)")); diff --git a/inc/Runtime/AgentsMdSections.php b/inc/Runtime/AgentsMdSections.php index 325b87fd..3ce04713 100644 --- a/inc/Runtime/AgentsMdSections.php +++ b/inc/Runtime/AgentsMdSections.php @@ -129,7 +129,8 @@ private static function register_datamachine_section( string $wp ): void { - **GitHub:** `{$wp} datamachine-code github issues|pulls|repos|status|view|close|review-flow|comment` — list/read GitHub state, manage issues, install review flows, and comment on reviews. - **Git sync:** `{$wp} datamachine-code gitsync bind|list|status|pull|submit|push|policy|unbind` — bind site-owned directories to remotes; `submit` opens or updates the PR path, while `push` writes directly to the configured branch. - **Editing inside a worktree:** any tool. Local agents on the same disk should use native file I/O and raw `git`; routing edits through workspace abilities is ceremony, not safety. -- **Workflow:** `workspace clone ` → `worktree add ` → edit files in the worktree with any tool → commit → push → PR. +- **Workflow:** reuse the existing primary when one exists for the remote; otherwise `workspace clone ` once → `worktree add ` → edit files in the worktree with any tool → commit → push → PR. +- **Primary freshness:** before using a primary checkout for investigation or verification, inspect `workspace list|show|hygiene` freshness metadata. If the primary is stale, run `workspace git pull --allow-primary-mutation` or create the worktree from an explicit remote ref with `worktree add --from=origin/`. Do not clone a second top-level primary for the same remote just to get fresh code. - **Why worktrees:** parallel-session isolation on disk. Multiple agents cook features in the same repo without stepping on each other. - **Primary is read-only.** Never edit `/` (no `@slug`). Mutating ops on bare `` handles via the CLI require `--allow-primary-mutation`. The primary tracks the deployed branch — operate on a worktree. - **Rule:** Never modify files under `wp-content/plugins/` or `wp-content/themes/` directly. Those paths are **read-only reference**. All code changes go through the workspace so they are tracked in git and reviewed via pull requests. diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 685487bc..77ac13c0 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -73,6 +73,11 @@ class Workspace { */ public const ARTIFACT_CLEANUP_DEFAULT_LIMIT = 100; + /** + * Default cap on top-level workspace entries sized by hygiene reports. + */ + public const HYGIENE_DEFAULT_SIZE_LIMIT = 1000; + /** * @var string Resolved workspace path. */ diff --git a/inc/Workspace/WorkspaceCoreUtilities.php b/inc/Workspace/WorkspaceCoreUtilities.php index c4e550a4..89732156 100644 --- a/inc/Workspace/WorkspaceCoreUtilities.php +++ b/inc/Workspace/WorkspaceCoreUtilities.php @@ -405,6 +405,187 @@ public function get_primary_path( string $repo ): string { return $this->workspace_path . '/' . $this->sanitize_name($repo); } + /** + * Normalize a git remote URL for same-repository comparisons. + * + * @param string $url Git remote URL. + * @return string Normalized URL-ish key. + */ + private function normalize_git_remote_url( string $url ): string { + $url = trim($url); + $url = rtrim($url, '/'); + $url = preg_replace('/\.git$/', '', $url) ?? $url; + + if ( preg_match('/^([^@\s]+)@([^:\s]+):(.+)$/', $url, $matches) ) { + $url = 'ssh://' . $matches[2] . '/' . $matches[3]; + } + + $parts = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url); + if ( is_array($parts) && ! empty($parts['host']) ) { + $host = strtolower( (string) $parts['host']); + $path = trim( (string) ( $parts['path'] ?? '' ), '/'); + $path = preg_replace('/\.git$/', '', $path) ?? $path; + return strtolower($host . '/' . $path); + } + + return strtolower($url); + } + + /** + * Find an existing primary checkout whose origin remote matches the URL. + * + * @param string $url Git remote URL to match. + * @param string $exclude_name Optional primary name to ignore. + * @return array{name: string, path: string, remote: string}|null + */ + private function find_primary_by_remote( string $url, string $exclude_name = '' ): ?array { + $needle = $this->normalize_git_remote_url($url); + if ( '' === $needle || ! is_dir($this->workspace_path) ) { + return null; + } + + $entries = scandir($this->workspace_path); + if ( ! is_array($entries) ) { + return null; + } + + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry || str_contains($entry, '@') || $entry === $exclude_name ) { + continue; + } + + $path = $this->workspace_path . '/' . $entry; + if ( ! is_dir($path) || ! file_exists($path . '/.git') ) { + continue; + } + + $remote = $this->git_get_remote($path); + if ( null !== $remote && $needle === $this->normalize_git_remote_url($remote) ) { + return array( + 'name' => $entry, + 'path' => $path, + 'remote' => $remote, + ); + } + } + + return null; + } + + /** + * Build local-ref freshness metadata for a primary checkout. + * + * This intentionally does not fetch. Read-only status/list/hygiene calls should + * reveal stale local remote refs without mutating repo state or requiring auth. + * + * @param string $repo_path Primary checkout path. + * @param string $handle Workspace primary handle. + * @return array|null + */ + private function build_primary_freshness_report( string $repo_path, string $handle ): ?array { + if ( ! file_exists($repo_path . '/.git') ) { + return null; + } + + $status_result = $this->run_git($repo_path, 'status --porcelain=v1 --branch --untracked-files=no'); + if ( is_wp_error($status_result) ) { + return array( + 'status' => 'unknown', + 'branch' => null, + 'upstream' => null, + 'behind' => null, + 'ahead' => null, + 'detached' => false, + 'local_refs' => true, + 'fetch_checked' => false, + ); + } + + $header = strtok((string) ( $status_result['output'] ?? '' ), "\n"); + $header = false === $header ? '' : trim($header); + $branch = null; + $detached = false; + $upstream = null; + $behind = 0; + $ahead = 0; + $status = 'unknown'; + + if ( preg_match('/^## HEAD \(no branch\)/', $header) ) { + $detached = true; + $status = 'detached'; + } elseif ( preg_match('/^## (.+?)(?:\.\.\.([^\s\[]+))?(?: \[(.+)\])?$/', $header, $matches) ) { + $branch = trim((string) $matches[1]); + $upstream = isset($matches[2]) && '' !== $matches[2] ? trim((string) $matches[2]) : null; + $divergence = isset($matches[3]) ? (string) $matches[3] : ''; + + if ( preg_match('/behind (\d+)/', $divergence, $behind_match) ) { + $behind = (int) $behind_match[1]; + } + if ( preg_match('/ahead (\d+)/', $divergence, $ahead_match) ) { + $ahead = (int) $ahead_match[1]; + } + + if ( null === $upstream ) { + $status = 'no_upstream'; + } elseif ( $behind > 0 && $ahead > 0 ) { + $status = 'diverged'; + } elseif ( $behind > 0 ) { + $status = 'stale'; + } elseif ( $ahead > 0 ) { + $status = 'ahead'; + } else { + $status = 'current'; + } + } + + $report = array( + 'status' => $status, + 'branch' => $branch, + 'upstream' => $upstream, + 'behind' => null === $upstream ? null : $behind, + 'ahead' => null === $upstream ? null : $ahead, + 'detached' => $detached, + 'local_refs' => true, + 'fetch_checked' => false, + ); + + if ( $this->primary_freshness_needs_refresh($status) ) { + $report['suggested_command'] = $this->primary_refresh_command($handle); + } + + return $report; + } + + /** + * Build the canonical command for refreshing a primary checkout. + * + * @param string $handle Primary workspace handle. + * @return string WP-CLI command. + */ + private function primary_refresh_command( string $handle ): string { + return sprintf('wp datamachine-code workspace git pull %s --allow-primary-mutation', $handle); + } + + /** + * Whether a primary freshness status needs a pull/reconciliation refresh. + * + * @param string $status Freshness status. + * @return bool True when refresh guidance should be shown. + */ + private function primary_freshness_needs_refresh( string $status ): bool { + return in_array($status, array( 'stale', 'diverged' ), true); + } + + /** + * Whether a primary freshness status should be surfaced in hygiene attention. + * + * @param string $status Freshness status. + * @return bool True when attention should be shown. + */ + private function primary_freshness_needs_attention( string $status ): bool { + return in_array($status, array( 'stale', 'diverged', 'detached', 'no_upstream', 'unknown' ), true); + } + /** * Ensure the workspace directory exists with correct permissions. * diff --git a/inc/Workspace/WorkspaceHygieneReport.php b/inc/Workspace/WorkspaceHygieneReport.php index 043c8bbe..14d679f5 100644 --- a/inc/Workspace/WorkspaceHygieneReport.php +++ b/inc/Workspace/WorkspaceHygieneReport.php @@ -103,6 +103,7 @@ public function workspace_hygiene_report( array $opts = array() ): array|\WP_Err $include_sizes ? (string) ( $size_report['mode_note'] ?? '' ) : 'Size scan disabled by request.', $include_worktree_status ? 'Full worktree status enabled; this may run git status across every worktree.' : 'Worktree status uses cheap top-level inventory; pass --include-worktree-status for full git status.', $include_cleanup ? 'Cleanup summary uses inventory-only dry-run detection (--inventory-only --skip-github); no per-worktree git probes or GitHub API lookups are required.' : 'Cleanup dry-run disabled by request.', + ! empty($worktree_summary['stale_primaries']) ? 'One or more primary checkouts are behind their configured upstream according to local remote refs; refresh before using a primary for verification.' : '', ! empty($worktree_summary['base_branch_worktree_count']) ? 'One or more non-primary worktrees have a base branch checked out; gh pr merge --delete-branch can merge remotely but fail local cleanup.' : '', ) ) @@ -379,6 +380,10 @@ private function build_workspace_inventory_rows(): array { 'metadata' => $metadata, ); + if ( 'primary' === $kind ) { + $row['primary_freshness'] = $this->build_primary_freshness_report($path, $parsed['dir_name']); + } + $base_branch_warning = $this->base_branch_worktree_warning($row); if ( null !== $base_branch_warning ) { $row['base_branch_warning'] = $base_branch_warning; @@ -544,6 +549,9 @@ private function summarize_workspace_worktrees( array $worktrees, ?array $cleanu 'protected_dirty' => 0, 'protected_unpushed' => 0, 'missing_metadata' => 0, + 'stale_primaries' => 0, + 'primary_freshness_by_status' => array(), + 'primary_freshness_attention' => array(), 'base_branch_worktree_count' => 0, 'base_branch_worktrees' => array(), 'by_liveness' => array( @@ -558,6 +566,23 @@ private function summarize_workspace_worktrees( array $worktrees, ?array $cleanu foreach ( $worktrees as $row ) { if ( ! empty($row['is_primary']) ) { ++$summary['primaries']; + $freshness = is_array($row['primary_freshness'] ?? null) ? $row['primary_freshness'] : null; + $status = is_array($freshness) ? (string) ( $freshness['status'] ?? 'unknown' ) : 'unknown'; + $summary['primary_freshness_by_status'][ $status ] = (int) ( $summary['primary_freshness_by_status'][ $status ] ?? 0 ) + 1; + if ( $this->primary_freshness_needs_attention($status) ) { + if ( $this->primary_freshness_needs_refresh($status) ) { + ++$summary['stale_primaries']; + } + $summary['primary_freshness_attention'][] = array( + 'handle' => (string) ( $row['handle'] ?? '' ), + 'status' => $status, + 'branch' => is_array($freshness) ? ( $freshness['branch'] ?? null ) : null, + 'upstream' => is_array($freshness) ? ( $freshness['upstream'] ?? null ) : null, + 'behind' => is_array($freshness) ? ( $freshness['behind'] ?? null ) : null, + 'ahead' => is_array($freshness) ? ( $freshness['ahead'] ?? null ) : null, + 'suggested_command' => is_array($freshness) ? ( $freshness['suggested_command'] ?? null ) : null, + ); + } } elseif ( ! empty($row['is_worktree']) ) { ++$summary['worktrees']; } elseif ( 'artifact' === (string) ( $row['kind'] ?? '' ) ) { diff --git a/inc/Workspace/WorkspaceRepositoryLifecycle.php b/inc/Workspace/WorkspaceRepositoryLifecycle.php index de894eff..88bc64aa 100644 --- a/inc/Workspace/WorkspaceRepositoryLifecycle.php +++ b/inc/Workspace/WorkspaceRepositoryLifecycle.php @@ -101,6 +101,10 @@ public function list_repos( ?string $repo = null, ?string $type = null ): array| if ( null !== $branch ) { $repo_info['branch'] = $branch; } + + if ( ! $is_worktree ) { + $repo_info['primary_freshness'] = $this->build_primary_freshness_report($entry_path, $entry); + } } $repos[] = $repo_info; @@ -147,9 +151,22 @@ public function clone_repo( string $url, ?string $name = null, array $options = $name = $this->sanitize_name($name); $repo_path = $this->workspace_path . '/' . $name; + $allow_duplicate_remote = ! empty($options['allow_duplicate_remote']); // Check if already exists. if ( is_dir($repo_path) ) { + $existing_remote = file_exists($repo_path . '/.git') ? $this->git_get_remote($repo_path) : null; + if ( null !== $existing_remote && ! $allow_duplicate_remote && $this->normalize_git_remote_url($url) === $this->normalize_git_remote_url($existing_remote) ) { + return $this->clone_remote_exists_error( + $url, + $name, + array( + 'name' => $name, + 'path' => $repo_path, + 'remote' => $existing_remote, + ) + ); + } return $this->clone_target_exists_error($name, $repo_path); } @@ -159,6 +176,11 @@ public function clone_repo( string $url, ?string $name = null, array $options = return $ensure; } + $existing_primary = $this->find_primary_by_remote($url, $name); + if ( null !== $existing_primary && ! $allow_duplicate_remote ) { + return $this->clone_remote_exists_error($url, $name, $existing_primary); + } + if ( ! GitRunner::supports_streaming() ) { return GitRunner::unavailable_error('Clone workspace repository', true); } @@ -386,6 +408,35 @@ private function clone_target_exists_error( string $name, string $repo_path ): \ ); } + /** + * Add recovery guidance when the same remote already has a primary checkout. + * + * @param string $url Requested clone URL. + * @param string $name Requested workspace name. + * @param array $existing Existing primary checkout summary. + * @return \WP_Error Error with remediation data. + */ + private function clone_remote_exists_error( string $url, string $name, array $existing ): \WP_Error { + $existing_name = (string) ( $existing['name'] ?? '' ); + $next_steps = array( + sprintf('Reuse existing primary checkout: %s', $existing_name), + sprintf('Refresh it when needed: %s', $this->primary_refresh_command($existing_name)), + sprintf('Then create an isolated branch: wp datamachine-code workspace worktree add %s ', $existing_name), + ); + + return new \WP_Error( + 'repo_remote_exists', + sprintf('A primary checkout for %s already exists as "%s" at %s. Do not clone the same remote as "%s"; refresh/reuse the existing primary instead. Next steps: %s', $url, $existing_name, (string) ( $existing['path'] ?? '' ), $name, implode(' ', $next_steps)), + array( + 'status' => 409, + 'url' => $url, + 'name' => $name, + 'existing' => $existing, + 'next_steps' => $next_steps, + ) + ); + } + /** * Add recovery guidance to git clone failures. * @@ -595,6 +646,7 @@ public function show_repo( string $handle ): array|\WP_Error { 'remote' => $remote ? $remote : null, 'commit' => $commit ? $commit : null, 'dirty' => (int) $status, + 'primary_freshness' => ! $parsed['is_worktree'] ? $this->build_primary_freshness_report($repo_path, $parsed['dir_name']) : null, ); } } diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 0814bbfd..2d7fb3d0 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -842,6 +842,10 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra $disk ); + if ( $is_primary ) { + $row['primary_freshness'] = $this->build_primary_freshness_report($wt['path'], $handle); + } + $base_branch_warning = $this->base_branch_worktree_warning($row); if ( null !== $base_branch_warning ) { $row['base_branch_warning'] = $base_branch_warning; @@ -1289,6 +1293,22 @@ private function worktree_behind_default_branch_error( int $behind, string $defa ); } + /** + * Remove a worktree rejected after creation and delete its new local branch. + * + * @param string $primary_path Primary checkout path. + * @param string $wt_path Worktree path. + * @param string $branch Branch checked out in the worktree. + * @param bool $created_branch Whether the branch was created by this call. + * @return void + */ + private function rollback_rejected_worktree( string $primary_path, string $wt_path, string $branch, bool $created_branch ): void { + $this->run_git($primary_path, sprintf('worktree remove --force %s', escapeshellarg($wt_path))); + if ( $created_branch ) { + $this->run_git($primary_path, sprintf('branch -D %s', escapeshellarg($branch))); + } + } + /** * Does a ref look like a remote-tracking ref? * diff --git a/tests/smoke-workspace-clone-ux.php b/tests/smoke-workspace-clone-ux.php index 44f8a5a5..2f8142d2 100644 --- a/tests/smoke-workspace-clone-ux.php +++ b/tests/smoke-workspace-clone-ux.php @@ -196,6 +196,34 @@ function do_action( string $_hook, array $_payload ): void $assert_same('start', $events[0]['phase'] ?? null, 'first progress event has start phase'); $assert(str_contains($events[0]['message'] ?? '', 'Cloning'), 'start progress includes clone context'); + echo "\n[3a] Duplicate same-remote primary is refused with reuse guidance\n"; + $duplicate = $workspace_object->clone_repo($upstream, 'fixture-clone-copy'); + $assert(is_wp_error($duplicate), 'same remote clone reports an error'); + $assert_same('repo_remote_exists', $duplicate->get_error_code(), 'same remote clone uses repo_remote_exists code'); + $assert(str_contains($duplicate->get_error_message(), 'fixture-clone'), 'same remote clone names the existing primary'); + $assert(str_contains($duplicate->get_error_message(), 'workspace git pull fixture-clone --allow-primary-mutation'), 'same remote clone includes refresh command'); + $assert(str_contains($duplicate->get_error_message(), 'workspace worktree add fixture-clone '), 'same remote clone includes worktree command'); + $exact_duplicate = $workspace_object->clone_repo($upstream); + $assert(is_wp_error($exact_duplicate), 'exact same remote clone reports an error'); + $assert_same('repo_remote_exists', $exact_duplicate->get_error_code(), 'exact same remote clone uses repo_remote_exists code'); + $exact_duplicate_opt_in = $workspace_object->clone_repo($upstream, 'fixture-clone', array( 'allow_duplicate_remote' => true )); + $assert(is_wp_error($exact_duplicate_opt_in), 'duplicate-remote opt-in does not overwrite an existing target'); + $assert_same('repo_exists', $exact_duplicate_opt_in->get_error_code(), 'duplicate-remote opt-in preserves target-exists error for exact path'); + $intentional_duplicate = $workspace_object->clone_repo($upstream, 'fixture-release-copy', array( 'allow_duplicate_remote' => true )); + $assert(! is_wp_error($intentional_duplicate), 'explicit duplicate-remote opt-in permits custom primary clone'); + $assert(is_dir($workspace . '/fixture-release-copy/.git'), 'explicit duplicate-remote clone creates custom primary'); + + echo "\n[3b] Primary freshness reports stale local remote refs\n"; + file_put_contents($upstream . '/README.md', "# fixture\n\nupdated\n"); + $run('git -C ' . escapeshellarg($upstream) . ' add README.md'); + $run('git -C ' . escapeshellarg($upstream) . ' -c user.name=Test -c user.email=test@example.com commit -q -m update'); + $run('git -C ' . escapeshellarg($workspace . '/fixture-clone') . ' fetch -q origin'); + $show = $workspace_object->show_repo('fixture-clone'); + $assert(! is_wp_error($show), 'show_repo succeeds for primary'); + $assert_same('stale', $show['primary_freshness']['status'] ?? null, 'primary freshness reports stale status'); + $assert_same(1, $show['primary_freshness']['behind'] ?? null, 'primary freshness reports behind count'); + $assert(str_contains($show['primary_freshness']['suggested_command'] ?? '', 'workspace git pull fixture-clone --allow-primary-mutation'), 'primary freshness includes refresh command'); + $remote_url = getenv('REMOTE_CLONE_URL'); if (is_string($remote_url) && '' !== trim($remote_url) ) { echo "\n[4] Remote clone smoke\n";