diff --git a/README.md b/README.md index 90bbfac..21b0ad2 100644 --- a/README.md +++ b/README.md @@ -45,18 +45,45 @@ wp datamachine-code workspace path wp datamachine-code workspace list wp datamachine-code workspace clone https://github.com/org/repo.git wp datamachine-code workspace show repo-name -wp datamachine-code workspace read repo-name src/main.php -wp datamachine-code workspace ls repo-name src/ -wp datamachine-code workspace write repo-name path/to/file.txt @content.txt -wp datamachine-code workspace edit repo-name path/to/file.txt --old="foo" --new="bar" + +# Worktrees — one per branch, parallel-safe +wp datamachine-code workspace worktree add repo-name fix/foo +wp datamachine-code workspace worktree list +wp datamachine-code workspace worktree remove repo-name fix/foo +wp datamachine-code workspace worktree prune + +# All read/write/git ops accept either (primary) or @ (worktree) +wp datamachine-code workspace read repo-name@fix-foo src/main.php +wp datamachine-code workspace ls repo-name@fix-foo src/ +wp datamachine-code workspace write repo-name@fix-foo path/to/file.txt @content.txt +wp datamachine-code workspace edit repo-name@fix-foo path/to/file.txt --old="foo" --new="bar" wp datamachine-code workspace remove repo-name -wp datamachine-code workspace git status repo-name -wp datamachine-code workspace git pull repo-name -wp datamachine-code workspace git add repo-name --paths=src/file.php -wp datamachine-code workspace git commit repo-name --message="fix: something" -wp datamachine-code workspace git push repo-name -wp datamachine-code workspace git log repo-name -wp datamachine-code workspace git diff repo-name +wp datamachine-code workspace git status repo-name@fix-foo +wp datamachine-code workspace git pull repo-name@fix-foo +wp datamachine-code workspace git add repo-name@fix-foo --path=src/file.php +wp datamachine-code workspace git commit repo-name@fix-foo "fix: something" +wp datamachine-code workspace git push repo-name@fix-foo +wp datamachine-code workspace git log repo-name@fix-foo +wp datamachine-code workspace git diff repo-name@fix-foo +``` + +### Worktrees: parallel-safe branch work + +The workspace is **worktree-native**. Each branch lives in its own directory at +`/@` (slashes in branch names become dashes). Multiple +agent sessions can edit different branches of the same repo simultaneously without +stepping on each other. + +The primary checkout (bare ``) is **read-only by default** for mutating +operations — pass `--allow-primary-mutation` to override. The default-deny is +intentional: the primary tracks the deployed branch, and silent branch-switches +on it are how parallel agents corrupt each other's work. + +``` +~/.datamachine/workspace/ +├── data-machine/ ← primary, hands-off by default +├── data-machine@fix-foo/ ← worktree for fix/foo +└── data-machine@feat-bar/ ← worktree for feat/bar ``` ## Requirements diff --git a/data-machine-code.php b/data-machine-code.php index 52737a5..39ca177 100644 --- a/data-machine-code.php +++ b/data-machine-code.php @@ -294,9 +294,11 @@ function datamachine_code_load_chat_tools() { - Discover available handlers: `{$wp} datamachine handlers list` **Code (data-machine-code):** All code changes go through the managed workspace and GitHub — never edit site files directly. -- Workspace: `{$wp} datamachine-code workspace clone|read|write|edit|git` — clone repos, create branches, edit files, commit, and push +- Workspace: `{$wp} datamachine-code workspace clone|worktree|read|write|edit|git` — clone repos, create per-branch worktrees, edit files, commit, and push - GitHub: `{$wp} datamachine-code github issues|pulls|repos|comment` — create PRs, manage issues, comment on reviews -- **Workflow:** clone → branch → edit → commit → push → PR. The workspace is a git checkout separate from the live site. +- **Workflow:** clone → `worktree add ` → edit → commit → push → PR. Operate on the `@` handle (e.g. `data-machine@fix-foo-bar`); never branch-switch the primary checkout. +- **Why worktrees:** every parallel session gets its own checkout on disk. Multiple agents can cook features in the same repo without stepping on each other. +- **Primary is read-only by default:** mutating ops on bare `` handles require `--allow-primary-mutation`. The primary tracks the deployed branch — leave it alone unless you really mean it. - **Rule:** Never modify files under `wp-content/plugins/` or `wp-content/themes/` directly. Those paths are for **reading source** only. All code changes must go through the workspace so they are tracked in git and reviewed via pull requests. **System:** `{$wp} datamachine system health|prompts|run` diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index cff42ec..a44e929 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to Data Machine Code will be documented in this file. +## [Unreleased] + +### Added +- Worktree-native workspace: each branch lives in its own directory at `/@`. Multiple agent sessions can edit different branches of the same repo simultaneously. +- Four new abilities: `datamachine/workspace-worktree-add|list|remove|prune`. +- New CLI subcommand: `wp datamachine-code workspace worktree add|list|remove|prune`. +- All read/write/git abilities accept the new `@` handle format alongside bare repo names. +- Pure-PHP smoke test for handle parsing and slug generation (`tests/smoke-worktree-handles.php`). +- Manual end-to-end test plan (`tests/TESTING.md`). + +### Changed +- `clone` rejects names containing `@` — that suffix is reserved for worktrees. +- `remove` refuses to delete a primary checkout that has linked worktrees attached. +- `git push` only enforces the `fixed_branch` policy on the primary checkout. Worktrees may push any branch. +- Mutating ops (`git pull|add|commit|push`) on a primary checkout require `--allow-primary-mutation` (CLI) / `allow_primary_mutation: true` (ability input). Worktrees are always allowed. Default-deny prevents parallel agents from clobbering the deployed branch. + ## [0.4.0] - 2026-04-15 ### Added diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 3e3551a..5aa8917 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -122,7 +122,7 @@ private function registerAbilities(): void { 'properties' => array( 'name' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` for primary checkout or `@` for a worktree.', ), ), 'required' => array( 'name' ), @@ -130,13 +130,15 @@ private function registerAbilities(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'name' => array( 'type' => 'string' ), - 'path' => array( 'type' => 'string' ), - 'branch' => array( 'type' => 'string' ), - 'remote' => array( 'type' => 'string' ), - 'commit' => array( 'type' => 'string' ), - 'dirty' => array( 'type' => 'integer' ), + 'success' => array( 'type' => 'boolean' ), + 'name' => array( 'type' => 'string' ), + 'repo' => array( 'type' => 'string' ), + 'is_worktree' => array( 'type' => 'boolean' ), + 'path' => array( 'type' => 'string' ), + 'branch' => array( 'type' => 'string' ), + 'remote' => array( 'type' => 'string' ), + 'commit' => array( 'type' => 'string' ), + 'dirty' => array( 'type' => 'integer' ), ), ), 'execute_callback' => array( self::class, 'showRepo' ), @@ -160,7 +162,7 @@ private function registerAbilities(): void { 'properties' => array( 'repo' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), 'path' => array( 'type' => 'string', @@ -209,7 +211,7 @@ private function registerAbilities(): void { 'properties' => array( 'repo' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), 'path' => array( 'type' => 'string', @@ -251,7 +253,7 @@ private function registerAbilities(): void { 'datamachine/workspace-clone', array( 'label' => 'Clone Workspace Repo', - 'description' => 'Clone a git repository into the workspace.', + 'description' => 'Clone a git repository into the workspace as a primary checkout. Worktrees are created separately via `workspace-worktree-add`.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', @@ -286,14 +288,14 @@ private function registerAbilities(): void { 'datamachine/workspace-remove', array( 'label' => 'Remove Workspace Repo', - 'description' => 'Remove a repository from the workspace.', + 'description' => 'Remove a workspace handle. Refuses to remove a primary that has linked worktrees.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'name' => array( 'type' => 'string', - 'description' => 'Repository directory name to remove.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), ), 'required' => array( 'name' ), @@ -322,7 +324,7 @@ private function registerAbilities(): void { 'properties' => array( 'repo' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), 'path' => array( 'type' => 'string', @@ -361,7 +363,7 @@ private function registerAbilities(): void { 'properties' => array( 'repo' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), 'path' => array( 'type' => 'string', @@ -400,14 +402,14 @@ private function registerAbilities(): void { 'datamachine/workspace-git-status', array( 'label' => 'Workspace Git Status', - 'description' => 'Get git status information for a workspace repository.', + 'description' => 'Get git status information for a workspace handle (primary or worktree).', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'name' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), ), 'required' => array( 'name' ), @@ -415,14 +417,16 @@ private function registerAbilities(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'name' => array( 'type' => 'string' ), - 'path' => array( 'type' => 'string' ), - 'branch' => array( 'type' => 'string' ), - 'remote' => array( 'type' => 'string' ), - 'commit' => array( 'type' => 'string' ), - 'dirty' => array( 'type' => 'integer' ), - 'files' => array( + 'success' => array( 'type' => 'boolean' ), + 'name' => array( 'type' => 'string' ), + 'repo' => array( 'type' => 'string' ), + 'is_worktree' => array( 'type' => 'boolean' ), + 'path' => array( 'type' => 'string' ), + 'branch' => array( 'type' => 'string' ), + 'remote' => array( 'type' => 'string' ), + 'commit' => array( 'type' => 'string' ), + 'dirty' => array( 'type' => 'integer' ), + 'files' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), ), @@ -438,14 +442,14 @@ private function registerAbilities(): void { 'datamachine/workspace-git-log', array( 'label' => 'Workspace Git Log', - 'description' => 'Read git log entries for a workspace repository.', + 'description' => 'Read git log entries for a workspace handle.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'name' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), 'limit' => array( 'type' => 'integer', @@ -483,14 +487,14 @@ private function registerAbilities(): void { 'datamachine/workspace-git-diff', array( 'label' => 'Workspace Git Diff', - 'description' => 'Read git diff output for a workspace repository.', + 'description' => 'Read git diff output for a workspace handle.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'name' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), 'from' => array( 'type' => 'string', @@ -529,19 +533,23 @@ private function registerAbilities(): void { 'datamachine/workspace-git-pull', array( 'label' => 'Workspace Git Pull', - 'description' => 'Run git pull --ff-only for a workspace repository.', + 'description' => 'Run git pull --ff-only for a workspace handle. Mutating ops on the primary checkout require allow_primary_mutation=true.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), - 'allow_dirty' => array( + 'allow_dirty' => array( 'type' => 'boolean', 'description' => 'Allow pull when working tree is dirty.', ), + 'allow_primary_mutation' => array( + 'type' => 'boolean', + 'description' => 'Permit mutation on the primary checkout (default false). Worktrees are always allowed.', + ), ), 'required' => array( 'name' ), ), @@ -563,20 +571,24 @@ private function registerAbilities(): void { 'datamachine/workspace-git-add', array( 'label' => 'Workspace Git Add', - 'description' => 'Stage repository paths with git add.', + 'description' => 'Stage repository paths with git add. Mutating ops on the primary checkout require allow_primary_mutation=true.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), - 'paths' => array( + 'paths' => array( 'type' => 'array', 'description' => 'Relative paths to stage.', 'items' => array( 'type' => 'string' ), ), + 'allow_primary_mutation' => array( + 'type' => 'boolean', + 'description' => 'Permit mutation on the primary checkout (default false). Worktrees are always allowed.', + ), ), 'required' => array( 'name', 'paths' ), ), @@ -602,19 +614,23 @@ private function registerAbilities(): void { 'datamachine/workspace-git-commit', array( 'label' => 'Workspace Git Commit', - 'description' => 'Commit staged changes in a workspace repository.', + 'description' => 'Commit staged changes in a workspace handle. Mutating ops on the primary checkout require allow_primary_mutation=true.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), - 'message' => array( + 'message' => array( 'type' => 'string', 'description' => 'Commit message.', ), + 'allow_primary_mutation' => array( + 'type' => 'boolean', + 'description' => 'Permit mutation on the primary checkout (default false). Worktrees are always allowed.', + ), ), 'required' => array( 'name', 'message' ), ), @@ -637,23 +653,27 @@ private function registerAbilities(): void { 'datamachine/workspace-git-push', array( 'label' => 'Workspace Git Push', - 'description' => 'Push commits for a workspace repository.', + 'description' => 'Push commits for a workspace handle. `fixed_branch` policy applies only to the primary checkout; worktrees may push any branch.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'name' => array( + 'name' => array( 'type' => 'string', - 'description' => 'Repository directory name.', + '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( + 'type' => 'boolean', + 'description' => 'Permit pushing from the primary checkout (default false). Worktrees are always allowed.', + ), ), 'required' => array( 'name' ), ), @@ -672,6 +692,161 @@ private function registerAbilities(): void { 'meta' => array( 'show_in_rest' => false ), ) ); + + // ----------------------------------------------------------------- + // Worktree abilities (mutating, CLI-only by default). + // ----------------------------------------------------------------- + + wp_register_ability( + 'datamachine/workspace-worktree-add', + array( + 'label' => 'Add Workspace Worktree', + 'description' => 'Create a git worktree for a branch under `@`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'repo' => array( + 'type' => 'string', + 'description' => 'Primary repo name (no @-suffix).', + ), + 'branch' => array( + 'type' => 'string', + 'description' => 'Branch to check out in the worktree (e.g. fix/foo-bar). Slashes become dashes in the on-disk slug.', + ), + 'from' => array( + 'type' => 'string', + 'description' => 'Base ref when creating the branch (default origin/HEAD).', + ), + ), + 'required' => array( 'repo', 'branch' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'handle' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + 'branch' => array( 'type' => 'string' ), + 'slug' => array( 'type' => 'string' ), + 'created_branch' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'worktreeAdd' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-worktree-list', + array( + 'label' => 'List Workspace Worktrees', + 'description' => 'List all worktrees in the workspace (optionally filtered by repo).', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'repo' => array( + 'type' => 'string', + 'description' => 'Optional repo name to limit the list.', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'worktrees' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'handle' => array( 'type' => 'string' ), + 'repo' => array( 'type' => 'string' ), + 'is_worktree' => array( 'type' => 'boolean' ), + 'is_primary' => array( 'type' => 'boolean' ), + 'external' => array( 'type' => 'boolean' ), + 'branch_slug' => array( 'type' => array( 'string', 'null' ) ), + 'branch' => array( 'type' => array( 'string', 'null' ) ), + 'head' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + 'dirty' => array( 'type' => 'integer' ), + ), + ), + ), + ), + ), + 'execute_callback' => array( self::class, 'worktreeList' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-worktree-remove', + array( + 'label' => 'Remove Workspace Worktree', + 'description' => 'Remove a worktree by repo and branch (or branch slug). Refuses if the worktree has uncommitted changes unless `force` is true.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'repo' => array( + 'type' => 'string', + 'description' => 'Primary repo name.', + ), + 'branch' => array( + 'type' => 'string', + 'description' => 'Branch (or slug) of the worktree.', + ), + 'force' => array( + 'type' => 'boolean', + 'description' => 'Force removal even if dirty (default false).', + ), + ), + 'required' => array( 'repo', 'branch' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'handle' => array( 'type' => 'string' ), + 'message' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'worktreeRemove' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-worktree-prune', + array( + 'label' => 'Prune Workspace Worktrees', + 'description' => 'Run git worktree prune across all primary checkouts to drop stale registry entries.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array(), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'pruned' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ), + 'execute_callback' => array( self::class, 'worktreePrune' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); }; if ( doing_action( 'wp_abilities_api_init' ) ) { @@ -691,7 +866,7 @@ private function registerAbilities(): void { * @param array $input Input parameters. * @return array Result. */ - public static function getPath( array $input ): array { + public static function getPath( array $input ): array|\WP_Error { $workspace = new Workspace(); if ( ! empty( $input['ensure'] ) ) { @@ -718,7 +893,7 @@ public static function getPath( array $input ): array { * @param array $input Input parameters. * @return array Result. */ - public static function listRepos( array $input ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + public static function listRepos( array $input ): array|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found $workspace = new Workspace(); return $workspace->list_repos(); } @@ -729,7 +904,7 @@ public static function listRepos( array $input ): array { // phpcs:ignore Generi * @param array $input Input parameters with 'name'. * @return array Result. */ - public static function showRepo( array $input ): array { + public static function showRepo( array $input ): array|\WP_Error { $workspace = new Workspace(); return $workspace->show_repo( $input['name'] ?? '' ); } @@ -740,7 +915,7 @@ public static function showRepo( array $input ): array { * @param array $input Input parameters with 'repo', 'path', optional 'max_size', 'offset', 'limit'. * @return array Result. */ - public static function readFile( array $input ): array { + public static function readFile( array $input ): array|\WP_Error { $workspace = new Workspace(); $reader = new WorkspaceReader( $workspace ); @@ -759,7 +934,7 @@ public static function readFile( array $input ): array { * @param array $input Input parameters with 'repo', optional 'path'. * @return array Result. */ - public static function listDirectory( array $input ): array { + public static function listDirectory( array $input ): array|\WP_Error { $workspace = new Workspace(); $reader = new WorkspaceReader( $workspace ); @@ -775,7 +950,7 @@ public static function listDirectory( array $input ): array { * @param array $input Input parameters with 'url', optional 'name'. * @return array Result. */ - public static function cloneRepo( array $input ): array { + public static function cloneRepo( array $input ): array|\WP_Error { $workspace = new Workspace(); return $workspace->clone_repo( $input['url'] ?? '', @@ -789,7 +964,7 @@ public static function cloneRepo( array $input ): array { * @param array $input Input parameters with 'name'. * @return array Result. */ - public static function removeRepo( array $input ): array { + public static function removeRepo( array $input ): array|\WP_Error { $workspace = new Workspace(); return $workspace->remove_repo( $input['name'] ?? '' ); } @@ -800,7 +975,7 @@ public static function removeRepo( array $input ): array { * @param array $input Input parameters with 'repo', 'path', 'content'. * @return array Result. */ - public static function writeFile( array $input ): array { + public static function writeFile( array $input ): array|\WP_Error { $workspace = new Workspace(); $writer = new WorkspaceWriter( $workspace ); @@ -817,7 +992,7 @@ public static function writeFile( array $input ): array { * @param array $input Input parameters with 'repo', 'path', 'old_string', 'new_string', optional 'replace_all'. * @return array Result. */ - public static function editFile( array $input ): array { + public static function editFile( array $input ): array|\WP_Error { $workspace = new Workspace(); $writer = new WorkspaceWriter( $workspace ); @@ -836,7 +1011,7 @@ public static function editFile( array $input ): array { * @param array $input Input parameters with 'name'. * @return array */ - public static function gitStatus( array $input ): array { + public static function gitStatus( array $input ): array|\WP_Error { $workspace = new Workspace(); return $workspace->git_status( $input['name'] ?? '' ); } @@ -847,11 +1022,12 @@ public static function gitStatus( array $input ): array { * @param array $input Input parameters with 'name', optional 'allow_dirty'. * @return array */ - public static function gitPull( array $input ): array { + public static function gitPull( array $input ): array|\WP_Error { $workspace = new Workspace(); return $workspace->git_pull( $input['name'] ?? '', - ! empty( $input['allow_dirty'] ) + ! empty( $input['allow_dirty'] ), + ! empty( $input['allow_primary_mutation'] ) ); } @@ -861,7 +1037,7 @@ public static function gitPull( array $input ): array { * @param array $input Input parameters with 'name', 'paths'. * @return array */ - public static function gitAdd( array $input ): array { + public static function gitAdd( array $input ): array|\WP_Error { $workspace = new Workspace(); $paths = $input['paths'] ?? array(); @@ -869,7 +1045,7 @@ public static function gitAdd( array $input ): array { $paths = array(); } - return $workspace->git_add( $input['name'] ?? '', $paths ); + return $workspace->git_add( $input['name'] ?? '', $paths, ! empty( $input['allow_primary_mutation'] ) ); } /** @@ -878,11 +1054,12 @@ public static function gitAdd( array $input ): array { * @param array $input Input parameters with 'name', 'message'. * @return array */ - public static function gitCommit( array $input ): array { + public static function gitCommit( array $input ): array|\WP_Error { $workspace = new Workspace(); return $workspace->git_commit( $input['name'] ?? '', - $input['message'] ?? '' + $input['message'] ?? '', + ! empty( $input['allow_primary_mutation'] ) ); } @@ -892,22 +1069,78 @@ public static function gitCommit( array $input ): array { * @param array $input Input parameters with 'name', optional 'remote', 'branch'. * @return array */ - public static function gitPush( array $input ): array { + public static function gitPush( array $input ): array|\WP_Error { $workspace = new Workspace(); return $workspace->git_push( $input['name'] ?? '', $input['remote'] ?? 'origin', - $input['branch'] ?? null + $input['branch'] ?? null, + ! empty( $input['allow_primary_mutation'] ) + ); + } + + /** + * Add a worktree for a branch. + * + * @param array $input Input parameters with 'repo', 'branch', optional 'from'. + * @return array + */ + public static function worktreeAdd( array $input ): array|\WP_Error { + $workspace = new Workspace(); + return $workspace->worktree_add( + $input['repo'] ?? '', + $input['branch'] ?? '', + $input['from'] ?? null + ); + } + + /** + * List worktrees in the workspace. + * + * @param array $input Input parameters with optional 'repo'. + * @return array + */ + public static function worktreeList( array $input ): array|\WP_Error { + $workspace = new Workspace(); + $repo = isset( $input['repo'] ) && '' !== trim( (string) $input['repo'] ) + ? (string) $input['repo'] + : null; + return $workspace->worktree_list( $repo ); + } + + /** + * Remove a worktree. + * + * @param array $input Input parameters with 'repo', 'branch', optional 'force'. + * @return array + */ + public static function worktreeRemove( array $input ): array|\WP_Error { + $workspace = new Workspace(); + return $workspace->worktree_remove( + $input['repo'] ?? '', + $input['branch'] ?? '', + ! empty( $input['force'] ) ); } + /** + * Prune stale worktree registry entries. + * + * @param array $input Unused. + * @return array + */ + public static function worktreePrune( array $input ): array|\WP_Error { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found + $workspace = new Workspace(); + return $workspace->worktree_prune(); + } + /** * Read git log entries for a workspace repository. * * @param array $input Input parameters with 'name', optional 'limit'. * @return array */ - public static function gitLog( array $input ): array { + public static function gitLog( array $input ): array|\WP_Error { $workspace = new Workspace(); return $workspace->git_log( $input['name'] ?? '', @@ -921,7 +1154,7 @@ public static function gitLog( array $input ): array { * @param array $input Input parameters. * @return array */ - public static function gitDiff( array $input ): array { + public static function gitDiff( array $input ): array|\WP_Error { $workspace = new Workspace(); return $workspace->git_diff( $input['name'] ?? '', diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index a028a92..3db7356 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -124,6 +124,8 @@ public function list_repos( array $args, array $assoc_args ): void { function ( $repo ) { 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', @@ -135,7 +137,7 @@ function ( $repo ) { $this->format_items( $items, - array( 'name', 'branch', 'remote', 'git' ), + array( 'name', 'kind', 'repo', 'branch', 'remote', 'git' ), $assoc_args, 'name' ); @@ -669,6 +671,9 @@ private function resolveAtFile( string $value ): string { * [--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. + * * [--remote=] * : Remote name for push (default: origin). * @@ -745,6 +750,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' ), true ) ) { + $input['allow_primary_mutation'] = ! empty( $assoc_args['allow-primary-mutation'] ); + } + if ( 'pull' === $operation ) { $input['allow_dirty'] = ! empty( $assoc_args['allow-dirty'] ); } @@ -863,4 +873,181 @@ private function renderGitOperationResult( string $operation, array $result, arr return; } } + + /** + * Manage workspace git worktrees. + * + * Worktrees let multiple agent sessions work on the same repo without + * stepping on each other. Each branch lives in its own directory at + * `/@`. Branch slashes become dashes in + * the slug (`fix/foo` → `fix-foo`). + * + * ## OPTIONS + * + * + * : Worktree operation: add, list, remove, prune. + * + * [] + * : Primary repo name (required for add and remove). + * + * [] + * : Branch name (required for add and remove). + * + * [--from=] + * : Base ref when creating a branch on add (default origin/HEAD). + * + * [--force] + * : Force-remove a worktree even if it is dirty. + * + * [--format=] + * : Output format for list (table, json, csv, yaml). + * + * ## EXAMPLES + * + * # Create a worktree for fix/foo on data-machine + * wp datamachine workspace worktree add data-machine fix/foo + * + * # Create off a specific base + * wp datamachine workspace worktree add data-machine feat/bar --from=origin/develop + * + * # List all worktrees + * wp datamachine workspace worktree list + * + * # List worktrees for one repo + * wp datamachine workspace worktree list data-machine + * + * # Remove a worktree + * wp datamachine workspace worktree remove data-machine fix/foo + * + * # Force-remove a dirty worktree + * wp datamachine workspace worktree remove data-machine fix/foo --force + * + * # Prune stale worktree registry entries across all primaries + * wp datamachine workspace worktree prune + * + * @subcommand worktree + */ + public function worktree( array $args, array $assoc_args ): void { + $operation = $args[0] ?? ''; + + if ( '' === $operation ) { + WP_CLI::error( 'Usage: wp datamachine workspace worktree [] [] [--flags]' ); + return; + } + + $ability_name = match ( $operation ) { + 'add' => 'datamachine/workspace-worktree-add', + 'list' => 'datamachine/workspace-worktree-list', + 'remove' => 'datamachine/workspace-worktree-remove', + 'prune' => 'datamachine/workspace-worktree-prune', + default => '', + }; + + if ( '' === $ability_name ) { + WP_CLI::error( sprintf( 'Unknown worktree operation: %s', $operation ) ); + return; + } + + $ability = wp_get_ability( $ability_name ); + if ( ! $ability ) { + WP_CLI::error( sprintf( 'Worktree ability not available: %s', $ability_name ) ); + return; + } + + $input = array(); + + switch ( $operation ) { + case 'add': + if ( empty( $args[1] ) || empty( $args[2] ) ) { + WP_CLI::error( 'Usage: worktree add [--from=]' ); + return; + } + $input['repo'] = $args[1]; + $input['branch'] = $args[2]; + if ( ! empty( $assoc_args['from'] ) ) { + $input['from'] = (string) $assoc_args['from']; + } + break; + + case 'list': + if ( ! empty( $args[1] ) ) { + $input['repo'] = $args[1]; + } + break; + + case 'remove': + if ( empty( $args[1] ) || empty( $args[2] ) ) { + WP_CLI::error( 'Usage: worktree remove [--force]' ); + return; + } + $input['repo'] = $args[1]; + $input['branch'] = $args[2]; + $input['force'] = ! empty( $assoc_args['force'] ); + break; + } + + $result = $ability->execute( $input ); + + if ( is_wp_error( $result ) ) { + WP_CLI::error( $result->get_error_message() ); + return; + } + + $this->renderWorktreeResult( $operation, $result, $assoc_args ); + } + + /** + * Render CLI output for worktree operations. + * + * @param string $operation Worktree operation. + * @param array $result Ability result. + * @param array $assoc_args CLI assoc args. + */ + private function renderWorktreeResult( string $operation, array $result, array $assoc_args ): void { + switch ( $operation ) { + case 'list': + $worktrees = $result['worktrees'] ?? array(); + if ( empty( $worktrees ) ) { + WP_CLI::log( 'No worktrees found.' ); + return; + } + $items = array_map( + fn( $wt ) => array( + 'handle' => $wt['handle'] ?? '', + 'repo' => $wt['repo'] ?? '', + 'kind' => ! empty( $wt['is_primary'] ) ? 'primary' : 'worktree', + 'branch' => $wt['branch'] ?? '-', + 'head' => isset( $wt['head'] ) ? substr( (string) $wt['head'], 0, 7 ) : '-', + 'dirty' => (int) ( $wt['dirty'] ?? 0 ), + 'path' => $wt['path'] ?? '', + ), + $worktrees + ); + $this->format_items( $items, array( 'handle', 'repo', 'kind', 'branch', 'head', 'dirty', 'path' ), $assoc_args, 'handle' ); + return; + + case 'prune': + $pruned = $result['pruned'] ?? array(); + if ( empty( $pruned ) ) { + WP_CLI::log( 'Nothing to prune.' ); + return; + } + WP_CLI::success( sprintf( 'Pruned worktree registry across: %s', implode( ', ', $pruned ) ) ); + return; + + case 'add': + WP_CLI::success( $result['message'] ?? 'Worktree created.' ); + if ( ! empty( $result['handle'] ) ) { + WP_CLI::log( sprintf( 'Handle: %s', $result['handle'] ) ); + WP_CLI::log( sprintf( 'Path: %s', $result['path'] ?? '-' ) ); + WP_CLI::log( sprintf( 'Branch: %s%s', $result['branch'] ?? '-', ! empty( $result['created_branch'] ) ? ' (created)' : '' ) ); + } + return; + + case 'remove': + default: + WP_CLI::success( $result['message'] ?? 'Worktree operation complete.' ); + return; + } + } } diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index c93a36d..3fc0291 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -97,13 +97,97 @@ public function get_path(): string { } /** - * Get the full path to a repo within the workspace. + * Get the full path to a workspace handle. * - * @param string $name Repository name (directory name). - * @return string Full path. + * Handles can be either a primary checkout (``) or a worktree + * (`@`). The directory name on disk equals the handle. + * + * @param string $handle Workspace handle (`` or `@`). + * @return string Full filesystem path. + */ + public function get_repo_path( string $handle ): string { + $parsed = $this->parse_handle( $handle ); + return $this->workspace_path . '/' . $parsed['dir_name']; + } + + /** + * Parse a workspace handle into its components. + * + * Accepts either: + * - `` → primary checkout + * - `@` → worktree (slug = slugified branch name) + * + * @param string $handle Workspace handle. + * @return array{repo: string, branch_slug: string|null, is_worktree: bool, dir_name: string} + */ + public function parse_handle( string $handle ): array { + $handle = trim( $handle ); + + if ( str_contains( $handle, '@' ) ) { + $parts = explode( '@', $handle, 2 ); + $repo = $this->sanitize_name( $parts[0] ); + $slug = $this->sanitize_slug( $parts[1] ); + + if ( '' !== $repo && '' !== $slug ) { + return array( + 'repo' => $repo, + 'branch_slug' => $slug, + 'is_worktree' => true, + 'dir_name' => $repo . '@' . $slug, + ); + } + } + + $repo = $this->sanitize_name( $handle ); + + return array( + 'repo' => $repo, + 'branch_slug' => null, + 'is_worktree' => false, + 'dir_name' => $repo, + ); + } + + /** + * Convert a branch name to a filesystem-safe slug. + * + * Slashes become dashes (`fix/foo-bar` → `fix-foo-bar`). Anything else + * outside [A-Za-z0-9._-] is stripped. + * + * @param string $branch Branch name. + * @return string Slug (empty if branch is invalid). */ - public function get_repo_path( string $name ): string { - return $this->workspace_path . '/' . $this->sanitize_name( $name ); + public function slugify_branch( string $branch ): string { + $branch = trim( $branch ); + if ( '' === $branch ) { + return ''; + } + + $slug = str_replace( '/', '-', $branch ); + return $this->sanitize_slug( $slug ); + } + + /** + * Sanitize a branch slug. Allows alphanumerics, dots, dashes, underscores. + * + * @param string $slug Raw slug. + * @return string + */ + private function sanitize_slug( string $slug ): string { + $slug = preg_replace( '/[^a-zA-Z0-9._-]/', '', $slug ); + // Collapse runs of dashes for readability. + $slug = preg_replace( '/-{2,}/', '-', (string) $slug ); + return trim( (string) $slug, '-.' ); + } + + /** + * Get the primary checkout path for a repo. + * + * @param string $repo Repository name (no @-suffix). + * @return string + */ + public function get_primary_path( string $repo ): string { + return $this->workspace_path . '/' . $this->sanitize_name( $repo ); } /** @@ -175,14 +259,25 @@ public function list_repos(): array { continue; } + $git_path = $entry_path . '/.git'; + $is_git = is_dir( $git_path ) || is_file( $git_path ); + $is_wt = is_file( $git_path ); + $parsed = $this->parse_handle( $entry ); + $repo_info = array( - 'name' => $entry, - 'path' => $entry_path, - 'git' => is_dir( $entry_path . '/.git' ), + 'name' => $entry, + 'path' => $entry_path, + 'git' => $is_git, + 'is_worktree' => $is_wt || $parsed['is_worktree'], + 'repo' => $parsed['repo'], ); + if ( $parsed['is_worktree'] ) { + $repo_info['branch_slug'] = $parsed['branch_slug']; + } + // Get git remote if available. - if ( $repo_info['git'] ) { + if ( $is_git ) { $remote = $this->git_get_remote( $entry_path ); if ( null !== $remote ) { $repo_info['remote'] = $remote; @@ -225,6 +320,11 @@ public function clone_repo( string $url, ?string $name = null ): array|\WP_Error } } + // Reject @-suffixed names — those are reserved for worktrees. + if ( str_contains( $name, '@' ) ) { + return new \WP_Error( 'invalid_clone_name', 'Repository names cannot contain "@". The "@" suffix is reserved for worktrees (use "workspace worktree add" instead).', array( 'status' => 400 ) ); + } + $name = $this->sanitize_name( $name ); $repo_path = $this->workspace_path . '/' . $name; @@ -265,12 +365,12 @@ public function clone_repo( string $url, ?string $name = null ): array|\WP_Error * @param string $name Repository directory name. * @return array{success: bool, message: string}|\WP_Error */ - public function remove_repo( string $name ): array|\WP_Error { - $name = $this->sanitize_name( $name ); - $repo_path = $this->workspace_path . '/' . $name; + public function remove_repo( string $handle ): array|\WP_Error { + $parsed = $this->parse_handle( $handle ); + $repo_path = $this->workspace_path . '/' . $parsed['dir_name']; if ( ! is_dir( $repo_path ) ) { - return new \WP_Error( 'repo_not_found', sprintf( 'Repository "%s" not found in workspace.', $name ), array( 'status' => 404 ) ); + return new \WP_Error( 'repo_not_found', sprintf( 'Workspace handle "%s" not found.', $parsed['dir_name'] ), array( 'status' => 404 ) ); } // Safety: ensure path is within workspace. @@ -279,6 +379,18 @@ public function remove_repo( string $name ): array|\WP_Error { return new \WP_Error( 'path_traversal', $validation['message'], array( 'status' => 403 ) ); } + // Refuse to remove a primary that still has live worktrees attached. + if ( ! $parsed['is_worktree'] ) { + $worktrees = $this->worktree_list( $parsed['repo'] ); + if ( ! is_wp_error( $worktrees ) ) { + $linked = array_filter( $worktrees['worktrees'] ?? array(), fn( $wt ) => ! empty( $wt['is_worktree'] ) ); + if ( ! empty( $linked ) ) { + $slugs = array_map( fn( $wt ) => $wt['branch_slug'] ?? '?', $linked ); + return new \WP_Error( 'has_worktrees', sprintf( 'Cannot remove primary "%s": linked worktrees exist (%s). Remove them first with "workspace worktree remove".', $parsed['repo'], implode( ', ', $slugs ) ), array( 'status' => 400 ) ); + } + } + } + // Remove recursively. $escaped = escapeshellarg( $validation['real_path'] ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec @@ -288,9 +400,19 @@ public function remove_repo( string $name ): array|\WP_Error { return new \WP_Error( 'remove_failed', sprintf( 'Failed to remove (exit %d): %s', $exit_code, implode( "\n", $output ) ), array( 'status' => 500 ) ); } + // If we removed a worktree directory but didn't go through `git worktree remove`, + // prune the registry on the primary so it doesn't keep stale entries. + if ( $parsed['is_worktree'] ) { + $primary_path = $this->get_primary_path( $parsed['repo'] ); + if ( is_dir( $primary_path . '/.git' ) ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( sprintf( 'git -C %s worktree prune 2>&1', escapeshellarg( $primary_path ) ) ); + } + } + return array( 'success' => true, - 'message' => sprintf( 'Removed "%s" from workspace.', $name ), + 'message' => sprintf( 'Removed "%s" from workspace.', $parsed['dir_name'] ), ); } @@ -300,12 +422,12 @@ public function remove_repo( string $name ): array|\WP_Error { * @param string $name Repository directory name. * @return array{success: bool, name?: string, path?: string, branch?: string, remote?: string, commit?: string, dirty?: int}|\WP_Error */ - public function show_repo( string $name ): array|\WP_Error { - $name = $this->sanitize_name( $name ); - $repo_path = $this->workspace_path . '/' . $name; + public function show_repo( string $handle ): array|\WP_Error { + $parsed = $this->parse_handle( $handle ); + $repo_path = $this->workspace_path . '/' . $parsed['dir_name']; if ( ! is_dir( $repo_path ) ) { - return new \WP_Error( 'repo_not_found', sprintf( 'Repository "%s" not found in workspace.', $name ), array( 'status' => 404 ) ); + return new \WP_Error( 'repo_not_found', sprintf( 'Workspace handle "%s" not found.', $parsed['dir_name'] ), array( 'status' => 404 ) ); } $escaped = escapeshellarg( $repo_path ); @@ -318,13 +440,15 @@ public function show_repo( string $name ): array|\WP_Error { // phpcs:enable return array( - 'success' => true, - 'name' => $name, - 'path' => $repo_path, - 'branch' => $branch ? $branch : null, - 'remote' => $remote ? $remote : null, - 'commit' => $commit ? $commit : null, - 'dirty' => (int) $status, + 'success' => true, + 'name' => $parsed['dir_name'], + 'repo' => $parsed['repo'], + 'is_worktree' => $parsed['is_worktree'], + 'path' => $repo_path, + 'branch' => $branch ? $branch : null, + 'remote' => $remote ? $remote : null, + 'commit' => $commit ? $commit : null, + 'dirty' => (int) $status, ); } @@ -334,8 +458,9 @@ public function show_repo( string $name ): array|\WP_Error { * @param string $name Repository directory name. * @return array */ - public function git_status( string $name ): array|\WP_Error { - $repo_path = $this->resolve_repo_path( $name ); + public function git_status( string $handle ): array|\WP_Error { + $parsed = $this->parse_handle( $handle ); + $repo_path = $this->resolve_repo_path( $handle ); if ( is_wp_error( $repo_path ) ) { return $repo_path; } @@ -352,14 +477,16 @@ public function git_status( string $name ): array|\WP_Error { $files = array_filter( array_map( 'trim', explode( "\n", $status_result['output'] ?? '' ) ) ); return array( - 'success' => true, - 'name' => $this->sanitize_name( $name ), - 'path' => $repo_path, - 'branch' => ! is_wp_error( $branch_result ) ? trim( (string) $branch_result['output'] ) : null, - 'remote' => ! is_wp_error( $remote_result ) ? trim( (string) $remote_result['output'] ) : null, - 'commit' => ! is_wp_error( $latest_result ) ? trim( (string) $latest_result['output'] ) : null, - 'dirty' => count( $files ), - 'files' => array_values( $files ), + 'success' => true, + 'name' => $parsed['dir_name'], + 'repo' => $parsed['repo'], + 'is_worktree' => $parsed['is_worktree'], + 'path' => $repo_path, + 'branch' => ! is_wp_error( $branch_result ) ? trim( (string) $branch_result['output'] ) : null, + 'remote' => ! is_wp_error( $remote_result ) ? trim( (string) $remote_result['output'] ) : null, + 'commit' => ! is_wp_error( $latest_result ) ? trim( (string) $latest_result['output'] ) : null, + 'dirty' => count( $files ), + 'files' => array_values( $files ), ); } @@ -370,18 +497,24 @@ public function git_status( string $name ): array|\WP_Error { * @param bool $allow_dirty Allow pull with dirty working tree. * @return array */ - public function git_pull( string $name, bool $allow_dirty = false ): array|\WP_Error { - $repo_path = $this->resolve_repo_path( $name ); + public function git_pull( string $handle, bool $allow_dirty = false, bool $allow_primary_mutation = false ): array|\WP_Error { + $parsed = $this->parse_handle( $handle ); + $repo_path = $this->resolve_repo_path( $handle ); if ( is_wp_error( $repo_path ) ) { return $repo_path; } - $policy_check = $this->ensure_git_mutation_allowed( $this->sanitize_name( $name ) ); + $policy_check = $this->ensure_git_mutation_allowed( $parsed['repo'] ); if ( is_wp_error( $policy_check ) ) { return $policy_check; } - $status = $this->git_status( $name ); + $primary_check = $this->ensure_primary_mutation_allowed( $parsed, $allow_primary_mutation ); + if ( is_wp_error( $primary_check ) ) { + return $primary_check; + } + + $status = $this->git_status( $handle ); if ( is_wp_error( $status ) ) { return $status; } @@ -399,7 +532,7 @@ public function git_pull( string $name, bool $allow_dirty = false ): array|\WP_E return array( 'success' => true, 'message' => trim( (string) $result['output'] ), - 'name' => $this->sanitize_name( $name ), + 'name' => $parsed['dir_name'], ); } @@ -410,9 +543,10 @@ public function git_pull( string $name, bool $allow_dirty = false ): array|\WP_E * @param array $paths Relative paths to stage. * @return array */ - public function git_add( string $name, array $paths ): array|\WP_Error { - $repo_name = $this->sanitize_name( $name ); - $repo_path = $this->resolve_repo_path( $name ); + public function git_add( string $handle, array $paths, bool $allow_primary_mutation = false ): array|\WP_Error { + $parsed = $this->parse_handle( $handle ); + $repo_name = $parsed['repo']; + $repo_path = $this->resolve_repo_path( $handle ); if ( is_wp_error( $repo_path ) ) { return $repo_path; } @@ -422,6 +556,11 @@ public function git_add( string $name, array $paths ): array|\WP_Error { return $policy_check; } + $primary_check = $this->ensure_primary_mutation_allowed( $parsed, $allow_primary_mutation ); + if ( is_wp_error( $primary_check ) ) { + return $primary_check; + } + if ( empty( $paths ) ) { return new \WP_Error( 'missing_paths', 'At least one path is required for git add.', array( 'status' => 400 ) ); } @@ -466,7 +605,8 @@ public function git_add( string $name, array $paths ): array|\WP_Error { return array( 'success' => true, - 'name' => $repo_name, + 'name' => $parsed['dir_name'], + 'repo' => $repo_name, 'paths' => $clean_paths, 'message' => 'Paths staged successfully.', ); @@ -475,13 +615,15 @@ public function git_add( string $name, array $paths ): array|\WP_Error { /** * Commit staged changes in a workspace repository. * - * @param string $name Repository directory name. - * @param string $message Commit message. + * @param string $handle Workspace handle. + * @param string $message Commit message. + * @param bool $allow_primary_mutation Whether the primary checkout may be mutated. * @return array */ - public function git_commit( string $name, string $message ): array|\WP_Error { - $repo_name = $this->sanitize_name( $name ); - $repo_path = $this->resolve_repo_path( $name ); + public function git_commit( string $handle, string $message, bool $allow_primary_mutation = false ): array|\WP_Error { + $parsed = $this->parse_handle( $handle ); + $repo_name = $parsed['repo']; + $repo_path = $this->resolve_repo_path( $handle ); if ( is_wp_error( $repo_path ) ) { return $repo_path; } @@ -491,6 +633,11 @@ public function git_commit( string $name, string $message ): array|\WP_Error { return $policy_check; } + $primary_check = $this->ensure_primary_mutation_allowed( $parsed, $allow_primary_mutation ); + if ( is_wp_error( $primary_check ) ) { + return $primary_check; + } + $message = trim( $message ); if ( '' === $message ) { return new \WP_Error( 'missing_message', 'Commit message is required.', array( 'status' => 400 ) ); @@ -521,7 +668,8 @@ public function git_commit( string $name, string $message ): array|\WP_Error { return array( 'success' => true, - 'name' => $repo_name, + 'name' => $parsed['dir_name'], + 'repo' => $repo_name, 'commit' => trim( (string) $commit['output'] ), 'message' => 'Commit created successfully.', ); @@ -530,14 +678,19 @@ public function git_commit( string $name, string $message ): array|\WP_Error { /** * Push commits for a workspace repository. * - * @param string $name Repository directory name. - * @param string $remote Remote name. - * @param string|null $branch Branch override. + * `fixed_branch` policy applies only to primary checkouts. Worktrees + * may push any branch (they exist precisely for feature work). + * + * @param string $handle Workspace handle. + * @param string $remote Remote name. + * @param string|null $branch Branch override. + * @param bool $allow_primary_mutation Whether the primary may be pushed. * @return array */ - public function git_push( string $name, string $remote = 'origin', ?string $branch = null ): array|\WP_Error { - $repo_name = $this->sanitize_name( $name ); - $repo_path = $this->resolve_repo_path( $name ); + public function git_push( string $handle, string $remote = 'origin', ?string $branch = null, bool $allow_primary_mutation = false ): array|\WP_Error { + $parsed = $this->parse_handle( $handle ); + $repo_name = $parsed['repo']; + $repo_path = $this->resolve_repo_path( $handle ); if ( is_wp_error( $repo_path ) ) { return $repo_path; } @@ -547,6 +700,11 @@ public function git_push( string $name, string $remote = 'origin', ?string $bran return $policy_check; } + $primary_check = $this->ensure_primary_mutation_allowed( $parsed, $allow_primary_mutation ); + if ( is_wp_error( $primary_check ) ) { + return $primary_check; + } + $current_branch_result = $this->run_git( $repo_path, 'rev-parse --abbrev-ref HEAD' ); if ( is_wp_error( $current_branch_result ) ) { return $current_branch_result; @@ -555,9 +713,12 @@ public function git_push( string $name, string $remote = 'origin', ?string $bran $current_branch = trim( (string) $current_branch_result['output'] ); $target_branch = $branch ? trim( $branch ) : $current_branch; - $fixed_branch = $this->get_repo_fixed_branch( $repo_name ); - if ( null !== $fixed_branch && $target_branch !== $fixed_branch ) { - return new \WP_Error( 'branch_restricted', sprintf( 'Push blocked: repo "%s" is restricted to branch "%s".', $repo_name, $fixed_branch ), array( 'status' => 403 ) ); + // fixed_branch only constrains the primary checkout. + if ( ! $parsed['is_worktree'] ) { + $fixed_branch = $this->get_repo_fixed_branch( $repo_name ); + if ( null !== $fixed_branch && $target_branch !== $fixed_branch ) { + return new \WP_Error( 'branch_restricted', sprintf( 'Push blocked: primary checkout of "%s" is restricted to branch "%s". Use a worktree for other branches.', $repo_name, $fixed_branch ), array( 'status' => 403 ) ); + } } $cmd = sprintf( 'push %s %s', escapeshellarg( $remote ), escapeshellarg( $target_branch ) ); @@ -569,7 +730,8 @@ public function git_push( string $name, string $remote = 'origin', ?string $bran return array( 'success' => true, - 'name' => $repo_name, + 'name' => $parsed['dir_name'], + 'repo' => $repo_name, 'remote' => $remote, 'branch' => $target_branch, 'message' => trim( (string) $result['output'] ), @@ -613,9 +775,11 @@ public function git_log( string $name, int $limit = 20 ): array|\WP_Error { ); } + $parsed = $this->parse_handle( $name ); return array( 'success' => true, - 'name' => $this->sanitize_name( $name ), + 'name' => $parsed['dir_name'], + 'repo' => $parsed['repo'], 'entries' => $entries, ); } @@ -664,13 +828,310 @@ public function git_diff( string $name, ?string $from = null, ?string $to = null return $diff; } + $parsed = $this->parse_handle( $name ); return array( 'success' => true, - 'name' => $this->sanitize_name( $name ), + 'name' => $parsed['dir_name'], + 'repo' => $parsed['repo'], 'diff' => $diff['output'] ?? '', ); } + // ========================================================================= + // Worktree operations + // ========================================================================= + + /** + * Create a git worktree for a branch. + * + * Layout: `/@` is added as a worktree of + * `/` checked out to ``. If the branch does not + * exist locally, it is created from `` (default `origin/HEAD`). + * + * @param string $repo Primary repo name (no @-suffix). + * @param string $branch Branch to check out (e.g. "fix/foo-bar"). + * @param string|null $from Base ref when creating the branch. + * @return array{success: bool, handle: string, path: string, branch: string, slug: string, created_branch: bool, message: string}|\WP_Error + */ + public function worktree_add( string $repo, string $branch, ?string $from = null ): array|\WP_Error { + $repo = $this->sanitize_name( $repo ); + $branch = trim( $branch ); + + if ( '' === $repo ) { + return new \WP_Error( 'invalid_repo', 'Repository name is required.', array( 'status' => 400 ) ); + } + + if ( '' === $branch ) { + return new \WP_Error( 'invalid_branch', 'Branch name is required.', array( 'status' => 400 ) ); + } + + $slug = $this->slugify_branch( $branch ); + if ( '' === $slug ) { + return new \WP_Error( 'invalid_branch', sprintf( 'Branch "%s" produced an empty slug.', $branch ), array( 'status' => 400 ) ); + } + + $primary_path = $this->get_primary_path( $repo ); + if ( ! is_dir( $primary_path ) || ! is_dir( $primary_path . '/.git' ) ) { + return new \WP_Error( 'primary_not_found', sprintf( 'Primary checkout for "%s" does not exist. Clone it first.', $repo ), array( 'status' => 404 ) ); + } + + $wt_handle = $repo . '@' . $slug; + $wt_path = $this->workspace_path . '/' . $wt_handle; + + if ( is_dir( $wt_path ) ) { + return new \WP_Error( 'worktree_exists', sprintf( 'Worktree handle "%s" already exists.', $wt_handle ), array( 'status' => 400 ) ); + } + + // Does the branch already exist locally? + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( sprintf( 'git -C %s show-ref --verify --quiet %s 2>&1', escapeshellarg( $primary_path ), escapeshellarg( 'refs/heads/' . $branch ) ), $_unused, $exists_local ); + $created_branch = false; + + if ( 0 === $exists_local ) { + $cmd = sprintf( 'worktree add %s %s', escapeshellarg( $wt_path ), escapeshellarg( $branch ) ); + } else { + $base = $from && '' !== trim( $from ) ? trim( $from ) : $this->resolve_default_base( $primary_path ); + // Fetch first to make sure remote refs are current. + $this->run_git( $primary_path, 'fetch --quiet origin' ); + $cmd = sprintf( 'worktree add -b %s %s %s', escapeshellarg( $branch ), escapeshellarg( $wt_path ), escapeshellarg( $base ) ); + $created_branch = true; + } + + $result = $this->run_git( $primary_path, $cmd ); + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + 'handle' => $wt_handle, + 'path' => $wt_path, + 'branch' => $branch, + 'slug' => $slug, + 'created_branch' => $created_branch, + 'message' => sprintf( 'Worktree "%s" added at %s (branch %s).', $wt_handle, $wt_path, $branch ), + ); + } + + /** + * List worktrees in the workspace. + * + * @param string|null $repo Optional repo filter (only this primary's worktrees). + * @return array{success: bool, worktrees: array}|\WP_Error + */ + public function worktree_list( ?string $repo = null ): array|\WP_Error { + if ( ! is_dir( $this->workspace_path ) ) { + return array( + 'success' => true, + 'worktrees' => array(), + ); + } + + $primaries = array(); + $entries = scandir( $this->workspace_path ); + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry || str_contains( $entry, '@' ) ) { + continue; + } + $entry_path = $this->workspace_path . '/' . $entry; + if ( ! is_dir( $entry_path . '/.git' ) ) { + continue; + } + $primaries[] = $entry; + } + + if ( null !== $repo ) { + $repo = $this->sanitize_name( $repo ); + $primaries = array_values( array_filter( $primaries, fn( $p ) => $p === $repo ) ); + } + + $worktrees = array(); + + foreach ( $primaries as $primary ) { + $primary_path = $this->workspace_path . '/' . $primary; + $result = $this->run_git( $primary_path, 'worktree list --porcelain' ); + if ( is_wp_error( $result ) ) { + continue; + } + + $blocks = preg_split( "/\n\n+/", trim( (string) ( $result['output'] ?? '' ) ) ); + foreach ( $blocks as $block ) { + $wt = $this->parse_worktree_block( $block ); + if ( null === $wt ) { + continue; + } + + $is_primary = ( $wt['path'] === $primary_path ); + $workspace_pfx = $this->workspace_path . '/'; + $inside_ws = str_starts_with( $wt['path'], $workspace_pfx ); + $relative = $inside_ws ? substr( $wt['path'], strlen( $workspace_pfx ) ) : ''; + $parsed = $inside_ws ? $this->parse_handle( $relative ) : array( 'branch_slug' => null ); + + if ( $is_primary ) { + $handle = $primary; + } elseif ( $inside_ws ) { + $handle = $relative; + } else { + // External worktree (created via raw `git worktree add` outside the workspace). + // Show the absolute path so it is still useful, even though it has no `@` handle. + $handle = $wt['path']; + } + + $dirty_result = $this->run_git( $wt['path'], 'status --porcelain' ); + $dirty_files = is_wp_error( $dirty_result ) + ? 0 + : count( array_filter( array_map( 'trim', explode( "\n", $dirty_result['output'] ?? '' ) ) ) ); + + $worktrees[] = array( + 'handle' => $handle, + 'repo' => $primary, + 'is_worktree' => ! $is_primary, + 'is_primary' => $is_primary, + 'external' => ! $is_primary && ! $inside_ws, + 'branch_slug' => $is_primary ? null : ( $parsed['branch_slug'] ?? null ), + 'branch' => $wt['branch'], + 'head' => $wt['head'], + 'path' => $wt['path'], + 'dirty' => $dirty_files, + ); + } + } + + return array( + 'success' => true, + 'worktrees' => $worktrees, + ); + } + + /** + * Remove a worktree. + * + * Refuses if the worktree has uncommitted changes unless `$force` is true. + * + * @param string $repo Primary repo name. + * @param string $branch Branch (or slug) of the worktree. + * @param bool $force Force removal even if dirty. + * @return array{success: bool, handle: string, message: string}|\WP_Error + */ + public function worktree_remove( string $repo, string $branch, bool $force = false ): array|\WP_Error { + $repo = $this->sanitize_name( $repo ); + if ( '' === $repo ) { + return new \WP_Error( 'invalid_repo', 'Repository name is required.', array( 'status' => 400 ) ); + } + + $slug = $this->slugify_branch( $branch ); + if ( '' === $slug ) { + return new \WP_Error( 'invalid_branch', 'Branch/slug is required.', array( 'status' => 400 ) ); + } + + $primary_path = $this->get_primary_path( $repo ); + if ( ! is_dir( $primary_path . '/.git' ) ) { + return new \WP_Error( 'primary_not_found', sprintf( 'Primary checkout for "%s" does not exist.', $repo ), array( 'status' => 404 ) ); + } + + $wt_handle = $repo . '@' . $slug; + $wt_path = $this->workspace_path . '/' . $wt_handle; + + if ( ! is_dir( $wt_path ) ) { + return new \WP_Error( 'worktree_not_found', sprintf( 'Worktree "%s" not found.', $wt_handle ), array( 'status' => 404 ) ); + } + + $cmd = sprintf( 'worktree remove %s%s', $force ? '--force ' : '', escapeshellarg( $wt_path ) ); + $result = $this->run_git( $primary_path, $cmd ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'success' => true, + 'handle' => $wt_handle, + 'message' => sprintf( 'Worktree "%s" removed.', $wt_handle ), + ); + } + + /** + * Prune stale worktree registry entries across all primaries. + * + * @return array{success: bool, pruned: array} + */ + public function worktree_prune(): array { + $pruned = array(); + + if ( ! is_dir( $this->workspace_path ) ) { + return array( + 'success' => true, + 'pruned' => $pruned, + ); + } + + $entries = scandir( $this->workspace_path ); + foreach ( $entries as $entry ) { + if ( '.' === $entry || '..' === $entry || str_contains( $entry, '@' ) ) { + continue; + } + $primary_path = $this->workspace_path . '/' . $entry; + if ( ! is_dir( $primary_path . '/.git' ) ) { + continue; + } + $this->run_git( $primary_path, 'worktree prune -v' ); + $pruned[] = $entry; + } + + return array( + 'success' => true, + 'pruned' => $pruned, + ); + } + + /** + * Resolve a sensible default base for new branches. + * + * Prefers `origin/HEAD` (typically `origin/main` or `origin/trunk`); falls + * back to plain `HEAD` if no remote default is configured. + * + * @param string $repo_path Primary repo path. + * @return string + */ + private function resolve_default_base( string $repo_path ): string { + $result = $this->run_git( $repo_path, 'symbolic-ref --quiet refs/remotes/origin/HEAD' ); + if ( ! is_wp_error( $result ) ) { + $ref = trim( (string) ( $result['output'] ?? '' ) ); + if ( '' !== $ref ) { + return $ref; + } + } + return 'HEAD'; + } + + /** + * Parse a `git worktree list --porcelain` block. + * + * @param string $block Newline-separated key/value lines. + * @return array{path: string, head: string, branch: string|null}|null + */ + private function parse_worktree_block( string $block ): ?array { + $lines = array_filter( array_map( 'trim', explode( "\n", $block ) ) ); + $out = array( + 'path' => '', + 'head' => '', + 'branch' => null, + ); + foreach ( $lines as $line ) { + if ( str_starts_with( $line, 'worktree ' ) ) { + $out['path'] = substr( $line, strlen( 'worktree ' ) ); + } elseif ( str_starts_with( $line, 'HEAD ' ) ) { + $out['head'] = substr( $line, strlen( 'HEAD ' ) ); + } elseif ( str_starts_with( $line, 'branch ' ) ) { + $ref = substr( $line, strlen( 'branch ' ) ); + $out['branch'] = preg_replace( '#^refs/heads/#', '', $ref ); + } elseif ( 'detached' === $line ) { + $out['branch'] = null; + } + } + return ( '' === $out['path'] ) ? null : $out; + } + // ========================================================================= // Internal helpers // ========================================================================= @@ -740,21 +1201,28 @@ private function sanitize_name( string $name ): string { } /** - * Resolve and validate repository path by name. + * Resolve and validate a workspace handle to a filesystem path. * - * @param string $name Repository name. - * @return string|\WP_Error String path on success, WP_Error on failure. + * Accepts both primary handles (``) and worktree handles + * (`@`). For worktrees, the .git is a file + * pointing back at the primary's .git directory — we accept both + * directory and file forms. + * + * @param string $handle Workspace handle. + * @return string|\WP_Error Real path on success, WP_Error on failure. */ - private function resolve_repo_path( string $name ): string|\WP_Error { - $sanitized = $this->sanitize_name( $name ); - $repo_path = $this->workspace_path . '/' . $sanitized; + private function resolve_repo_path( string $handle ): string|\WP_Error { + $parsed = $this->parse_handle( $handle ); + $repo_path = $this->workspace_path . '/' . $parsed['dir_name']; if ( ! is_dir( $repo_path ) ) { - return new \WP_Error( 'repo_not_found', sprintf( 'Repository "%s" not found in workspace.', $sanitized ), array( 'status' => 404 ) ); + return new \WP_Error( 'repo_not_found', sprintf( 'Workspace handle "%s" not found.', $parsed['dir_name'] ), array( 'status' => 404 ) ); } - if ( ! is_dir( $repo_path . '/.git' ) ) { - return new \WP_Error( 'not_git_repo', sprintf( 'Repository "%s" is not a git repository.', $sanitized ), array( 'status' => 400 ) ); + // .git can be a directory (primary) or a file (worktree). + $git_path = $repo_path . '/.git'; + if ( ! is_dir( $git_path ) && ! is_file( $git_path ) ) { + return new \WP_Error( 'not_git_repo', sprintf( 'Handle "%s" is not a git repository or worktree.', $parsed['dir_name'] ), array( 'status' => 400 ) ); } $validation = $this->validate_containment( $repo_path, $this->workspace_path ); @@ -796,6 +1264,35 @@ private function run_git( string $repo_path, string $git_args ): array|\WP_Error ); } + /** + * Block mutating ops on the primary checkout unless explicitly allowed. + * + * The primary is intentionally treated as the "deployed" checkout — + * agents should branch via worktrees, not switch the primary's HEAD. + * Worktree handles are always allowed. + * + * @param array{is_worktree: bool, repo: string, dir_name: string} $parsed + * @param bool $allow + * @return true|\WP_Error + */ + private function ensure_primary_mutation_allowed( array $parsed, bool $allow ): true|\WP_Error { + if ( $parsed['is_worktree'] ) { + return true; + } + if ( $allow ) { + return true; + } + 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@).', + $parsed['repo'], + $parsed['repo'] + ), + array( 'status' => 403 ) + ); + } + /** * Check if repo has git mutation permissions enabled. * diff --git a/tests/TESTING.md b/tests/TESTING.md new file mode 100644 index 0000000..e7e45b8 --- /dev/null +++ b/tests/TESTING.md @@ -0,0 +1,140 @@ +# Manual Testing — Workspace Worktrees + +End-to-end test plan for the worktree-native workspace. Pure unit-style coverage +of handle parsing and slugification lives in `smoke-worktree-handles.php`. + +Prereqs: +- WordPress 6.9+ with Data Machine + data-machine-code activated. +- A writable `~/.datamachine/workspace/` (or `DATAMACHINE_WORKSPACE_PATH`). +- `git` available on `$PATH`. + +## 1. Pure unit smoke (no WP required) + +```bash +php tests/smoke-worktree-handles.php +``` + +Expected: `32/32 passed`. + +## 2. Clone a primary checkout + +```bash +wp datamachine-code workspace clone https://github.com/Extra-Chill/data-machine-code.git +wp datamachine-code workspace list +``` + +Expected: list shows `data-machine-code` with `git: true`, `is_worktree: false`. + +Negative case — names with `@` are reserved: + +```bash +wp datamachine-code workspace clone https://example.com/r.git --name=foo@bar +``` + +Expected: `invalid_clone_name` error. + +## 3. Create a worktree + +```bash +wp datamachine-code workspace worktree add data-machine-code feat/test-worktree +wp datamachine-code workspace worktree list +ls ~/.datamachine/workspace/ +``` + +Expected: +- New dir `~/.datamachine/workspace/data-machine-code@feat-test-worktree/`. +- `worktree list` shows two entries (primary + worktree) with branch info. +- `data-machine-code@feat-test-worktree/.git` is a **file** (worktree pointer). + +Idempotency check — re-running `worktree add` with the same branch fails cleanly: + +```bash +wp datamachine-code workspace worktree add data-machine-code feat/test-worktree +``` + +Expected: `worktree_exists` error. + +## 4. Operate on the worktree handle + +```bash +wp datamachine-code workspace git status data-machine-code@feat-test-worktree +wp datamachine-code workspace ls data-machine-code@feat-test-worktree inc/ +wp datamachine-code workspace read data-machine-code@feat-test-worktree README.md --limit=20 +``` + +Expected: each command targets the worktree's directory; status reports +`is_worktree: true`, `repo: data-machine-code`. + +## 5. Primary mutation guard + +Make sure mutating the primary is blocked by default: + +```bash +wp datamachine-code workspace git pull data-machine-code +``` + +Expected: `primary_mutation_blocked` error with the suggested override. + +Override works: + +```bash +wp datamachine-code workspace git pull data-machine-code --allow-primary-mutation +``` + +Expected: pull runs (or reports clean / dirty state). The primary checkout +should still be on its original branch — pull is fast-forward only. + +## 6. Edit, commit, push from the worktree + +Set up a per-repo policy if needed (see `datamachine_workspace_git_policies` +option). For this smoke pass, set `write_enabled` and `push_enabled` for +`data-machine-code` and add `tests/` to `allowed_paths`. + +```bash +wp datamachine-code workspace write data-machine-code@feat-test-worktree tests/SMOKE.txt --content="hello" +wp datamachine-code workspace git status data-machine-code@feat-test-worktree +wp datamachine-code workspace git add data-machine-code@feat-test-worktree --path=tests/SMOKE.txt +wp datamachine-code workspace git commit data-machine-code@feat-test-worktree "test: smoke worktree commit" +wp datamachine-code workspace git log data-machine-code@feat-test-worktree --limit=3 +``` + +Expected: commit lands on the worktree's branch. Primary's branch is unchanged. +Verify: + +```bash +git -C ~/.datamachine/workspace/data-machine-code log --oneline -3 +git -C ~/.datamachine/workspace/data-machine-code@feat-test-worktree log --oneline -3 +``` + +The worktree shows the new commit; the primary does not. + +## 7. Remove the worktree + +Dirty case — should refuse without `--force`: + +```bash +wp datamachine-code workspace write data-machine-code@feat-test-worktree tests/DIRTY.txt --content="x" +wp datamachine-code workspace worktree remove data-machine-code feat/test-worktree +``` + +Expected: error from `git worktree remove` because the worktree is dirty. + +Force removal: + +```bash +wp datamachine-code workspace worktree remove data-machine-code feat/test-worktree --force +ls ~/.datamachine/workspace/ +``` + +Expected: directory gone; `worktree list` no longer shows it. + +## 8. Cleanup + +```bash +wp datamachine-code workspace worktree prune +wp datamachine-code workspace remove data-machine-code --yes +``` + +Expected: prune reports the primary it inspected; remove deletes the primary +(only after worktrees are gone — try removing while a worktree exists to confirm +the `has_worktrees` guard). diff --git a/tests/smoke-worktree-handles.php b/tests/smoke-worktree-handles.php new file mode 100644 index 0000000..c7604bf --- /dev/null +++ b/tests/smoke-worktree-handles.php @@ -0,0 +1,116 @@ + array( 'data-machine', null, false, 'data-machine' ), + 'data-machine@fix-foo' => array( 'data-machine', 'fix-foo', true, 'data-machine@fix-foo' ), + 'data-machine@fix-foo-bar' => array( 'data-machine', 'fix-foo-bar', true, 'data-machine@fix-foo-bar' ), + 'intelligence@feat.with.dots' => array( 'intelligence', 'feat.with.dots', true, 'intelligence@feat.with.dots' ), + 'repo@' => array( 'repo', null, false, 'repo' ), + // Malformed handles fall back to the bare-name parse (the `@` is stripped by sanitize_name). + '@branch' => array( 'branch', null, false, 'branch' ), + ); + + foreach ( $cases as $handle => $expected ) { + $parsed = $ws->parse_handle( $handle ); + $assert( $expected[0], $parsed['repo'], "{$handle} → repo" ); + $assert( $expected[1], $parsed['branch_slug'], "{$handle} → branch_slug" ); + $assert( $expected[2], $parsed['is_worktree'], "{$handle} → is_worktree" ); + $assert( $expected[3], $parsed['dir_name'], "{$handle} → dir_name" ); + } + + echo "\nBranch slug generation\n"; + $slug_cases = array( + 'main' => 'main', + 'fix/foo' => 'fix-foo', + 'fix/foo-bar' => 'fix-foo-bar', + 'feat/auth/oauth' => 'feat-auth-oauth', + 'release/1.2.3' => 'release-1.2.3', + 'fix//double-slash' => 'fix-double-slash', + ' spaced/branch ' => 'spaced-branch', + 'fix/foo:bad/chars!@#' => 'fix-foobad-chars', + ); + + foreach ( $slug_cases as $branch => $expected ) { + $assert( $expected, $ws->slugify_branch( $branch ), "slugify '{$branch}' → '{$expected}'" ); + } + + echo "\nResult: " . ( $total - $failures ) . "/{$total} passed\n"; + exit( $failures > 0 ? 1 : 0 ); +}