From 4ccf71a98f2c947679c7c286c9bec8047e0a91df Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 5 Mar 2026 01:15:19 +0000 Subject: [PATCH 1/2] feat: add workspace global read tools --- inc/Engine/AI/Tools/Global/WorkspaceTools.php | 360 ++++++++++++++++++ inc/Engine/AI/Tools/ToolServiceProvider.php | 2 + .../Tools/WorkspaceToolsAvailabilityTest.php | 72 ++++ 3 files changed, 434 insertions(+) create mode 100644 inc/Engine/AI/Tools/Global/WorkspaceTools.php create mode 100644 tests/Unit/AI/Tools/WorkspaceToolsAvailabilityTest.php diff --git a/inc/Engine/AI/Tools/Global/WorkspaceTools.php b/inc/Engine/AI/Tools/Global/WorkspaceTools.php new file mode 100644 index 00000000..8026b68e --- /dev/null +++ b/inc/Engine/AI/Tools/Global/WorkspaceTools.php @@ -0,0 +1,360 @@ +registerGlobalTool( 'workspace_path', array( $this, 'getPathDefinition' ) ); + $this->registerGlobalTool( 'workspace_list', array( $this, 'getListDefinition' ) ); + $this->registerGlobalTool( 'workspace_show', array( $this, 'getShowDefinition' ) ); + $this->registerGlobalTool( 'workspace_ls', array( $this, 'getLsDefinition' ) ); + $this->registerGlobalTool( 'workspace_read', array( $this, 'getReadDefinition' ) ); + } + + /** + * Dispatch tool calls to specific handlers. + * + * @param array $parameters Tool parameters. + * @param array $tool_def Tool definition with method key. + * @return array + */ + public function handle_tool_call( array $parameters, array $tool_def = array() ): array { + $method = $tool_def['method'] ?? ''; + + if ( ! method_exists( $this, $method ) ) { + return $this->buildErrorResponse( "Unknown workspace tool method: {$method}", 'workspace_tools' ); + } + + return $this->{$method}( $parameters, $tool_def ); + } + + /** + * Handle workspace_path tool call. + * + * @param array $parameters Tool parameters. + * @return array + */ + public function handlePath( array $parameters ): array { + $ability = wp_get_ability( 'datamachine/workspace-path' ); + + if ( ! $ability ) { + return $this->buildErrorResponse( 'Workspace path ability not available.', 'workspace_path' ); + } + + $result = $ability->execute( + array( + 'ensure' => ! empty( $parameters['ensure'] ), + ) + ); + + if ( is_wp_error( $result ) ) { + return $this->buildErrorResponse( $result->get_error_message(), 'workspace_path' ); + } + + if ( ! $this->isAbilitySuccess( $result ) ) { + return $this->buildErrorResponse( + $this->getAbilityError( $result, 'Failed to get workspace path.' ), + 'workspace_path' + ); + } + + return array( + 'success' => true, + 'data' => $result, + 'tool_name' => 'workspace_path', + ); + } + + /** + * Handle workspace_list tool call. + * + * @return array + */ + public function handleList(): array { + $ability = wp_get_ability( 'datamachine/workspace-list' ); + + if ( ! $ability ) { + return $this->buildErrorResponse( 'Workspace list ability not available.', 'workspace_list' ); + } + + $result = $ability->execute( array() ); + + if ( is_wp_error( $result ) ) { + return $this->buildErrorResponse( $result->get_error_message(), 'workspace_list' ); + } + + if ( ! $this->isAbilitySuccess( $result ) ) { + return $this->buildErrorResponse( + $this->getAbilityError( $result, 'Failed to list workspace repositories.' ), + 'workspace_list' + ); + } + + return array( + 'success' => true, + 'data' => $result, + 'tool_name' => 'workspace_list', + ); + } + + /** + * Handle workspace_show tool call. + * + * @param array $parameters Tool parameters. + * @return array + */ + public function handleShow( array $parameters ): array { + $ability = wp_get_ability( 'datamachine/workspace-show' ); + + if ( ! $ability ) { + return $this->buildErrorResponse( 'Workspace show ability not available.', 'workspace_show' ); + } + + $result = $ability->execute( + array( + 'name' => $parameters['name'] ?? '', + ) + ); + + if ( is_wp_error( $result ) ) { + return $this->buildErrorResponse( $result->get_error_message(), 'workspace_show' ); + } + + if ( ! $this->isAbilitySuccess( $result ) ) { + return $this->buildErrorResponse( + $this->getAbilityError( $result, 'Failed to get workspace repository details.' ), + 'workspace_show' + ); + } + + return array( + 'success' => true, + 'data' => $result, + 'tool_name' => 'workspace_show', + ); + } + + /** + * Handle workspace_ls tool call. + * + * @param array $parameters Tool parameters. + * @return array + */ + public function handleLs( array $parameters ): array { + $ability = wp_get_ability( 'datamachine/workspace-ls' ); + + if ( ! $ability ) { + return $this->buildErrorResponse( 'Workspace ls ability not available.', 'workspace_ls' ); + } + + $result = $ability->execute( + array( + 'repo' => $parameters['repo'] ?? '', + 'path' => $parameters['path'] ?? '', + ) + ); + + if ( is_wp_error( $result ) ) { + return $this->buildErrorResponse( $result->get_error_message(), 'workspace_ls' ); + } + + if ( ! $this->isAbilitySuccess( $result ) ) { + return $this->buildErrorResponse( + $this->getAbilityError( $result, 'Failed to list workspace directory.' ), + 'workspace_ls' + ); + } + + return array( + 'success' => true, + 'data' => $result, + 'tool_name' => 'workspace_ls', + ); + } + + /** + * Handle workspace_read tool call. + * + * @param array $parameters Tool parameters. + * @return array + */ + public function handleRead( array $parameters ): array { + $ability = wp_get_ability( 'datamachine/workspace-read' ); + + if ( ! $ability ) { + return $this->buildErrorResponse( 'Workspace read ability not available.', 'workspace_read' ); + } + + $input = array( + 'repo' => $parameters['repo'] ?? '', + 'path' => $parameters['path'] ?? '', + ); + + if ( isset( $parameters['max_size'] ) ) { + $input['max_size'] = (int) $parameters['max_size']; + } + + if ( isset( $parameters['offset'] ) ) { + $input['offset'] = (int) $parameters['offset']; + } + + if ( isset( $parameters['limit'] ) ) { + $input['limit'] = (int) $parameters['limit']; + } + + $result = $ability->execute( $input ); + + if ( is_wp_error( $result ) ) { + return $this->buildErrorResponse( $result->get_error_message(), 'workspace_read' ); + } + + if ( ! $this->isAbilitySuccess( $result ) ) { + return $this->buildErrorResponse( + $this->getAbilityError( $result, 'Failed to read workspace file.' ), + 'workspace_read' + ); + } + + return array( + 'success' => true, + 'data' => $result, + 'tool_name' => 'workspace_read', + ); + } + + /** + * Tool definition for workspace_path. + * + * @return array + */ + public function getPathDefinition(): array { + return array( + 'class' => __CLASS__, + 'method' => 'handlePath', + 'description' => 'Get the Data Machine workspace path. Optionally ensure it exists.', + 'parameters' => array( + 'ensure' => array( + 'type' => 'boolean', + 'required' => false, + 'description' => 'Create the workspace directory if it does not exist (default false).', + ), + ), + ); + } + + /** + * Tool definition for workspace_list. + * + * @return array + */ + public function getListDefinition(): array { + return array( + 'class' => __CLASS__, + 'method' => 'handleList', + 'description' => 'List repositories currently present in the Data Machine workspace.', + 'parameters' => array(), + ); + } + + /** + * Tool definition for workspace_show. + * + * @return array + */ + public function getShowDefinition(): array { + return array( + 'class' => __CLASS__, + 'method' => 'handleShow', + 'description' => 'Show detailed information about a workspace repository (branch, remote, latest commit, dirty count).', + 'parameters' => array( + 'name' => array( + 'type' => 'string', + 'required' => true, + 'description' => 'Workspace repository directory name.', + ), + ), + ); + } + + /** + * Tool definition for workspace_ls. + * + * @return array + */ + public function getLsDefinition(): array { + return array( + 'class' => __CLASS__, + 'method' => 'handleLs', + 'description' => 'List directory contents within a workspace repository.', + 'parameters' => array( + 'repo' => array( + 'type' => 'string', + 'required' => true, + 'description' => 'Workspace repository directory name.', + ), + 'path' => array( + 'type' => 'string', + 'required' => false, + 'description' => 'Optional relative directory path inside the repo.', + ), + ), + ); + } + + /** + * Tool definition for workspace_read. + * + * @return array + */ + public function getReadDefinition(): array { + return array( + 'class' => __CLASS__, + 'method' => 'handleRead', + 'description' => 'Read a text file from a workspace repository. Supports optional max_size, offset, and limit for large files.', + 'parameters' => array( + 'repo' => array( + 'type' => 'string', + 'required' => true, + 'description' => 'Workspace repository directory name.', + ), + 'path' => array( + 'type' => 'string', + 'required' => true, + 'description' => 'Relative file path inside the repository.', + ), + 'max_size' => array( + 'type' => 'integer', + 'required' => false, + 'description' => 'Maximum readable size in bytes (default 1MB).', + ), + 'offset' => array( + 'type' => 'integer', + 'required' => false, + 'description' => 'Line offset to start reading from (1-indexed).', + ), + 'limit' => array( + 'type' => 'integer', + 'required' => false, + 'description' => 'Maximum number of lines to return.', + ), + ), + ); + } +} diff --git a/inc/Engine/AI/Tools/ToolServiceProvider.php b/inc/Engine/AI/Tools/ToolServiceProvider.php index ffd52935..d3ac4d32 100644 --- a/inc/Engine/AI/Tools/ToolServiceProvider.php +++ b/inc/Engine/AI/Tools/ToolServiceProvider.php @@ -28,6 +28,7 @@ use DataMachine\Engine\AI\Tools\Global\LocalSearch; use DataMachine\Engine\AI\Tools\Global\QueueValidator; use DataMachine\Engine\AI\Tools\Global\WebFetch; +use DataMachine\Engine\AI\Tools\Global\WorkspaceTools; use DataMachine\Engine\AI\Tools\Global\WordPressPostReader; // Chat tools. @@ -97,6 +98,7 @@ private static function registerGlobalTools(): void { new LocalSearch(); new QueueValidator(); new WebFetch(); + new WorkspaceTools(); new WordPressPostReader(); } diff --git a/tests/Unit/AI/Tools/WorkspaceToolsAvailabilityTest.php b/tests/Unit/AI/Tools/WorkspaceToolsAvailabilityTest.php new file mode 100644 index 00000000..243a5c12 --- /dev/null +++ b/tests/Unit/AI/Tools/WorkspaceToolsAvailabilityTest.php @@ -0,0 +1,72 @@ +getAvailableToolsForChat(); + + $this->assertIsArray( $tools ); + $this->assertArrayHasKey( 'workspace_path', $tools ); + $this->assertArrayHasKey( 'workspace_list', $tools ); + $this->assertArrayHasKey( 'workspace_show', $tools ); + $this->assertArrayHasKey( 'workspace_ls', $tools ); + $this->assertArrayHasKey( 'workspace_read', $tools ); + } + + /** + * Verify pipeline tool list includes workspace global read tools. + */ + public function test_pipeline_tools_include_workspace_global_read_tools(): void { + $pipelines = new Pipelines(); + $pipeline_id = $pipelines->create_pipeline( + array( + 'pipeline_name' => 'Workspace Tools Pipeline', + 'pipeline_config' => array(), + ) + ); + + $this->assertIsInt( $pipeline_id ); + $this->assertGreaterThan( 0, $pipeline_id ); + + $pipeline_step_id = $pipeline_id . '_workspace-tools-step'; + $updated = $pipelines->update_pipeline( + $pipeline_id, + array( + 'pipeline_config' => array( + $pipeline_step_id => array( + 'step_type' => 'fetch', + 'disabled_tools' => array(), + 'handler_slugs' => array(), + 'handler_configs' => array(), + ), + ), + ) + ); + + $this->assertTrue( $updated ); + + $tools = ToolExecutor::getAvailableTools( null, null, $pipeline_step_id, array() ); + + $this->assertIsArray( $tools ); + $this->assertArrayHasKey( 'workspace_path', $tools ); + $this->assertArrayHasKey( 'workspace_list', $tools ); + $this->assertArrayHasKey( 'workspace_show', $tools ); + $this->assertArrayHasKey( 'workspace_ls', $tools ); + $this->assertArrayHasKey( 'workspace_read', $tools ); + } +} From 61e464b425ab2071cdb42c61587383592f349730 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Thu, 5 Mar 2026 01:21:33 +0000 Subject: [PATCH 2/2] feat: add workspace git abilities and CLI operations --- inc/Abilities/WorkspaceAbilities.php | 379 +++++++++++++++ inc/Cli/Commands/WorkspaceCommand.php | 220 +++++++++ inc/Core/FilesRepository/Workspace.php | 608 +++++++++++++++++++++++++ 3 files changed, 1207 insertions(+) diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 6f8188ac..168cbcd0 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -396,6 +396,283 @@ private function registerAbilities(): void { 'meta' => array( 'show_in_rest' => false ), ) ); + + wp_register_ability( + 'datamachine/workspace-git-status', + array( + 'label' => 'Workspace Git Status', + 'description' => 'Get git status information for a workspace repository.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Repository directory name.', + ), + ), + 'required' => array( 'name' ), + ), + '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( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ), + 'execute_callback' => array( self::class, 'gitStatus' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-git-log', + array( + 'label' => 'Workspace Git Log', + 'description' => 'Read git log entries for a workspace repository.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Repository directory name.', + ), + 'limit' => array( + 'type' => 'integer', + 'description' => 'Maximum log entries to return (1-100).', + ), + ), + 'required' => array( 'name' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'name' => array( 'type' => 'string' ), + 'entries' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'hash' => array( 'type' => 'string' ), + 'author' => array( 'type' => 'string' ), + 'date' => array( 'type' => 'string' ), + 'subject' => array( 'type' => 'string' ), + ), + ), + ), + ), + ), + 'execute_callback' => array( self::class, 'gitLog' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-git-diff', + array( + 'label' => 'Workspace Git Diff', + 'description' => 'Read git diff output for a workspace repository.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Repository directory name.', + ), + 'from' => array( + 'type' => 'string', + 'description' => 'Optional from git ref.', + ), + 'to' => array( + 'type' => 'string', + 'description' => 'Optional to git ref.', + ), + 'staged' => array( + 'type' => 'boolean', + 'description' => 'Read staged diff instead of working tree diff.', + ), + 'path' => array( + 'type' => 'string', + 'description' => 'Optional relative path filter.', + ), + ), + 'required' => array( 'name' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'name' => array( 'type' => 'string' ), + 'diff' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'gitDiff' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-git-pull', + array( + 'label' => 'Workspace Git Pull', + 'description' => 'Run git pull --ff-only for a workspace repository.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Repository directory name.', + ), + 'allow_dirty' => array( + 'type' => 'boolean', + 'description' => 'Allow pull when working tree is dirty.', + ), + ), + 'required' => array( 'name' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'name' => array( 'type' => 'string' ), + 'message' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'gitPull' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-git-add', + array( + 'label' => 'Workspace Git Add', + 'description' => 'Stage repository paths with git add.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Repository directory name.', + ), + 'paths' => array( + 'type' => 'array', + 'description' => 'Relative paths to stage.', + 'items' => array( 'type' => 'string' ), + ), + ), + 'required' => array( 'name', 'paths' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'name' => array( 'type' => 'string' ), + 'paths' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'message' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'gitAdd' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-git-commit', + array( + 'label' => 'Workspace Git Commit', + 'description' => 'Commit staged changes in a workspace repository.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Repository directory name.', + ), + 'message' => array( + 'type' => 'string', + 'description' => 'Commit message.', + ), + ), + 'required' => array( 'name', 'message' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'name' => array( 'type' => 'string' ), + 'commit' => array( 'type' => 'string' ), + 'message' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'gitCommit' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-git-push', + array( + 'label' => 'Workspace Git Push', + 'description' => 'Push commits for a workspace repository.', + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Repository directory name.', + ), + 'remote' => array( + 'type' => 'string', + 'description' => 'Remote name (default origin).', + ), + 'branch' => array( + 'type' => 'string', + 'description' => 'Branch override.', + ), + ), + 'required' => array( 'name' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'name' => array( 'type' => 'string' ), + 'remote' => array( 'type' => 'string' ), + 'branch' => array( 'type' => 'string' ), + 'message' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'gitPush' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); }; if ( doing_action( 'wp_abilities_api_init' ) ) { @@ -553,4 +830,106 @@ public static function editFile( array $input ): array { ! empty( $input['replace_all'] ) ); } + + /** + * Get git status details for a workspace repository. + * + * @param array $input Input parameters with 'name'. + * @return array + */ + public static function gitStatus( array $input ): array { + $workspace = new Workspace(); + return $workspace->git_status( $input['name'] ?? '' ); + } + + /** + * Pull latest changes for a workspace repository. + * + * @param array $input Input parameters with 'name', optional 'allow_dirty'. + * @return array + */ + public static function gitPull( array $input ): array { + $workspace = new Workspace(); + return $workspace->git_pull( + $input['name'] ?? '', + ! empty( $input['allow_dirty'] ) + ); + } + + /** + * Stage paths in a workspace repository. + * + * @param array $input Input parameters with 'name', 'paths'. + * @return array + */ + public static function gitAdd( array $input ): array { + $workspace = new Workspace(); + $paths = $input['paths'] ?? array(); + + if ( ! is_array( $paths ) ) { + $paths = array(); + } + + return $workspace->git_add( $input['name'] ?? '', $paths ); + } + + /** + * Commit staged changes in a workspace repository. + * + * @param array $input Input parameters with 'name', 'message'. + * @return array + */ + public static function gitCommit( array $input ): array { + $workspace = new Workspace(); + return $workspace->git_commit( + $input['name'] ?? '', + $input['message'] ?? '' + ); + } + + /** + * Push commits for a workspace repository. + * + * @param array $input Input parameters with 'name', optional 'remote', 'branch'. + * @return array + */ + public static function gitPush( array $input ): array { + $workspace = new Workspace(); + return $workspace->git_push( + $input['name'] ?? '', + $input['remote'] ?? 'origin', + $input['branch'] ?? null + ); + } + + /** + * 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 { + $workspace = new Workspace(); + return $workspace->git_log( + $input['name'] ?? '', + isset( $input['limit'] ) ? (int) $input['limit'] : 20 + ); + } + + /** + * Read git diff output for a workspace repository. + * + * @param array $input Input parameters. + * @return array + */ + public static function gitDiff( array $input ): array { + $workspace = new Workspace(); + return $workspace->git_diff( + $input['name'] ?? '', + $input['from'] ?? null, + $input['to'] ?? null, + ! empty( $input['staged'] ), + $input['path'] ?? null + ); + } } diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 01139eb0..a27eb16d 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -683,4 +683,224 @@ private function resolveAtFile( string $value ): string { return $content; } + + /** + * Git operations for workspace repositories. + * + * ## OPTIONS + * + * + * : Git operation: status, pull, add, commit, push, log, diff + * + * + * : Repository directory name. + * + * [] + * : Optional operation value (e.g., commit message for commit). + * + * [--path=] + * : Relative path (repeatable) for add/diff operations. + * + * [--allow-dirty] + * : Allow pull with dirty working tree. + * + * [--remote=] + * : Remote name for push (default: origin). + * + * [--branch=] + * : Branch override for push. + * + * [--from=] + * : From ref for diff. + * + * [--to=] + * : To ref for diff. + * + * [--staged] + * : Show staged diff. + * + * [--limit=] + * : Number of log entries to return (default 20). + * + * ## EXAMPLES + * + * # Show git status for a workspace repo + * wp datamachine workspace git status data-machine + * + * # Pull latest changes + * wp datamachine workspace git pull data-machine + * + * # Stage docs paths + * wp datamachine workspace git add extrachill-docs --path=ec_docs/community/getting-started.md + * + * # Commit staged changes + * wp datamachine workspace git commit extrachill-docs "docs: update community guide" + * + * # Push current branch to origin + * wp datamachine workspace git push extrachill-docs --remote=origin + * + * # Show recent log + * wp datamachine workspace git log data-machine --limit=10 + * + * # Show diff for a path + * wp datamachine workspace git diff data-machine --path=inc/Core/FilesRepository/Workspace.php + * + * @subcommand git + */ + public function git( array $args, array $assoc_args ): void { + $operation = $args[0] ?? ''; + $repo = $args[1] ?? ''; + + if ( '' === $operation || '' === $repo ) { + WP_CLI::error( 'Usage: wp datamachine workspace git [] [--flags]' ); + return; + } + + $ability_name = match ( $operation ) { + 'status' => 'datamachine/workspace-git-status', + 'pull' => 'datamachine/workspace-git-pull', + 'add' => 'datamachine/workspace-git-add', + 'commit' => 'datamachine/workspace-git-commit', + 'push' => 'datamachine/workspace-git-push', + 'log' => 'datamachine/workspace-git-log', + 'diff' => 'datamachine/workspace-git-diff', + default => '', + }; + + if ( '' === $ability_name ) { + WP_CLI::error( sprintf( 'Unknown git operation: %s', $operation ) ); + return; + } + + $ability = wp_get_ability( $ability_name ); + if ( ! $ability ) { + WP_CLI::error( sprintf( 'Workspace git ability not available: %s', $ability_name ) ); + return; + } + + $input = array( 'name' => $repo ); + + if ( 'pull' === $operation ) { + $input['allow_dirty'] = ! empty( $assoc_args['allow-dirty'] ); + } + + if ( 'add' === $operation ) { + $paths = $assoc_args['path'] ?? array(); + if ( ! is_array( $paths ) ) { + $paths = array( $paths ); + } + $input['paths'] = array_values( array_filter( array_map( 'strval', $paths ) ) ); + + if ( empty( $input['paths'] ) ) { + WP_CLI::error( 'git add requires at least one --path=.' ); + return; + } + } + + if ( 'commit' === $operation ) { + $message = $args[2] ?? ''; + if ( '' === trim( $message ) ) { + WP_CLI::error( 'git commit requires a commit message as the third argument.' ); + return; + } + $input['message'] = $message; + } + + if ( 'push' === $operation ) { + $input['remote'] = $assoc_args['remote'] ?? 'origin'; + if ( ! empty( $assoc_args['branch'] ) ) { + $input['branch'] = (string) $assoc_args['branch']; + } + } + + if ( 'log' === $operation ) { + if ( isset( $assoc_args['limit'] ) ) { + $input['limit'] = (int) $assoc_args['limit']; + } + } + + if ( 'diff' === $operation ) { + if ( isset( $assoc_args['from'] ) ) { + $input['from'] = (string) $assoc_args['from']; + } + if ( isset( $assoc_args['to'] ) ) { + $input['to'] = (string) $assoc_args['to']; + } + if ( ! empty( $assoc_args['staged'] ) ) { + $input['staged'] = true; + } + if ( isset( $assoc_args['path'] ) ) { + $path = $assoc_args['path']; + $input['path'] = is_array( $path ) ? (string) reset( $path ) : (string) $path; + } + } + + $result = $ability->execute( $input ); + + if ( is_wp_error( $result ) ) { + WP_CLI::error( $result->get_error_message() ); + return; + } + + if ( empty( $result['success'] ) ) { + WP_CLI::error( $result['message'] ?? 'Workspace git operation failed.' ); + return; + } + + $this->renderGitOperationResult( $operation, $result, $assoc_args ); + } + + /** + * Render CLI output for workspace git operations. + * + * @param string $operation Git operation. + * @param array $result Ability result. + * @param array $assoc_args CLI assoc args. + */ + private function renderGitOperationResult( string $operation, array $result, array $assoc_args ): void { + switch ( $operation ) { + case 'status': + WP_CLI::log( sprintf( 'Repo: %s', $result['name'] ?? '-' ) ); + WP_CLI::log( sprintf( 'Path: %s', $result['path'] ?? '-' ) ); + WP_CLI::log( sprintf( 'Branch: %s', $result['branch'] ?? '-' ) ); + WP_CLI::log( sprintf( 'Remote: %s', $result['remote'] ?? '-' ) ); + WP_CLI::log( sprintf( 'Latest: %s', $result['commit'] ?? '-' ) ); + $dirty = (int) ( $result['dirty'] ?? 0 ); + WP_CLI::log( sprintf( 'Dirty: %s', 0 === $dirty ? 'no' : "yes ({$dirty} files)" ) ); + if ( ! empty( $result['files'] ) ) { + WP_CLI::log( '' ); + foreach ( $result['files'] as $file ) { + WP_CLI::log( (string) $file ); + } + } + return; + + case 'log': + if ( empty( $result['entries'] ) ) { + WP_CLI::log( 'No commits found.' ); + return; + } + + $items = array_map( + fn( $entry ) => array( + 'hash' => $entry['hash'] ?? '', + 'author' => $entry['author'] ?? '', + 'date' => $entry['date'] ?? '', + 'subject' => $entry['subject'] ?? '', + ), + $result['entries'] + ); + + $this->format_items( $items, array( 'hash', 'author', 'date', 'subject' ), $assoc_args, 'hash' ); + return; + + case 'diff': + WP_CLI::log( (string) ( $result['diff'] ?? '' ) ); + return; + + default: + WP_CLI::success( $result['message'] ?? 'Workspace git operation completed.' ); + return; + } + } } diff --git a/inc/Core/FilesRepository/Workspace.php b/inc/Core/FilesRepository/Workspace.php index 4011cdab..df03ff09 100644 --- a/inc/Core/FilesRepository/Workspace.php +++ b/inc/Core/FilesRepository/Workspace.php @@ -311,6 +311,388 @@ public function show_repo( string $name ): array { ); } + /** + * Get git status details for a workspace repository. + * + * @param string $name Repository directory name. + * @return array + */ + public function git_status( string $name ): array { + $repo_path = $this->resolve_repo_path( $name ); + if ( is_array( $repo_path ) ) { + return $repo_path; + } + + $status_result = $this->run_git( $repo_path, 'status --porcelain' ); + if ( ! $status_result['success'] ) { + return $status_result; + } + + $branch_result = $this->run_git( $repo_path, 'rev-parse --abbrev-ref HEAD' ); + $remote_result = $this->run_git( $repo_path, 'config --get remote.origin.url' ); + $latest_result = $this->run_git( $repo_path, 'log -1 --format="%h %s"' ); + + $files = array_filter( array_map( 'trim', explode( "\n", $status_result['output'] ?? '' ) ) ); + + return array( + 'success' => true, + 'name' => $this->sanitize_name( $name ), + 'path' => $repo_path, + 'branch' => $branch_result['success'] ? trim( (string) $branch_result['output'] ) : null, + 'remote' => $remote_result['success'] ? trim( (string) $remote_result['output'] ) : null, + 'commit' => $latest_result['success'] ? trim( (string) $latest_result['output'] ) : null, + 'dirty' => count( $files ), + 'files' => array_values( $files ), + ); + } + + /** + * Pull latest changes for a workspace repository. + * + * @param string $name Repository directory name. + * @param bool $allow_dirty Allow pull with dirty working tree. + * @return array + */ + public function git_pull( string $name, bool $allow_dirty = false ): array { + $repo_path = $this->resolve_repo_path( $name ); + if ( is_array( $repo_path ) ) { + return $repo_path; + } + + $policy_check = $this->ensure_git_mutation_allowed( $this->sanitize_name( $name ) ); + if ( ! $policy_check['success'] ) { + return $policy_check; + } + + $status = $this->git_status( $name ); + if ( ! $status['success'] ) { + return $status; + } + + if ( ! $allow_dirty && ( $status['dirty'] ?? 0 ) > 0 ) { + return array( + 'success' => false, + 'message' => 'Working tree is dirty. Commit/stash changes first or pass allow_dirty=true.', + ); + } + + $result = $this->run_git( $repo_path, 'pull --ff-only' ); + + if ( ! $result['success'] ) { + return $result; + } + + return array( + 'success' => true, + 'message' => trim( (string) $result['output'] ), + 'name' => $this->sanitize_name( $name ), + ); + } + + /** + * Stage paths in a workspace repository. + * + * @param string $name Repository directory name. + * @param array $paths Relative paths to stage. + * @return array + */ + public function git_add( string $name, array $paths ): array { + $repo_name = $this->sanitize_name( $name ); + $repo_path = $this->resolve_repo_path( $name ); + if ( is_array( $repo_path ) ) { + return $repo_path; + } + + $policy_check = $this->ensure_git_mutation_allowed( $repo_name ); + if ( ! $policy_check['success'] ) { + return $policy_check; + } + + if ( empty( $paths ) ) { + return array( + 'success' => false, + 'message' => 'At least one path is required for git add.', + ); + } + + $allowed_roots = $this->get_repo_allowed_paths( $repo_name ); + if ( empty( $allowed_roots ) ) { + return array( + 'success' => false, + 'message' => sprintf( 'No allowed paths configured for repo "%s".', $repo_name ), + ); + } + + $clean_paths = array(); + foreach ( $paths as $path ) { + $relative = trim( (string) $path ); + if ( '' === $relative ) { + continue; + } + + if ( $this->has_traversal( $relative ) || str_starts_with( $relative, '/' ) ) { + return array( + 'success' => false, + 'message' => sprintf( 'Invalid path for git add: %s', $relative ), + ); + } + + if ( $this->is_sensitive_path( $relative ) ) { + return array( + 'success' => false, + 'message' => sprintf( 'Refusing to stage sensitive path: %s', $relative ), + ); + } + + if ( ! $this->is_path_allowed( $relative, $allowed_roots ) ) { + return array( + 'success' => false, + 'message' => sprintf( 'Path "%s" is outside configured allowlist.', $relative ), + ); + } + + $clean_paths[] = $relative; + } + + if ( empty( $clean_paths ) ) { + return array( + 'success' => false, + 'message' => 'No valid paths provided for git add.', + ); + } + + $escaped_paths = array_map( 'escapeshellarg', $clean_paths ); + $result = $this->run_git( $repo_path, 'add -- ' . implode( ' ', $escaped_paths ) ); + + if ( ! $result['success'] ) { + return $result; + } + + return array( + 'success' => true, + 'name' => $repo_name, + 'paths' => $clean_paths, + 'message' => 'Paths staged successfully.', + ); + } + + /** + * Commit staged changes in a workspace repository. + * + * @param string $name Repository directory name. + * @param string $message Commit message. + * @return array + */ + public function git_commit( string $name, string $message ): array { + $repo_name = $this->sanitize_name( $name ); + $repo_path = $this->resolve_repo_path( $name ); + if ( is_array( $repo_path ) ) { + return $repo_path; + } + + $policy_check = $this->ensure_git_mutation_allowed( $repo_name, true ); + if ( ! $policy_check['success'] ) { + return $policy_check; + } + + $message = trim( $message ); + if ( '' === $message ) { + return array( + 'success' => false, + 'message' => 'Commit message is required.', + ); + } + + if ( strlen( $message ) < 8 ) { + return array( + 'success' => false, + 'message' => 'Commit message must be at least 8 characters.', + ); + } + + if ( strlen( $message ) > 200 ) { + return array( + 'success' => false, + 'message' => 'Commit message must be 200 characters or fewer.', + ); + } + + $staged = $this->run_git( $repo_path, 'diff --cached --name-only' ); + if ( ! $staged['success'] ) { + return $staged; + } + + $staged_files = array_filter( array_map( 'trim', explode( "\n", $staged['output'] ?? '' ) ) ); + if ( empty( $staged_files ) ) { + return array( + 'success' => false, + 'message' => 'No staged changes to commit.', + ); + } + + $commit = $this->run_git( $repo_path, 'commit -m ' . escapeshellarg( $message ) ); + if ( ! $commit['success'] ) { + return $commit; + } + + return array( + 'success' => true, + 'name' => $repo_name, + 'commit' => trim( (string) $commit['output'] ), + 'message' => 'Commit created successfully.', + ); + } + + /** + * Push commits for a workspace repository. + * + * @param string $name Repository directory name. + * @param string $remote Remote name. + * @param string|null $branch Branch override. + * @return array + */ + public function git_push( string $name, string $remote = 'origin', ?string $branch = null ): array { + $repo_name = $this->sanitize_name( $name ); + $repo_path = $this->resolve_repo_path( $name ); + if ( is_array( $repo_path ) ) { + return $repo_path; + } + + $policy_check = $this->ensure_git_mutation_allowed( $repo_name, true ); + if ( ! $policy_check['success'] ) { + return $policy_check; + } + + $current_branch_result = $this->run_git( $repo_path, 'rev-parse --abbrev-ref HEAD' ); + if ( ! $current_branch_result['success'] ) { + return $current_branch_result; + } + + $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 array( + 'success' => false, + 'message' => sprintf( 'Push blocked: repo "%s" is restricted to branch "%s".', $repo_name, $fixed_branch ), + ); + } + + $cmd = sprintf( 'push %s %s', escapeshellarg( $remote ), escapeshellarg( $target_branch ) ); + $result = $this->run_git( $repo_path, $cmd ); + + if ( ! $result['success'] ) { + return $result; + } + + return array( + 'success' => true, + 'name' => $repo_name, + 'remote' => $remote, + 'branch' => $target_branch, + 'message' => trim( (string) $result['output'] ), + ); + } + + /** + * Read git log entries for a workspace repository. + * + * @param string $name Repository directory name. + * @param int $limit Number of entries. + * @return array + */ + public function git_log( string $name, int $limit = 20 ): array { + $repo_path = $this->resolve_repo_path( $name ); + if ( is_array( $repo_path ) ) { + return $repo_path; + } + + $limit = max( 1, min( 100, $limit ) ); + $cmd = sprintf( 'log -n %d --pretty=format:%s', $limit, escapeshellarg( '%h|%an|%ad|%s' ) ); + $log = $this->run_git( $repo_path, $cmd ); + + if ( ! $log['success'] ) { + return $log; + } + + $entries = array(); + $lines = array_filter( array_map( 'trim', explode( "\n", $log['output'] ?? '' ) ) ); + foreach ( $lines as $line ) { + $parts = explode( '|', $line, 4 ); + if ( count( $parts ) < 4 ) { + continue; + } + + $entries[] = array( + 'hash' => $parts[0], + 'author' => $parts[1], + 'date' => $parts[2], + 'subject' => $parts[3], + ); + } + + return array( + 'success' => true, + 'name' => $this->sanitize_name( $name ), + 'entries' => $entries, + ); + } + + /** + * Read git diff for a workspace repository. + * + * @param string $name Repository directory name. + * @param string|null $from Optional from ref. + * @param string|null $to Optional to ref. + * @param bool $staged Whether to diff staged changes. + * @param string|null $path Optional relative path filter. + * @return array + */ + public function git_diff( string $name, ?string $from = null, ?string $to = null, bool $staged = false, ?string $path = null ): array { + $repo_path = $this->resolve_repo_path( $name ); + if ( is_array( $repo_path ) ) { + return $repo_path; + } + + $args = array( 'diff' ); + if ( $staged ) { + $args[] = '--cached'; + } + + if ( ! empty( $from ) ) { + $args[] = escapeshellarg( $from ); + } + + if ( ! empty( $to ) ) { + $args[] = escapeshellarg( $to ); + } + + if ( ! empty( $path ) ) { + $relative = trim( $path ); + if ( $this->has_traversal( $relative ) || str_starts_with( $relative, '/' ) ) { + return array( + 'success' => false, + 'message' => sprintf( 'Invalid diff path: %s', $relative ), + ); + } + + $args[] = '--'; + $args[] = escapeshellarg( $relative ); + } + + $diff = $this->run_git( $repo_path, implode( ' ', $args ) ); + if ( ! $diff['success'] ) { + return $diff; + } + + return array( + 'success' => true, + 'name' => $this->sanitize_name( $name ), + 'diff' => $diff['output'] ?? '', + ); + } + // ========================================================================= // Internal helpers // ========================================================================= @@ -379,6 +761,232 @@ private function sanitize_name( string $name ): string { return preg_replace( '/[^a-zA-Z0-9._-]/', '', $name ); } + /** + * Resolve and validate repository path by name. + * + * @param string $name Repository name. + * @return string|array String path on success, error array on failure. + */ + private function resolve_repo_path( string $name ): string|array { + $sanitized = $this->sanitize_name( $name ); + $repo_path = $this->workspace_path . '/' . $sanitized; + + if ( ! is_dir( $repo_path ) ) { + return array( + 'success' => false, + 'message' => sprintf( 'Repository "%s" not found in workspace.', $sanitized ), + ); + } + + if ( ! is_dir( $repo_path . '/.git' ) ) { + return array( + 'success' => false, + 'message' => sprintf( 'Repository "%s" is not a git repository.', $sanitized ), + ); + } + + $validation = $this->validate_containment( $repo_path, $this->workspace_path ); + if ( ! $validation['valid'] ) { + return array( + 'success' => false, + 'message' => $validation['message'], + ); + } + + return $validation['real_path']; + } + + /** + * Run a git command in a repository. + * + * @param string $repo_path Resolved repository path. + * @param string $git_args Git arguments (without leading "git"). + * @return array + */ + private function run_git( string $repo_path, string $git_args ): array { + $escaped_repo = escapeshellarg( $repo_path ); + $command = sprintf( 'git -C %s %s 2>&1', $escaped_repo, $git_args ); + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec + exec( $command, $output, $exit_code ); + + if ( 0 !== $exit_code ) { + return array( + 'success' => false, + 'message' => sprintf( 'Git command failed (exit %d): %s', $exit_code, implode( "\n", $output ) ), + 'output' => implode( "\n", $output ), + ); + } + + return array( + 'success' => true, + 'output' => implode( "\n", $output ), + ); + } + + /** + * Check if repo has git mutation permissions enabled. + * + * @param string $repo_name Repository name. + * @param bool $require_push Whether push must also be enabled. + * @return array + */ + private function ensure_git_mutation_allowed( string $repo_name, bool $require_push = false ): array { + $policies = $this->get_workspace_git_policies(); + $repo = $policies['repos'][ $repo_name ] ?? null; + + if ( ! is_array( $repo ) || empty( $repo['write_enabled'] ) ) { + return array( + 'success' => false, + 'message' => sprintf( 'Git write operations are disabled for repo "%s".', $repo_name ), + ); + } + + if ( $require_push && empty( $repo['push_enabled'] ) ) { + return array( + 'success' => false, + 'message' => sprintf( 'Git push is disabled for repo "%s".', $repo_name ), + ); + } + + return array( 'success' => true ); + } + + /** + * Get allowed relative paths for staged mutations. + * + * @param string $repo_name Repository name. + * @return array + */ + private function get_repo_allowed_paths( string $repo_name ): array { + $policies = $this->get_workspace_git_policies(); + $repo = $policies['repos'][ $repo_name ] ?? array(); + + $paths = $repo['allowed_paths'] ?? array(); + if ( ! is_array( $paths ) ) { + return array(); + } + + $clean = array(); + foreach ( $paths as $path ) { + $normalized = trim( (string) $path ); + if ( '' === $normalized ) { + continue; + } + + $normalized = ltrim( str_replace( '\\', '/', $normalized ), '/' ); + $normalized = rtrim( $normalized, '/' ); + $clean[] = $normalized; + } + + return array_values( array_unique( $clean ) ); + } + + /** + * Get fixed branch restriction for a repo. + * + * @param string $repo_name Repository name. + * @return string|null + */ + private function get_repo_fixed_branch( string $repo_name ): ?string { + $policies = $this->get_workspace_git_policies(); + $repo = $policies['repos'][ $repo_name ] ?? array(); + $branch = trim( (string) ( $repo['fixed_branch'] ?? '' ) ); + + return '' === $branch ? null : $branch; + } + + /** + * Check if a relative path is within the allowlist. + * + * @param string $path Relative path. + * @param array $allowed_paths Allowed roots. + * @return bool + */ + private function is_path_allowed( string $path, array $allowed_paths ): bool { + $normalized = ltrim( str_replace( '\\', '/', $path ), '/' ); + + foreach ( $allowed_paths as $allowed ) { + $root = ltrim( str_replace( '\\', '/', (string) $allowed ), '/' ); + if ( '' === $root ) { + continue; + } + + if ( $normalized === $root || str_starts_with( $normalized, $root . '/' ) ) { + return true; + } + } + + return false; + } + + /** + * Check whether a path appears sensitive. + * + * @param string $path Relative path. + * @return bool + */ + private function is_sensitive_path( string $path ): bool { + $normalized = strtolower( ltrim( str_replace( '\\', '/', $path ), '/' ) ); + + $sensitive_patterns = array( + '.env', + 'credentials.json', + 'id_rsa', + 'id_ed25519', + '.pem', + '.key', + 'secrets', + ); + + foreach ( $sensitive_patterns as $pattern ) { + if ( str_contains( $normalized, $pattern ) ) { + return true; + } + } + + return false; + } + + /** + * Basic traversal detection for relative paths. + * + * @param string $path Relative path. + * @return bool + */ + private function has_traversal( string $path ): bool { + $parts = explode( '/', str_replace( '\\', '/', $path ) ); + foreach ( $parts as $part ) { + if ( '..' === $part || '.' === $part ) { + return true; + } + } + + return false; + } + + /** + * Read workspace git policy settings. + * + * @return array + */ + private function get_workspace_git_policies(): array { + $defaults = array( + 'repos' => array(), + ); + + $settings = get_option( 'datamachine_workspace_git_policies', $defaults ); + if ( ! is_array( $settings ) ) { + return $defaults; + } + + if ( ! isset( $settings['repos'] ) || ! is_array( $settings['repos'] ) ) { + $settings['repos'] = array(); + } + + return $settings; + } + /** * Get the origin remote URL for a git repo. *