diff --git a/docs/primary-mirror-architecture.md b/docs/primary-mirror-architecture.md new file mode 100644 index 00000000..0e09d2dc --- /dev/null +++ b/docs/primary-mirror-architecture.md @@ -0,0 +1,36 @@ +# Primary Mirror Architecture + +## Problem + +DMC primary checkouts currently serve three roles at once: default-branch reference, mutable working tree, and base for creating task worktrees. That makes stale reads and accidental default-branch mutations likely. + +## Target Model + +- A primary mirror is DMC-owned reference state for a remote default branch. +- Agents do feature, release, and repair work only in managed `repo@slug` worktrees. +- Mirror refresh is a narrow operation: fetch and fast-forward to the tracked remote ref. +- Dangerous operations on mirrors are blocked by default and require explicit operator approval. +- Read surfaces fail closed when a mirror is stale, diverged, detached, or otherwise unsafe unless the caller opts in with stale-read metadata. + +## Migration Plan + +1. Keep existing primary directories as the compatibility mirror path. +2. Enforce tactical safety first: separate primary refresh from dangerous primary mutation, and guard stale primary reads. +3. Add mirror metadata to each primary row: remote URL, tracked ref, last refresh result, freshness state, and whether local commits/dirty state block automated conversion. +4. For clean/current primaries, mark them `mirror_compatible` and allow DMC to refresh them as mirrors. +5. For dirty/diverged primaries, keep them read-protected and report a non-destructive remediation plan: preserve local commits in a worktree, push/open PR if needed, or archive before mirror conversion. +6. After migration, create release/default-branch work via explicit managed worktrees, not the mirror. + +## Command Shape + +- Investigation: `workspace show ` then `workspace read @ ...` or `workspace read ... --allow-stale-primary` when intentionally inspecting stale local state. +- Refresh: `workspace git pull --allow-primary-refresh`. +- Feature work: `workspace worktree add --from=origin/`. +- Dangerous primary repair: `workspace git --allow-dangerous-primary-mutation` with operator approval. + +## Open Decisions + +- Whether mirrors should become bare repositories or remain non-bare worktrees with stricter DMC-owned metadata. +- Whether stale primary reads should stay fail-closed permanently or return warnings for privileged UI callers. +- How long to keep compatibility for `--allow-primary-mutation` as a pull alias. +- Whether release checkouts need a distinct lifecycle state separate from normal task worktrees. diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 1022166f..e7bacca8 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -238,26 +238,30 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'repo' => array( + 'repo' => array( 'type' => 'string', 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), - 'path' => array( + 'path' => array( 'type' => 'string', 'description' => 'Relative file path within the repo.', ), - 'max_size' => array( + 'max_size' => array( 'type' => 'integer', 'description' => 'Maximum file size in bytes (default 1 MB).', ), - 'offset' => array( + 'offset' => array( 'type' => 'integer', 'description' => 'Line number to start reading from (1-indexed).', ), - 'limit' => array( + 'limit' => array( 'type' => 'integer', 'description' => 'Maximum number of lines to return.', ), + 'allow_stale_primary' => array( + 'type' => 'boolean', + 'description' => 'Explicitly allow reading from a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.', + ), ), 'required' => array( 'path' ), ), @@ -287,14 +291,18 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'repo' => array( + 'repo' => array( 'type' => 'string', 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), - 'path' => array( + 'path' => array( 'type' => 'string', 'description' => 'Relative directory path within the repo (omit for root).', ), + 'allow_stale_primary' => array( + 'type' => 'boolean', + 'description' => 'Explicitly allow listing a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.', + ), ), 'required' => array(), ), @@ -332,30 +340,34 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'repo' => array( + 'repo' => array( 'type' => 'string', 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), - 'pattern' => array( + 'pattern' => array( 'type' => 'string', 'description' => 'Regular expression pattern to search for.', ), - 'path' => array( + 'path' => array( 'type' => 'string', 'description' => 'Optional relative file or directory path to search within.', ), - 'include' => array( + 'include' => array( 'type' => 'string', 'description' => 'Optional glob pattern to limit matching file paths.', ), - 'max_results' => array( + 'max_results' => array( 'type' => 'integer', 'description' => 'Maximum number of matches to return (default 100, max 500).', ), - 'context_lines' => array( + 'context_lines' => array( 'type' => 'integer', 'description' => 'Number of surrounding lines to include for each match (default 0, max 10).', ), + 'allow_stale_primary' => array( + 'type' => 'boolean', + 'description' => 'Explicitly allow grepping a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.', + ), ), 'required' => array( 'pattern' ), ), @@ -824,7 +836,7 @@ private function registerAbilities(): void { 'datamachine-code/workspace-git-pull', array( 'label' => 'Workspace Git Pull', - 'description' => 'Run git pull --ff-only for a workspace handle. Mutating ops on the primary checkout require allow_primary_mutation=true.', + 'description' => 'Run git pull --ff-only for a workspace handle. Primary refresh requires allow_primary_refresh=true; worktrees are always allowed.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', @@ -837,9 +849,13 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Allow pull when working tree is dirty.', ), + 'allow_primary_refresh' => array( + 'type' => 'boolean', + 'description' => 'Permit safe primary refresh with git pull --ff-only. Worktrees are always allowed.', + ), 'allow_primary_mutation' => array( 'type' => 'boolean', - 'description' => 'Permit mutation on the primary checkout (default false). Worktrees are always allowed.', + 'description' => 'Legacy alias for allow_primary_refresh on git pull only.', ), 'remote' => array( 'type' => 'string', @@ -965,22 +981,22 @@ private function registerAbilities(): void { 'datamachine-code/workspace-git-commit', array( 'label' => 'Workspace Git Commit', - 'description' => 'Commit staged changes in a workspace handle. Mutating ops on the primary checkout require allow_primary_mutation=true.', + 'description' => 'Commit staged changes in a workspace handle. Primary commits require allow_dangerous_primary_mutation=true; use a worktree whenever possible.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'type' => 'string', 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), - 'message' => array( + 'message' => array( 'type' => 'string', 'description' => 'Commit message.', ), - 'allow_primary_mutation' => array( + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', - 'description' => 'Permit mutation on the primary checkout (default false). Worktrees are always allowed.', + 'description' => 'Permit committing on a primary checkout. Use only for an explicitly approved primary mutation.', ), ), 'required' => array( 'name', 'message' ), @@ -1009,27 +1025,27 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'type' => 'string', 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), - 'remote' => array( + 'remote' => array( 'type' => 'string', 'description' => 'Remote name (default origin).', ), - 'branch' => array( + 'branch' => array( 'type' => 'string', 'description' => 'Branch override.', ), - 'allow_primary_mutation' => array( + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', - 'description' => 'Permit pushing from the primary checkout (default false). Worktrees are always allowed.', + 'description' => 'Permit pushing from a primary checkout. Use only for an explicitly approved primary mutation.', ), - 'force_with_lease' => array( + 'force_with_lease' => array( 'type' => 'boolean', 'description' => 'Use git push --force-with-lease. Refuses protected base/fixed branches.', ), - 'expected_sha' => array( + 'expected_sha' => array( 'type' => 'string', 'description' => 'Optional expected remote branch SHA for --force-with-lease.', ), @@ -1099,29 +1115,29 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'type' => 'string', 'description' => 'Workspace handle: `` or `@`.', ), - 'onto' => array( + 'onto' => array( 'type' => 'string', 'description' => 'Base ref to rebase onto. Defaults to origin/HEAD or origin/.', ), - 'interactive' => array( + 'interactive' => array( 'type' => 'boolean', 'description' => 'Reserved; interactive rebases are not supported.', ), - 'strategy_option' => array( + 'strategy_option' => array( 'type' => 'string', 'description' => 'Optional git strategy option such as theirs or ours.', ), - 'continue' => array( + 'continue' => array( 'type' => 'boolean', 'description' => 'Continue an in-progress rebase after conflicts were resolved and staged.', ), - 'allow_primary_mutation' => array( + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', - 'description' => 'Permit mutation on a primary checkout. Default false.', + 'description' => 'Permit rebasing a primary checkout. Use only for an explicitly approved primary mutation.', ), ), 'required' => array( 'name' ), @@ -1142,26 +1158,26 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'type' => 'string', 'description' => 'Workspace handle: `` or `@`.', ), - 'mode' => array( + 'mode' => array( 'type' => 'string', 'enum' => array( 'soft', 'mixed', 'hard' ), 'description' => 'Reset mode. Default mixed.', ), - 'target' => array( + 'target' => array( 'type' => 'string', 'description' => 'Target ref or commit. Defaults to origin/HEAD or origin/.', ), - 'allow_destructive' => array( + 'allow_destructive' => array( 'type' => 'boolean', 'description' => 'Required for hard reset.', ), - 'allow_primary_mutation' => array( + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', - 'description' => 'Permit mutation on a primary checkout. Default false.', + 'description' => 'Permit resetting a primary checkout. Use only for an explicitly approved primary mutation.', ), ), 'required' => array( 'name' ), @@ -1225,26 +1241,26 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'type' => 'string', 'description' => 'Workspace handle.', ), - 'pr' => array( + 'pr' => array( 'type' => array( 'string', 'integer' ), 'description' => 'PR number or URL. Defaults to the current branch PR.', ), - 'squash' => array( + 'squash' => array( 'type' => 'boolean', 'description' => 'Squash rebased commits into one PR-title commit before pushing.', ), - 'drop_paths' => array( + 'drop_paths' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'description' => 'Glob patterns to resolve by taking the base version during rebase conflicts.', ), - 'allow_primary_mutation' => array( + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', - 'description' => 'Permit mutation on a primary checkout. Default false.', + 'description' => 'Permit rebasing and force-with-lease pushing from a primary checkout. Use only for an explicitly approved primary mutation.', ), ), 'required' => array( 'name' ), @@ -2568,7 +2584,8 @@ public static function readFile( array $input ): array|\WP_Error { $input['path'] ?? '', isset($input['max_size']) ? (int) $input['max_size'] : Workspace::MAX_READ_SIZE, isset($input['offset']) ? (int) $input['offset'] : null, - isset($input['limit']) ? (int) $input['limit'] : null + isset($input['limit']) ? (int) $input['limit'] : null, + ! empty($input['allow_stale_primary']) ); } @@ -2590,7 +2607,8 @@ public static function readFile( array $input ): array|\WP_Error { $input['path'] ?? '', isset($input['max_size']) ? (int) $input['max_size'] : Workspace::MAX_READ_SIZE, isset($input['offset']) ? (int) $input['offset'] : null, - isset($input['limit']) ? (int) $input['limit'] : null + isset($input['limit']) ? (int) $input['limit'] : null, + ! empty($input['allow_stale_primary']) ); } @@ -2607,7 +2625,8 @@ public static function listDirectory( array $input ): array|\WP_Error { if ( RemoteWorkspaceBackend::should_handle() && null !== self::showLocalWorkspaceHandleIfPresent($workspace, (string) ( $input['repo'] ?? '' )) ) { return $reader->list_directory( $input['repo'] ?? '', - $input['path'] ?? null + $input['path'] ?? null, + ! empty($input['allow_stale_primary']) ); } @@ -2623,7 +2642,8 @@ public static function listDirectory( array $input ): array|\WP_Error { return $reader->list_directory( $input['repo'] ?? '', - $input['path'] ?? null + $input['path'] ?? null, + ! empty($input['allow_stale_primary']) ); } @@ -2644,7 +2664,8 @@ public static function grepFiles( array $input ): array|\WP_Error { $input['path'] ?? null, $input['include'] ?? null, isset($input['max_results']) ? (int) $input['max_results'] : 100, - isset($input['context_lines']) ? (int) $input['context_lines'] : 0 + isset($input['context_lines']) ? (int) $input['context_lines'] : 0, + ! empty($input['allow_stale_primary']) ); } @@ -2668,7 +2689,8 @@ public static function grepFiles( array $input ): array|\WP_Error { $input['path'] ?? null, $input['include'] ?? null, isset($input['max_results']) ? (int) $input['max_results'] : 100, - isset($input['context_lines']) ? (int) $input['context_lines'] : 0 + isset($input['context_lines']) ? (int) $input['context_lines'] : 0, + ! empty($input['allow_stale_primary']) ); } @@ -3060,7 +3082,7 @@ public static function gitPull( array $input ): array|\WP_Error { return $workspace->git_pull( $input['name'] ?? '', ! empty($input['allow_dirty']), - ! empty($input['allow_primary_mutation']), + ! empty($input['allow_primary_refresh']) || ! empty($input['allow_primary_mutation']), (string) ( $input['remote'] ?? 'origin' ), isset($input['branch']) ? (string) $input['branch'] : null ); @@ -3127,7 +3149,7 @@ public static function gitCommit( array $input ): array|\WP_Error { return $workspace->git_commit( $input['name'] ?? '', $input['message'] ?? '', - ! empty($input['allow_primary_mutation']) + ! empty($input['allow_dangerous_primary_mutation']) ); } @@ -3152,7 +3174,7 @@ public static function gitPush( array $input ): array|\WP_Error { $input['name'] ?? '', $input['remote'] ?? 'origin', $input['branch'] ?? null, - ! empty($input['allow_primary_mutation']), + ! empty($input['allow_dangerous_primary_mutation']), ! empty($input['force_with_lease']), $input['expected_sha'] ?? null ); @@ -3420,7 +3442,7 @@ public static function gitRebase( array $input ): array|\WP_Error { $input['onto'] ?? null, $input['strategy_option'] ?? null, ! empty($input['continue']), - ! empty($input['allow_primary_mutation']) + ! empty($input['allow_dangerous_primary_mutation']) ); } @@ -3437,7 +3459,7 @@ public static function gitReset( array $input ): array|\WP_Error { $input['mode'] ?? 'mixed', $input['target'] ?? null, ! empty($input['allow_destructive']), - ! empty($input['allow_primary_mutation']) + ! empty($input['allow_dangerous_primary_mutation']) ); } @@ -3474,7 +3496,7 @@ public static function prRebase( array $input ): array|\WP_Error { $input['pr'] ?? null, ! empty($input['squash']), $drop_paths, - ! empty($input['allow_primary_mutation']) + ! empty($input['allow_dangerous_primary_mutation']) ); } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 43aedf00..b3287dc2 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -1910,11 +1910,14 @@ public function show( array $args, array $assoc_args ): void { * default: 1048576 * --- * - * [--offset=] - * : Line number to start reading from (1-indexed). - * - * [--limit=] - * : Maximum number of lines to return. + * [--offset=] + * : Line number to start reading from (1-indexed). + * + * [--limit=] + * : Maximum number of lines to return. + * + * [--allow-stale-primary] + * : Explicitly read from a stale, diverged, detached, or otherwise unsafe primary checkout. * * ## EXAMPLES * @@ -1958,6 +1961,10 @@ public function read( array $args, array $assoc_args ): void { $input['limit'] = (int) $assoc_args['limit']; } + if ( ! empty($assoc_args['allow-stale-primary']) ) { + $input['allow_stale_primary'] = true; + } + $result = $ability->execute($input); if ( is_wp_error($result) ) { @@ -1980,12 +1987,15 @@ public function read( array $args, array $assoc_args ): void { * * : Repository directory name. * - * [] - * : Relative directory path within the repo (defaults to root). - * - * [--format=] - * : Output format. - * --- + * [] + * : Relative directory path within the repo (defaults to root). + * + * [--allow-stale-primary] + * : Explicitly list a stale, diverged, detached, or otherwise unsafe primary checkout. + * + * [--format=] + * : Output format. + * --- * default: table * options: * - table @@ -2025,6 +2035,10 @@ public function ls( array $args, array $assoc_args ): void { $input['path'] = $args[1]; } + if ( ! empty($assoc_args['allow-stale-primary']) ) { + $input['allow_stale_primary'] = true; + } + $result = $ability->execute($input); if ( is_wp_error($result) ) { @@ -2079,15 +2093,18 @@ function ( $entry ) { * default: 100 * --- * - * [--context-lines=] - * : Number of surrounding lines to include for each match. - * --- - * default: 0 - * --- - * - * [--format=] - * : Output format. - * --- + * [--context-lines=] + * : Number of surrounding lines to include for each match. + * --- + * default: 0 + * --- + * + * [--allow-stale-primary] + * : Explicitly grep a stale, diverged, detached, or otherwise unsafe primary checkout. + * + * [--format=] + * : Output format. + * --- * default: table * options: * - table @@ -2135,6 +2152,10 @@ public function grep( array $args, array $assoc_args ): void { $input['context_lines'] = (int) $assoc_args['context-lines']; } + if ( ! empty($assoc_args['allow-stale-primary']) ) { + $input['allow_stale_primary'] = true; + } + $result = $ability->execute($input); if ( is_wp_error($result) ) { WP_CLI::error($result->get_error_message()); @@ -2601,11 +2622,17 @@ private function resolveAtFile( string $value ): string { * : Relative path (repeatable) for add/diff operations. Named `--rel` * to avoid colliding with WP-CLI's documented global `--path` flag. * - * [--allow-dirty] - * : Allow pull with dirty working tree. - * - * [--allow-primary-mutation] - * : Permit mutating ops (pull/add/commit/push) on the primary checkout. Default-deny — use a worktree handle (`@`) instead whenever possible. + * [--allow-dirty] + * : Allow pull with dirty working tree. + * + * [--allow-primary-refresh] + * : Permit safe primary refresh with `git pull --ff-only`. + * + * [--allow-primary-mutation] + * : Legacy alias for `--allow-primary-refresh` on `git pull`, and for non-dangerous primary file/index mutations. Does not permit primary commit, push, reset, or rebase. + * + * [--allow-dangerous-primary-mutation] + * : Permit primary commit, push, reset, or rebase. Use only for an explicitly approved primary mutation. * * [--remote=] * : Remote name for pull/push (default: origin). @@ -2687,8 +2714,11 @@ public function git( array $args, array $assoc_args ): void { $input = array( 'name' => $repo ); - // Mutating ops accept --allow-primary-mutation to operate on a primary checkout. - if ( in_array($operation, array( 'pull', 'add', 'commit', 'push', 'rebase', 'reset', 'pr-rebase' ), true) ) { + if ( 'pull' === $operation ) { + $input['allow_primary_refresh'] = ! empty($assoc_args['allow-primary-refresh']) || ! empty($assoc_args['allow-primary-mutation']); + } elseif ( in_array($operation, array( 'commit', 'push', 'rebase', 'reset', 'pr-rebase' ), true) ) { + $input['allow_dangerous_primary_mutation'] = ! empty($assoc_args['allow-dangerous-primary-mutation']); + } elseif ( in_array($operation, array( 'add' ), true) ) { $input['allow_primary_mutation'] = ! empty($assoc_args['allow-primary-mutation']); } diff --git a/inc/Runtime/AgentsMdSections.php b/inc/Runtime/AgentsMdSections.php index 646d6ae1..e1b7e150 100644 --- a/inc/Runtime/AgentsMdSections.php +++ b/inc/Runtime/AgentsMdSections.php @@ -144,8 +144,8 @@ private static function register_datamachine_section( string $wp ): void { - **GitHub:** `{$wp} datamachine-code github {$github_subcmds}` — list/read GitHub state, manage issues and PRs, install review flows, and comment on reviews. - **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. - **Workspace lifecycle:** use `workspace clone` for primary checkout adoption/cloning and `workspace worktree add` for isolated branches. Use the CLI `--help` output for current flags and subcommands. -- **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. -- **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. +- **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-refresh` or create the worktree from an explicit remote ref with `worktree add --from=origin/`. Stale primary reads require an explicit `--allow-stale-primary` opt-in. Do not clone a second top-level primary for the same remote just to get fresh code. +- **Primary is read-only.** Never edit `/` (no `@slug`). Safe primary refresh uses `--allow-primary-refresh`; primary commit, push, reset, and rebase require the stronger `--allow-dangerous-primary-mutation` approval. 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. **System:** `{$wp} datamachine system health|prompts|run` — site health, prompt inspection, diagnostic runs. diff --git a/inc/Tools/WorkspaceTools.php b/inc/Tools/WorkspaceTools.php index 4db9e5d7..732c9118 100644 --- a/inc/Tools/WorkspaceTools.php +++ b/inc/Tools/WorkspaceTools.php @@ -302,6 +302,9 @@ public function handleLs( array $parameters ): array if (isset($input['_workspace_alias_error']) ) { return $this->buildErrorResponse((string) $input['_workspace_alias_error'], 'workspace_ls'); } + if (array_key_exists('allow_stale_primary', $parameters) ) { + $input['allow_stale_primary'] = (bool) $parameters['allow_stale_primary']; + } $result = $ability->execute($input); if (is_wp_error($result) ) { @@ -350,6 +353,10 @@ public function handleRead( array $parameters ): array $input['limit'] = (int) $parameters['limit']; } + if (array_key_exists('allow_stale_primary', $parameters) ) { + $input['allow_stale_primary'] = (bool) $parameters['allow_stale_primary']; + } + $result = $ability->execute($input); if (is_wp_error($result) ) { @@ -397,6 +404,9 @@ public function handleGrep( array $parameters ): array $input[ $key ] = (int) $parameters[ $key ]; } } + if (array_key_exists('allow_stale_primary', $parameters) ) { + $input['allow_stale_primary'] = (bool) $parameters['allow_stale_primary']; + } $result = $ability->execute($input); @@ -525,7 +535,7 @@ public function handleGitDiff( array $parameters ): array public function handleGitPull( array $parameters ): array { $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - foreach ( array( 'allow_dirty', 'allow_primary_mutation' ) as $key ) { + foreach ( array( 'allow_dirty', 'allow_primary_refresh', 'allow_primary_mutation' ) as $key ) { if (array_key_exists($key, $parameters) ) { $input[ $key ] = (bool) $parameters[ $key ]; } @@ -584,8 +594,8 @@ public function handleGitCommit( array $parameters ): array 'name' => $parameters['name'] ?? $parameters['repo'] ?? '', 'message' => $parameters['message'] ?? '', ); - if (array_key_exists('allow_primary_mutation', $parameters) ) { - $input['allow_primary_mutation'] = (bool) $parameters['allow_primary_mutation']; + if (array_key_exists('allow_dangerous_primary_mutation', $parameters) ) { + $input['allow_dangerous_primary_mutation'] = (bool) $parameters['allow_dangerous_primary_mutation']; } return $this->executeAbility('datamachine-code/workspace-git-commit', 'workspace_git_commit', $input, array( 'name' )); @@ -602,7 +612,7 @@ public function handleGitPush( array $parameters ): array $input[ $key ] = $parameters[ $key ]; } } - foreach ( array( 'allow_primary_mutation', 'force_with_lease' ) as $key ) { + foreach ( array( 'allow_dangerous_primary_mutation', 'force_with_lease' ) as $key ) { if (array_key_exists($key, $parameters) ) { $input[ $key ] = (bool) $parameters[ $key ]; } @@ -654,7 +664,7 @@ public function handleGitRebase( array $parameters ): array $input[ $key ] = $parameters[ $key ]; } } - foreach ( array( 'continue', 'allow_primary_mutation' ) as $key ) { + foreach ( array( 'continue', 'allow_dangerous_primary_mutation' ) as $key ) { if (array_key_exists($key, $parameters) ) { $input[ $key ] = (bool) $parameters[ $key ]; } @@ -674,7 +684,7 @@ public function handleGitReset( array $parameters ): array $input[ $key ] = $parameters[ $key ]; } } - foreach ( array( 'allow_destructive', 'allow_primary_mutation' ) as $key ) { + foreach ( array( 'allow_destructive', 'allow_dangerous_primary_mutation' ) as $key ) { if (array_key_exists($key, $parameters) ) { $input[ $key ] = (bool) $parameters[ $key ]; } @@ -710,7 +720,7 @@ public function handlePrRebase( array $parameters ): array if (isset($parameters['drop_paths']) && is_array($parameters['drop_paths']) ) { $input['drop_paths'] = $parameters['drop_paths']; } - foreach ( array( 'squash', 'allow_primary_mutation' ) as $key ) { + foreach ( array( 'squash', 'allow_dangerous_primary_mutation' ) as $key ) { if (array_key_exists($key, $parameters) ) { $input[ $key ] = (bool) $parameters[ $key ]; } @@ -1127,10 +1137,14 @@ public function getLsDefinition(): array 'type' => 'string', 'description' => 'Workspace repository directory name.', ), - 'path' => array( - 'type' => 'string', - 'description' => 'Optional relative directory path inside the repo.', - ), + 'path' => array( + 'type' => 'string', + 'description' => 'Optional relative directory path inside the repo.', + ), + 'allow_stale_primary' => array( + 'type' => 'boolean', + 'description' => 'Explicitly list a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.', + ), ), 'required' => array(), ), @@ -1169,10 +1183,14 @@ public function getReadDefinition(): array 'type' => 'integer', 'description' => 'Line offset to start reading from (1-indexed).', ), - 'limit' => array( - 'type' => 'integer', - 'description' => 'Maximum number of lines to return.', - ), + 'limit' => array( + 'type' => 'integer', + 'description' => 'Maximum number of lines to return.', + ), + 'allow_stale_primary' => array( + 'type' => 'boolean', + 'description' => 'Explicitly read from a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.', + ), ), 'required' => array( 'path' ), ), @@ -1215,10 +1233,14 @@ public function getGrepDefinition(): array 'type' => 'integer', 'description' => 'Maximum number of matches to return (default 100, max 500).', ), - 'context_lines' => array( - 'type' => 'integer', - 'description' => 'Number of surrounding lines to include for each match (default 0, max 10).', - ), + 'context_lines' => array( + 'type' => 'integer', + 'description' => 'Number of surrounding lines to include for each match (default 0, max 10).', + ), + 'allow_stale_primary' => array( + 'type' => 'boolean', + 'description' => 'Explicitly grep a stale, diverged, detached, or otherwise unsafe primary checkout. Worktree reads are unaffected.', + ), ), 'required' => array( 'pattern' ), ), @@ -1368,7 +1390,8 @@ public function getGitPullDefinition(): array return $this->simpleGitDefinition( 'handleGitPull', 'Run git pull --ff-only for a workspace handle. Policy-gated for pipeline use.', array( 'allow_dirty' => array( 'type' => 'boolean', 'description' => 'Allow pull when working tree is dirty. Default false.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), + 'allow_primary_refresh' => array( 'type' => 'boolean', 'description' => 'Permit safe primary refresh with git pull --ff-only. Default false.' ), + 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Legacy alias for allow_primary_refresh on git pull only.' ), ), array( 'name' ), array( 'completion_signal' => 'progress' ) ); } @@ -1424,7 +1447,7 @@ public function getGitCommitDefinition(): array return $this->simpleGitDefinition( 'handleGitCommit', 'Commit staged changes in a workspace handle. Policy-gated for pipeline use.', array( 'message' => array( 'type' => 'string', 'description' => 'Commit message.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit committing on a primary checkout. Use only for an explicitly approved primary mutation.' ), ), array( 'name', 'message' ), array( 'completion_signal' => 'progress' ) ); } @@ -1438,7 +1461,7 @@ public function getGitPushDefinition(): array 'handleGitPush', 'Push commits for a workspace handle. Policy-gated for pipeline use.', array( 'remote' => array( 'type' => 'string', 'description' => 'Remote name. Default origin.' ), 'branch' => array( 'type' => 'string', 'description' => 'Branch override.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit pushing from a primary checkout. Use only for an explicitly approved primary mutation.' ), 'force_with_lease' => array( 'type' => 'boolean', 'description' => 'Use --force-with-lease. Refuses protected base/fixed branches.' ), 'expected_sha' => array( 'type' => 'string', 'description' => 'Optional expected remote branch SHA.' ), ), array( 'name' ), array( 'completion_signal' => 'progress' ) @@ -1592,7 +1615,7 @@ public function getGitRebaseDefinition(): array 'onto' => array( 'type' => 'string', 'description' => 'Base ref to rebase onto.' ), 'strategy_option' => array( 'type' => 'string', 'description' => 'Optional strategy option, such as theirs or ours.' ), 'continue' => array( 'type' => 'boolean', 'description' => 'Continue an in-progress rebase.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit rebasing a primary checkout. Use only for an explicitly approved primary mutation.' ), ), array( 'name' ), array( 'completion_signal' => 'progress' ) ); } @@ -1607,7 +1630,7 @@ public function getGitResetDefinition(): array 'mode' => array( 'type' => 'string', 'enum' => array( 'soft', 'mixed', 'hard' ), 'description' => 'Reset mode.' ), 'target' => array( 'type' => 'string', 'description' => 'Target ref or commit.' ), 'allow_destructive' => array( 'type' => 'boolean', 'description' => 'Required for hard reset.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit resetting a primary checkout. Use only for an explicitly approved primary mutation.' ), ), array( 'name' ), array( 'completion_signal' => 'progress' ) ); } @@ -1635,7 +1658,7 @@ public function getPrRebaseDefinition(): array 'pr' => array( 'type' => array( 'string', 'integer' ), 'description' => 'PR number or URL.' ), 'squash' => array( 'type' => 'boolean', 'description' => 'Squash rebased commits into one PR-title commit.' ), 'drop_paths' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'description' => 'Conflict path globs to resolve by taking the base version.' ), - 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), + 'allow_dangerous_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit rebasing and force-with-lease pushing from a primary checkout. Use only for an explicitly approved primary mutation.' ), ), array( 'name' ), array( 'completion_signal' => 'progress' ) ); } diff --git a/inc/Workspace/WorkspaceCoreUtilities.php b/inc/Workspace/WorkspaceCoreUtilities.php index 7162a789..a5659e75 100644 --- a/inc/Workspace/WorkspaceCoreUtilities.php +++ b/inc/Workspace/WorkspaceCoreUtilities.php @@ -511,6 +511,7 @@ private function normalize_git_remote_url( string $url ): string { $url = 'ssh://' . $matches[2] . '/' . $matches[3]; } + // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- wp_parse_url() is unavailable in pure-PHP smoke tests. $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']); @@ -592,21 +593,21 @@ private function build_primary_freshness_report( string $repo_path, string $hand ); } - $header = strtok((string) ( $status_result['output'] ?? '' ), "\n"); + $header = strtok( (string) ( $status_result['output'] ?? '' ), "\n"); $header = false === $header ? '' : trim($header); $branch = null; $detached = false; - $upstream = null; + $upstream = null; $behind = 0; $ahead = 0; $status = 'unknown'; if ( preg_match('/^## HEAD \(no branch\)/', $header) ) { $detached = true; - $status = 'detached'; + $status = 'detached'; } elseif ( preg_match('/^## (.+?)(?:\.\.\.([^\s\[]+))?(?: \[(.+)\])?$/', $header, $matches) ) { - $branch = trim((string) $matches[1]); - $upstream = isset($matches[2]) && '' !== $matches[2] ? trim((string) $matches[2]) : null; + $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) ) { @@ -654,7 +655,55 @@ private function build_primary_freshness_report( string $repo_path, string $hand * @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); + return sprintf('wp datamachine-code workspace git pull %s --allow-primary-refresh', $handle); + } + + /** + * Guard reads from stale or otherwise unsafe primary checkouts. + * + * @param string $handle Workspace handle. + * @param bool $allow_stale_primary Whether stale primary reads are explicitly allowed. + * @return true|\WP_Error + */ + public function ensure_primary_read_allowed( string $handle, bool $allow_stale_primary = false ): true|\WP_Error { + $parsed = $this->parse_handle($handle); + if ( $parsed['is_worktree'] || $allow_stale_primary ) { + return true; + } + + $repo_path = $this->workspace_path . '/' . $parsed['dir_name']; + if ( ! is_dir($repo_path) ) { + return true; + } + + $freshness = $this->build_primary_freshness_report($repo_path, $parsed['dir_name']); + if ( ! is_array($freshness) ) { + return true; + } + + $status = (string) ( $freshness['status'] ?? 'unknown' ); + if ( ! in_array($status, array( 'stale', 'diverged', 'detached', 'unknown', 'no_upstream', 'ahead' ), true) ) { + return true; + } + + $behind_value = $freshness['behind'] ?? null; + $ahead_value = $freshness['ahead'] ?? null; + + return new \WP_Error( + 'stale_primary_read_blocked', + sprintf( + 'Primary checkout "%s" is %s and may not reflect the current remote. Refresh with `%s`, read a fresh worktree, or pass allow_stale_primary=true to opt in. Behind: %s. Ahead: %s.', + $parsed['repo'], + $status, + (string) ( $freshness['suggested_command'] ?? $this->primary_refresh_command($parsed['dir_name']) ), + null === $behind_value ? '-' : (string) $behind_value, + null === $ahead_value ? '-' : (string) $ahead_value + ), + array( + 'status' => 409, + 'primary_freshness' => $freshness, + ) + ); } /** diff --git a/inc/Workspace/WorkspaceGitOperations.php b/inc/Workspace/WorkspaceGitOperations.php index 4de755a6..5b640541 100644 --- a/inc/Workspace/WorkspaceGitOperations.php +++ b/inc/Workspace/WorkspaceGitOperations.php @@ -93,7 +93,7 @@ public function git_pull( string $handle, bool $allow_dirty = false, bool $allow return $policy_check; } - $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation); + $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation, 'Pass allow_primary_refresh=true to refresh it'); if ( is_wp_error($primary_check) ) { return $primary_check; } @@ -513,7 +513,7 @@ public function git_commit( string $handle, string $message, bool $allow_primary return $policy_check; } - $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation); + $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation, 'Pass allow_dangerous_primary_mutation=true to commit on it'); if ( is_wp_error($primary_check) ) { return $primary_check; } @@ -595,7 +595,7 @@ public function git_push( string $handle, string $remote = 'origin', ?string $br return $policy_check; } - $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation); + $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation, 'Pass allow_dangerous_primary_mutation=true to push from it'); if ( is_wp_error($primary_check) ) { return $primary_check; } @@ -718,7 +718,7 @@ public function git_rebase( string $handle, ?string $onto = null, ?string $strat return $policy_check; } - $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation); + $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation, 'Pass allow_dangerous_primary_mutation=true to rebase it'); if ( is_wp_error($primary_check) ) { return $primary_check; } @@ -792,7 +792,7 @@ public function git_reset( string $handle, string $mode = 'mixed', ?string $targ return $policy_check; } - $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation); + $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation, 'Pass allow_dangerous_primary_mutation=true to reset it'); if ( is_wp_error($primary_check) ) { return $primary_check; } @@ -889,7 +889,7 @@ public function pr_rebase( string $handle, string|int|null $pr = null, bool $squ return $policy_check; } - $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation); + $primary_check = $this->ensure_primary_mutation_allowed($parsed, $allow_primary_mutation, 'Pass allow_dangerous_primary_mutation=true to rebase it'); if ( is_wp_error($primary_check) ) { return $primary_check; } @@ -1163,7 +1163,7 @@ private function resolve_repo_path( string $handle ): string|\WP_Error { * @param bool $allow * @return true|\WP_Error */ - private function ensure_primary_mutation_allowed( array $parsed, bool $allow ): true|\WP_Error { + private function ensure_primary_mutation_allowed( array $parsed, bool $allow, string $allow_guidance = 'Pass allow_primary_mutation=true to operate on it' ): true|\WP_Error { if ( $parsed['is_worktree'] ) { return true; } @@ -1173,8 +1173,9 @@ private function ensure_primary_mutation_allowed( array $parsed, bool $allow ): return new \WP_Error( 'primary_mutation_blocked', sprintf( - 'Primary checkout "%s" is read-only by default. Pass allow_primary_mutation=true to operate on it, or use a worktree handle (e.g. %s@).', + 'Primary checkout "%s" is read-only by default. %s, or use a worktree handle (e.g. %s@).', $parsed['repo'], + $allow_guidance, $parsed['repo'] ), array( 'status' => 403 ) diff --git a/inc/Workspace/WorkspaceReader.php b/inc/Workspace/WorkspaceReader.php index a454588d..3b7ba514 100644 --- a/inc/Workspace/WorkspaceReader.php +++ b/inc/Workspace/WorkspaceReader.php @@ -39,12 +39,17 @@ public function __construct( Workspace $workspace ) { * @param int|null $limit Maximum number of lines to return. * @return array{success: bool, content?: string, path?: string, size?: int, lines_read?: int, offset?: int}|\WP_Error */ - public function read_file( string $name, string $path, int $max_size = Workspace::MAX_READ_SIZE, ?int $offset = null, ?int $limit = null ): array|\WP_Error { + public function read_file( string $name, string $path, int $max_size = Workspace::MAX_READ_SIZE, ?int $offset = null, ?int $limit = null, bool $allow_stale_primary = false ): array|\WP_Error { $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path); if ( null !== $policy_error ) { return $policy_error; } + $primary_read = $this->workspace->ensure_primary_read_allowed($name, $allow_stale_primary); + if ( is_wp_error($primary_read) ) { + return $primary_read; + } + $repo_path = $this->workspace->get_repo_path($name); $path = ltrim($path, '/'); @@ -144,12 +149,17 @@ public function read_file( string $name, string $path, int $max_size = Workspace * @param string|null $path Relative directory path within the repo (null for root). * @return array{success: bool, repo?: string, path?: string, entries?: array}|\WP_Error */ - public function list_directory( string $name, ?string $path = null ): array|\WP_Error { + public function list_directory( string $name, ?string $path = null, bool $allow_stale_primary = false ): array|\WP_Error { $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path ?? ''); if ( null !== $policy_error ) { return $policy_error; } + $primary_read = $this->workspace->ensure_primary_read_allowed($name, $allow_stale_primary); + if ( is_wp_error($primary_read) ) { + return $primary_read; + } + $repo_path = $this->workspace->get_repo_path($name); if ( ! is_dir($repo_path) ) { @@ -239,12 +249,17 @@ function ( $a, $b ) { * @param int $context_lines Number of surrounding lines to include. * @return array{success: bool, repo?: string, path?: string, pattern?: string, matches?: array, count?: int, truncated?: bool}|\WP_Error */ - public function grep( string $name, string $pattern, ?string $path = null, ?string $include_pattern = null, int $max_results = 100, int $context_lines = 0 ): array|\WP_Error { + public function grep( string $name, string $pattern, ?string $path = null, ?string $include_pattern = null, int $max_results = 100, int $context_lines = 0, bool $allow_stale_primary = false ): array|\WP_Error { $policy_error = WorkspaceAliasResolver::read_error_if_disallowed($name, $path ?? ''); if ( null !== $policy_error ) { return $policy_error; } + $primary_read = $this->workspace->ensure_primary_read_allowed($name, $allow_stale_primary); + if ( is_wp_error($primary_read) ) { + return $primary_read; + } + $repo_path = $this->workspace->get_repo_path($name); if ( ! is_dir($repo_path) ) { return new \WP_Error('repo_not_found', sprintf('Repository "%s" not found in workspace.', $name), array( 'status' => 404 )); diff --git a/tests/smoke-workspace-clone-ux.php b/tests/smoke-workspace-clone-ux.php index 2f8142d2..b0132c88 100644 --- a/tests/smoke-workspace-clone-ux.php +++ b/tests/smoke-workspace-clone-ux.php @@ -87,8 +87,11 @@ function do_action( string $_hook, array $_payload ): void } } - include __DIR__ . '/../inc/Support/GitRunner.php'; - include __DIR__ . '/../inc/Workspace/Workspace.php'; + include __DIR__ . '/../inc/Support/GitRunner.php'; + include __DIR__ . '/../inc/Support/PathSecurity.php'; + include __DIR__ . '/../inc/Workspace/WorkspaceAliasResolver.php'; + include __DIR__ . '/../inc/Workspace/Workspace.php'; + include __DIR__ . '/../inc/Workspace/WorkspaceReader.php'; $failures = 0; $total = 0; @@ -201,7 +204,7 @@ function do_action( string $_hook, array $_payload ): void $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 git pull fixture-clone --allow-primary-refresh'), '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'); @@ -222,7 +225,15 @@ function do_action( string $_hook, array $_payload ): void $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'); + $assert(str_contains($show['primary_freshness']['suggested_command'] ?? '', 'workspace git pull fixture-clone --allow-primary-refresh'), 'primary freshness includes refresh command'); + + echo "\n[3c] Stale primary reads require explicit opt-in\n"; + $reader = new \DataMachineCode\Workspace\WorkspaceReader($workspace_object); + $blocked_read = $reader->read_file('fixture-clone', 'README.md'); + $assert(is_wp_error($blocked_read), 'stale primary read is blocked by default'); + $assert_same('stale_primary_read_blocked', is_wp_error($blocked_read) ? $blocked_read->get_error_code() : '', 'stale primary read uses expected error code'); + $allowed_read = $reader->read_file('fixture-clone', 'README.md', \DataMachineCode\Workspace\Workspace::MAX_READ_SIZE, null, null, true); + $assert(! is_wp_error($allowed_read), 'stale primary read succeeds with explicit opt-in'); $remote_url = getenv('REMOTE_CLONE_URL'); if (is_string($remote_url) && '' !== trim($remote_url) ) {