From 602b7a939dad8233475027edca8bd4ac892bf250 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 16 May 2026 11:14:23 -0400 Subject: [PATCH 1/3] feat: add workspace PR rebase abilities --- inc/Abilities/WorkspaceAbilities.php | 414 +++++++++++++++--- inc/Cli/Commands/WorkspaceCommand.php | 116 +++-- inc/Tools/WorkspaceTools.php | 120 ++++- inc/Workspace/WorkspaceGitOperations.php | 529 ++++++++++++++++++++++- tests/smoke-workspace-policy-tools.php | 4 + tests/smoke-workspace-pr-rebase.php | 146 +++++++ 6 files changed, 1224 insertions(+), 105 deletions(-) create mode 100644 tests/smoke-workspace-pr-rebase.php diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index be5ba4e..fc64c2b 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -556,7 +556,7 @@ private function registerAbilities(): void { 'type' => 'string', 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', ), - 'patch' => array( + 'patch' => array( 'type' => 'string', 'description' => 'Unified diff content to apply.', ), @@ -917,6 +917,14 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Permit pushing from the primary checkout (default false). Worktrees are always allowed.', ), + 'force_with_lease' => array( + 'type' => 'boolean', + 'description' => 'Use git push --force-with-lease. Refuses protected base/fixed branches.', + ), + 'expected_sha' => array( + 'type' => 'string', + 'description' => 'Optional expected remote branch SHA for --force-with-lease.', + ), ), 'required' => array( 'name' ), ), @@ -931,6 +939,8 @@ private function registerAbilities(): void { 'github_repo' => array( 'type' => array( 'string', 'null' ) ), 'remote' => array( 'type' => 'string' ), 'branch' => array( 'type' => 'string' ), + 'force_with_lease' => array( 'type' => 'boolean' ), + 'expected_sha' => array( 'type' => array( 'string', 'null' ) ), 'url' => array( 'type' => array( 'string', 'null' ) ), 'html_url' => array( 'type' => array( 'string', 'null' ) ), 'next_required_tool' => array( 'type' => array( 'string', 'null' ) ), @@ -944,6 +954,175 @@ private function registerAbilities(): void { ) ); + wp_register_ability( + 'datamachine/workspace-git-rebase', + array( + 'label' => 'Workspace Git Rebase', + 'description' => 'Fetch and rebase a workspace handle, returning structured conflict information without auto-resolving conflicts.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Workspace handle: `` or `@`.', + ), + 'onto' => array( + 'type' => 'string', + 'description' => 'Base ref to rebase onto. Defaults to origin/HEAD or origin/.', + ), + 'interactive' => array( + 'type' => 'boolean', + 'description' => 'Reserved; interactive rebases are not supported.', + ), + 'strategy_option' => array( + 'type' => 'string', + 'description' => 'Optional git strategy option such as theirs or ours.', + ), + 'continue' => array( + 'type' => 'boolean', + 'description' => 'Continue an in-progress rebase after conflicts were resolved and staged.', + ), + 'allow_primary_mutation' => array( + 'type' => 'boolean', + 'description' => 'Permit mutation on a primary checkout. Default false.', + ), + ), + 'required' => array( 'name' ), + ), + 'output_schema' => self::gitRebaseOutputSchema(), + 'execute_callback' => array( self::class, 'gitRebase' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-git-reset', + array( + 'label' => 'Workspace Git Reset', + 'description' => 'Run git reset --soft/--mixed/--hard for a workspace handle. Hard reset requires allow_destructive=true.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Workspace handle: `` or `@`.', + ), + 'mode' => array( + 'type' => 'string', + 'enum' => array( 'soft', 'mixed', 'hard' ), + 'description' => 'Reset mode. Default mixed.', + ), + 'target' => array( + 'type' => 'string', + 'description' => 'Target ref or commit. Defaults to origin/HEAD or origin/.', + ), + 'allow_destructive' => array( + 'type' => 'boolean', + 'description' => 'Required for hard reset.', + ), + 'allow_primary_mutation' => array( + 'type' => 'boolean', + 'description' => 'Permit mutation on a primary checkout. Default false.', + ), + ), + 'required' => array( 'name' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'mode' => array( 'type' => 'string' ), + 'previous_head' => array( 'type' => 'string' ), + 'new_head' => array( 'type' => 'string' ), + 'paths_affected' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => array( self::class, 'gitReset' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-pr-status', + array( + 'label' => 'Workspace Pull Request Status', + 'description' => 'Resolve a workspace pull request and return mergeability/freshness state from GitHub.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Workspace handle.', + ), + 'pr' => array( + 'type' => array( 'string', 'integer' ), + 'description' => 'PR number or URL. Defaults to the current branch PR.', + ), + 'branch' => array( + 'type' => 'string', + 'description' => 'Branch to resolve when pr is omitted.', + ), + ), + 'required' => array( 'name' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( 'success' => array( 'type' => 'boolean' ) ), + ), + 'execute_callback' => array( self::class, 'prStatus' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + + wp_register_ability( + 'datamachine/workspace-pr-rebase', + array( + 'label' => 'Workspace Pull Request Rebase', + 'description' => 'Bring a workspace pull request branch up to date with its base branch, optionally dropping configured conflict paths, squashing, and force-with-lease pushing.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'description' => 'Workspace handle.', + ), + 'pr' => array( + 'type' => array( 'string', 'integer' ), + 'description' => 'PR number or URL. Defaults to the current branch PR.', + ), + 'squash' => array( + 'type' => 'boolean', + 'description' => 'Squash rebased commits into one PR-title commit before pushing.', + ), + 'drop_paths' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => 'Glob patterns to resolve by taking the base version during rebase conflicts.', + ), + 'allow_primary_mutation' => array( + 'type' => 'boolean', + 'description' => 'Permit mutation on a primary checkout. Default false.', + ), + ), + 'required' => array( 'name' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( 'success' => array( 'type' => 'boolean' ) ), + ), + 'execute_callback' => array( self::class, 'prRebase' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + // ----------------------------------------------------------------- // Worktree abilities (mutating, CLI-only by default). // ----------------------------------------------------------------- @@ -1164,11 +1343,11 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'include_cleanup' => array( + 'include_cleanup' => array( 'type' => 'boolean', 'description' => 'Include a local-only worktree cleanup dry-run summary. Default true.', ), - 'include_sizes' => array( + 'include_sizes' => array( 'type' => 'boolean', 'description' => 'Include best-effort top-level workspace size data. Default true.', ), @@ -1176,11 +1355,11 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Include full per-worktree git status. Default false for huge-workspace safety.', ), - 'refresh_inventory' => array( + 'refresh_inventory' => array( 'type' => 'boolean', 'description' => 'Refresh the DB-backed worktree inventory before reporting freshness. Default false.', ), - 'size_limit' => array( + 'size_limit' => array( 'type' => 'integer', 'description' => 'Maximum top-level workspace entries to size. Default 1000.', ), @@ -1255,7 +1434,7 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Forward force=true to cleanup tasks that support it.', ), - 'dry_run' => array( + 'dry_run' => array( 'type' => 'boolean', 'description' => 'Rejected for background cleanup scheduling; use review abilities for dry-runs.', ), @@ -1297,11 +1476,11 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'repo' => array( + 'repo' => array( 'type' => 'string', 'description' => 'Optional repo name to limit the list.', ), - 'state' => array( + 'state' => array( 'type' => 'string', 'description' => 'Optional lifecycle state filter.', ), @@ -1309,7 +1488,7 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Run `git status --porcelain` per worktree to populate the dirty count. Default false (cheap listing). Expensive on large workspaces.', ), - 'include_disk' => array( + 'include_disk' => array( 'type' => 'boolean', 'description' => 'Run size and artifact `du` probes per worktree. Default false (cheap listing). Expensive on large workspaces.', ), @@ -1354,9 +1533,9 @@ private function registerAbilities(): void { ), 'heartbeat_age_seconds' => array( 'type' => array( 'integer', 'null' ) ), 'owner' => array( - 'type' => 'object', + 'type' => 'object', 'description' => 'Owner snapshot recorded at worktree creation. Unknown-safe defaults: site, agent, user default to the literal string "unknown".', - 'properties' => array( + 'properties' => array( 'site' => array( 'type' => 'string' ), 'site_url' => array( 'type' => array( 'string', 'null' ) ), 'agent' => array( 'type' => 'string' ), @@ -1364,9 +1543,9 @@ private function registerAbilities(): void { ), ), 'session' => array( - 'type' => 'object', + 'type' => 'object', 'description' => 'Captured session identifiers (kimaki/opencode). Fields default to null when the corresponding env was not present at worktree creation.', - 'properties' => array( + 'properties' => array( 'primary_id' => array( 'type' => array( 'string', 'null' ) ), 'kimaki_session_id' => array( 'type' => array( 'string', 'null' ) ), 'kimaki_thread_id' => array( 'type' => array( 'string', 'null' ) ), @@ -1376,7 +1555,7 @@ private function registerAbilities(): void { ), ), 'task' => array( - 'type' => array( 'object', 'null' ), + 'type' => array( 'object', 'null' ), 'description' => 'Optional task/issue reference recorded at creation, when supplied via input or DATAMACHINE_TASK_URL/DATAMACHINE_TASK_REF env.', ), 'last_touched_at' => array( 'type' => array( 'string', 'null' ) ), @@ -1393,10 +1572,10 @@ private function registerAbilities(): void { ), ), ), - 'duplicates' => array( - 'type' => 'array', + 'duplicates' => array( + 'type' => 'array', 'description' => 'Groups of worktrees sharing a task_url, task_ref, pr_url, or pr_repo#pr_number. Reported only — never used to drive deletions.', - 'items' => array( + 'items' => array( 'type' => 'object', 'properties' => array( 'kind' => array( 'type' => 'string' ), @@ -1489,27 +1668,27 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'dry_run' => array( + 'dry_run' => array( 'type' => 'boolean', 'description' => 'If true, return the plan without removing anything.', ), - 'force' => array( + 'force' => array( 'type' => 'boolean', 'description' => 'If true, ignore dirty working-tree safety check.', ), - 'skip_github' => array( + 'skip_github' => array( 'type' => 'boolean', 'description' => 'If true, rely solely on the local upstream-gone signal and skip GitHub API lookup.', ), - 'inventory_only' => array( + 'inventory_only' => array( 'type' => 'boolean', 'description' => 'If true, build a dry-run review from cheap top-level inventory and explicit lifecycle cleanup signals only. Avoids full git worktree/status scans and GitHub lookups.', ), - 'apply_plan' => array( + 'apply_plan' => array( 'type' => 'object', 'description' => 'Decoded cleanup dry-run report to apply after revalidating every candidate.', ), - 'older_than' => array( + 'older_than' => array( 'type' => 'string', 'description' => 'Optional candidate age filter such as 7d, 24h, 30m, or 60s. Uses lifecycle created_at metadata only.', ), @@ -1561,7 +1740,7 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'dry_run' => array( + 'dry_run' => array( 'type' => 'boolean', 'description' => 'If true, return a review plan without writing metadata.', ), @@ -1618,11 +1797,11 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'limit' => array( + 'limit' => array( 'type' => 'integer', 'description' => 'Maximum active_no_signal rows to inspect in this page. Defaults to 25.', ), - 'offset' => array( + 'offset' => array( 'type' => 'integer', 'description' => 'Pagination offset into the active_no_signal inventory ordering.', ), @@ -1635,11 +1814,11 @@ private function registerAbilities(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), + 'success' => array( 'type' => 'boolean' ), 'review_only' => array( 'type' => 'boolean' ), - 'rows' => array( 'type' => 'array' ), - 'summary' => array( 'type' => 'object' ), - 'pagination' => array( 'type' => 'object' ), + 'rows' => array( 'type' => 'array' ), + 'summary' => array( 'type' => 'object' ), + 'pagination' => array( 'type' => 'object' ), ), ), 'execute_callback' => array( self::class, 'worktreeActiveNoSignalReport' ), @@ -1737,27 +1916,27 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'dry_run' => array( + 'dry_run' => array( 'type' => 'boolean', 'description' => 'If true, return the artifact cleanup plan without deleting anything.', ), - 'force' => array( + 'force' => array( 'type' => 'boolean', 'description' => 'If true, allow artifact cleanup in dirty or unpushed worktrees. Active plugin/theme symlink targets remain protected.', ), - 'apply_plan' => array( + 'apply_plan' => array( 'type' => 'object', 'description' => 'Decoded artifact cleanup dry-run report to apply after revalidating every worktree and artifact path.', ), - 'limit' => array( + 'limit' => array( 'type' => 'integer', 'description' => 'Maximum worktrees to scan in a dry-run page. Defaults to ' . Workspace::ARTIFACT_CLEANUP_DEFAULT_LIMIT . '. Use 0 to disable the cap (still bounded by exhaustive=false unless you also pass exhaustive=true).', ), - 'offset' => array( + 'offset' => array( 'type' => 'integer', 'description' => 'Pagination offset (0-indexed) into the inventory ordering. Combine with the previous response\'s pagination.next_offset to walk huge workspaces in pages.', ), - 'exhaustive' => array( + 'exhaustive' => array( 'type' => 'boolean', 'description' => 'If true, scan every worktree (no limit) AND run per-worktree git status / unpushed-commit safety probes. Slow on huge workspaces; use for one-shot full audits.', ), @@ -1837,27 +2016,27 @@ private function registerAbilities(): void { 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'dry_run' => array( + 'dry_run' => array( 'type' => 'boolean', 'description' => 'Preview the bounded batch without removing anything.', ), - 'limit' => array( + 'limit' => array( 'type' => 'integer', 'description' => 'Maximum candidates to attempt this call (default 25, hard ceiling 200).', ), - 'older_than' => array( + 'older_than' => array( 'type' => 'string', 'description' => 'Restrict candidates to lifecycle created_at older than the duration (e.g. 7d, 24h).', ), - 'sort' => array( + 'sort' => array( 'type' => 'string', 'description' => 'Candidate ordering before bounding: size or age (default age).', ), - 'force' => array( + 'force' => array( 'type' => 'boolean', 'description' => 'Allow apply on dirty worktrees. Unpushed-commit gate is never overridden.', ), - 'via_jobs' => array( + 'via_jobs' => array( 'type' => 'boolean', 'description' => 'Schedule each candidate as a single-row worktree_cleanup_chunk job for resumable async apply.', ), @@ -1865,7 +2044,7 @@ private function registerAbilities(): void { 'type' => 'boolean', 'description' => 'Also include repaired metadata rows. Requires explicit opt-in and still runs fresh safety probes before removal.', ), - 'source' => array( + 'source' => array( 'type' => 'string', 'description' => 'Caller source marker recorded in evidence.', ), @@ -1874,19 +2053,19 @@ private function registerAbilities(): void { 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'mode' => array( 'type' => 'string' ), - 'dry_run' => array( 'type' => 'boolean' ), - 'destructive' => array( 'type' => 'boolean' ), - 'job_backed' => array( 'type' => 'boolean' ), - 'workspace_path' => array( 'type' => 'string' ), - 'generated_at' => array( 'type' => 'string' ), - 'candidates' => array( 'type' => 'array' ), - 'removed' => array( 'type' => 'array' ), - 'skipped' => array( 'type' => 'array' ), - 'summary' => array( 'type' => 'object' ), - 'continuation' => array( 'type' => 'object' ), - 'evidence' => array( 'type' => 'object' ), + 'success' => array( 'type' => 'boolean' ), + 'mode' => array( 'type' => 'string' ), + 'dry_run' => array( 'type' => 'boolean' ), + 'destructive' => array( 'type' => 'boolean' ), + 'job_backed' => array( 'type' => 'boolean' ), + 'workspace_path' => array( 'type' => 'string' ), + 'generated_at' => array( 'type' => 'string' ), + 'candidates' => array( 'type' => 'array' ), + 'removed' => array( 'type' => 'array' ), + 'skipped' => array( 'type' => 'array' ), + 'summary' => array( 'type' => 'object' ), + 'continuation' => array( 'type' => 'object' ), + 'evidence' => array( 'type' => 'object' ), ), ), 'execute_callback' => array( self::class, 'worktreeBoundedCleanupEligibleApply' ), @@ -2267,6 +2446,34 @@ public static function applyPatch( array $input ): array|\WP_Error { ); } + /** @return array */ + private static function gitRebaseOutputSchema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'state' => array( + 'type' => 'string', + 'enum' => array( 'clean', 'conflicting' ), + ), + 'conflicts' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'path' => array( 'type' => 'string' ), + 'conflict_markers' => array( 'type' => 'integer' ), + 'has_conflict_markers' => array( 'type' => 'boolean' ), + ), + ), + ), + 'applied' => array( 'type' => 'integer' ), + 'pending' => array( 'type' => 'integer' ), + 'head_sha' => array( 'type' => 'string' ), + ), + ); + } + /** * Get git status details for a workspace repository. * @@ -2381,6 +2588,79 @@ public static function gitPush( array $input ): array|\WP_Error { $input['name'] ?? '', $input['remote'] ?? 'origin', $input['branch'] ?? null, + ! empty( $input['allow_primary_mutation'] ), + ! empty( $input['force_with_lease'] ), + $input['expected_sha'] ?? null + ); + } + + /** + * Rebase a workspace repository. + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function gitRebase( array $input ): array|\WP_Error { + $workspace = new Workspace(); + return $workspace->git_rebase( + $input['name'] ?? '', + $input['onto'] ?? null, + $input['strategy_option'] ?? null, + ! empty( $input['continue'] ), + ! empty( $input['allow_primary_mutation'] ) + ); + } + + /** + * Reset a workspace repository. + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function gitReset( array $input ): array|\WP_Error { + $workspace = new Workspace(); + return $workspace->git_reset( + $input['name'] ?? '', + $input['mode'] ?? 'mixed', + $input['target'] ?? null, + ! empty( $input['allow_destructive'] ), + ! empty( $input['allow_primary_mutation'] ) + ); + } + + /** + * Return pull request freshness state. + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function prStatus( array $input ): array|\WP_Error { + $workspace = new Workspace(); + return $workspace->pr_status( + $input['name'] ?? '', + $input['pr'] ?? null, + $input['branch'] ?? null + ); + } + + /** + * Bring a pull request branch up to date. + * + * @param array $input Input parameters. + * @return array|\WP_Error + */ + public static function prRebase( array $input ): array|\WP_Error { + $workspace = new Workspace(); + $drop_paths = $input['drop_paths'] ?? array(); + if ( ! is_array( $drop_paths ) ) { + $drop_paths = array(); + } + + return $workspace->pr_rebase( + $input['name'] ?? '', + $input['pr'] ?? null, + ! empty( $input['squash'] ), + $drop_paths, ! empty( $input['allow_primary_mutation'] ) ); } @@ -2555,11 +2835,11 @@ public static function workspaceCleanupRun( array $input ): array|\WP_Error { 'retention' => array( 'task_type' => 'workspace_retention_cleanup', 'params' => array( - 'dry_run' => false, - 'artifact_cleanup' => true, - 'worktree_cleanup' => true, - 'skip_github' => true, - 'worktree_older_than' => '14d', + 'dry_run' => false, + 'artifact_cleanup' => true, + 'worktree_cleanup' => true, + 'skip_github' => true, + 'worktree_older_than' => '14d', ), ), 'emergency' => array( @@ -2578,8 +2858,8 @@ public static function workspaceCleanupRun( array $input ): array|\WP_Error { return new \WP_Error( 'task_scheduler_unavailable', 'Data Machine TaskScheduler is unavailable.', array( 'status' => 500 ) ); } - $task_type = (string) $map[ $mode ]['task_type']; - $params = (array) $map[ $mode ]['params']; + $task_type = (string) $map[ $mode ]['task_type']; + $params = (array) $map[ $mode ]['params']; $params['source'] = (string) ( $input['source'] ?? 'workspace_cleanup_ability' ); if ( isset( $input['force'] ) ) { @@ -2815,9 +3095,9 @@ public static function worktreeCleanupArtifacts( array $input ): array|\WP_Error public static function worktreeBoundedCleanupEligibleApply( array $input ): array|\WP_Error { $workspace = new Workspace(); $opts = array( - 'dry_run' => ! empty( $input['dry_run'] ), - 'force' => ! empty( $input['force'] ), - 'via_jobs' => ! empty( $input['via_jobs'] ), + 'dry_run' => ! empty( $input['dry_run'] ), + 'force' => ! empty( $input['force'] ), + 'via_jobs' => ! empty( $input['via_jobs'] ), 'include_repaired_metadata' => ! empty( $input['include_repaired_metadata'] ), ); if ( isset( $input['limit'] ) ) { @@ -2862,7 +3142,7 @@ public static function worktreeEmergencyCleanup( array $input ): array|\WP_Error * @return array|\WP_Error */ public static function workspaceCleanupPlan( array $input ): array|\WP_Error { - $opts = array( + $opts = array( 'force_artifact_cleanup' => ! empty( $input['force_artifact_cleanup'] ), 'include_resolvers' => ! empty( $input['include_resolvers'] ), 'mode' => (string) ( $input['mode'] ?? 'cleanup_plan' ), diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 2749c40..541d613 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -545,7 +545,7 @@ private function run_cleanup_review( array $assoc_args ): void { return; case 'artifacts': - $ability = wp_get_ability( 'datamachine/workspace-worktree-cleanup-artifacts' ); + $ability = wp_get_ability( 'datamachine/workspace-worktree-cleanup-artifacts' ); $artifact_input = array( 'dry_run' => true, 'force' => ! empty( $assoc_args['force'] ), @@ -1459,7 +1459,7 @@ public function patch( array $args, array $assoc_args ): void { $result = $ability->execute( array( 'repo' => $repo, - 'patch' => $patch, + 'patch' => $patch, 'allow_primary_mutation' => ! empty( $assoc_args['allow-primary-mutation'] ), ) ); @@ -1713,13 +1713,17 @@ public function git( array $args, array $assoc_args ): void { } $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', + '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', + 'rebase' => 'datamachine/workspace-git-rebase', + 'reset' => 'datamachine/workspace-git-reset', + 'pr-status' => 'datamachine/workspace-pr-status', + 'pr-rebase' => 'datamachine/workspace-pr-rebase', + 'log' => 'datamachine/workspace-git-log', + 'diff' => 'datamachine/workspace-git-diff', default => '', }; @@ -1737,7 +1741,7 @@ 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 ) ) { + if ( in_array( $operation, array( 'pull', 'add', 'commit', 'push', 'rebase', 'reset', 'pr-rebase' ), true ) ) { $input['allow_primary_mutation'] = ! empty( $assoc_args['allow-primary-mutation'] ); } @@ -1768,6 +1772,56 @@ public function git( array $args, array $assoc_args ): void { if ( ! empty( $assoc_args['branch'] ) ) { $input['branch'] = (string) $assoc_args['branch']; } + if ( ! empty( $assoc_args['force-with-lease'] ) ) { + $input['force_with_lease'] = true; + } + if ( ! empty( $assoc_args['expected-sha'] ) ) { + $input['expected_sha'] = (string) $assoc_args['expected-sha']; + } + } + + if ( 'rebase' === $operation ) { + if ( ! empty( $assoc_args['onto'] ) ) { + $input['onto'] = (string) $assoc_args['onto']; + } + if ( ! empty( $assoc_args['strategy-option'] ) ) { + $input['strategy_option'] = (string) $assoc_args['strategy-option']; + } + if ( ! empty( $assoc_args['continue'] ) ) { + $input['continue'] = true; + } + } + + if ( 'reset' === $operation ) { + if ( ! empty( $assoc_args['mode'] ) ) { + $input['mode'] = (string) $assoc_args['mode']; + } + if ( ! empty( $assoc_args['target'] ) ) { + $input['target'] = (string) $assoc_args['target']; + } + if ( ! empty( $assoc_args['allow-destructive'] ) ) { + $input['allow_destructive'] = true; + } + } + + if ( 'pr-status' === $operation || 'pr-rebase' === $operation ) { + if ( ! empty( $assoc_args['pr'] ) ) { + $input['pr'] = (string) $assoc_args['pr']; + } + } + + if ( 'pr-status' === $operation && ! empty( $assoc_args['branch'] ) ) { + $input['branch'] = (string) $assoc_args['branch']; + } + + if ( 'pr-rebase' === $operation ) { + if ( ! empty( $assoc_args['squash'] ) ) { + $input['squash'] = true; + } + $drop_paths = $this->collectRepeatableFlag( 'drop-path' ); + if ( ! empty( $drop_paths ) ) { + $input['drop_paths'] = $drop_paths; + } } if ( 'log' === $operation ) { @@ -2288,10 +2342,10 @@ public function worktree( array $args, array $assoc_args ): void { } // Cheap inventory by default — opt in to expensive probes via flags. // `--full` is a shorthand for both, `--stale` requires status to detect dirty. - $want_status = ! empty( $assoc_args['with-status'] ) + $want_status = ! empty( $assoc_args['with-status'] ) || ! empty( $assoc_args['full'] ) || ! empty( $assoc_args['stale'] ); - $want_disk = ! empty( $assoc_args['with-size'] ) + $want_disk = ! empty( $assoc_args['with-size'] ) || ! empty( $assoc_args['full'] ); $input['include_status'] = $want_status; $input['include_disk'] = $want_disk; @@ -2308,10 +2362,10 @@ public function worktree( array $args, array $assoc_args ): void { break; case 'cleanup': - $input['dry_run'] = ! empty( $assoc_args['dry-run'] ); - $input['force'] = ! empty( $assoc_args['force'] ); - $input['skip_github'] = ! empty( $assoc_args['skip-github'] ); - $input['inventory_only'] = ! empty( $assoc_args['inventory-only'] ); + $input['dry_run'] = ! empty( $assoc_args['dry-run'] ); + $input['force'] = ! empty( $assoc_args['force'] ); + $input['skip_github'] = ! empty( $assoc_args['skip-github'] ); + $input['inventory_only'] = ! empty( $assoc_args['inventory-only'] ); $input['include_repaired_metadata'] = ! empty( $assoc_args['include-repaired-metadata'] ); if ( isset( $assoc_args['limit'] ) ) { $input['limit'] = (int) $assoc_args['limit']; @@ -2342,8 +2396,8 @@ public function worktree( array $args, array $assoc_args ): void { } break; case 'reconcile-metadata': - $input['dry_run'] = ! empty( $assoc_args['dry-run'] ); - $input['apply'] = ! empty( $assoc_args['apply'] ); + $input['dry_run'] = ! empty( $assoc_args['dry-run'] ); + $input['apply'] = ! empty( $assoc_args['apply'] ); $input['via_jobs'] = ! empty( $assoc_args['via-jobs'] ); $input['source'] = self::CLEANUP_CLI_SOURCE; if ( isset( $assoc_args['limit'] ) ) { @@ -2407,11 +2461,11 @@ public function worktree( array $args, array $assoc_args ): void { break; case 'bounded-cleanup-eligible-apply': - $input['dry_run'] = ! empty( $assoc_args['dry-run'] ); - $input['force'] = ! empty( $assoc_args['force'] ); - $input['via_jobs'] = ! empty( $assoc_args['via-jobs'] ); + $input['dry_run'] = ! empty( $assoc_args['dry-run'] ); + $input['force'] = ! empty( $assoc_args['force'] ); + $input['via_jobs'] = ! empty( $assoc_args['via-jobs'] ); $input['include_repaired_metadata'] = ! empty( $assoc_args['include-repaired-metadata'] ); - $input['source'] = self::CLEANUP_CLI_SOURCE; + $input['source'] = self::CLEANUP_CLI_SOURCE; if ( isset( $assoc_args['limit'] ) ) { $input['limit'] = (int) $assoc_args['limit']; } @@ -2514,7 +2568,7 @@ function ( $wt ) { ) ); } $this->format_items( $items, $fields, $assoc_args, 'handle' ); - $duplicates = (array) ( $result['duplicates'] ?? array() ); + $duplicates = (array) ( $result['duplicates'] ?? array() ); $base_branch_worktrees = (array) ( $result['base_branch_worktrees'] ?? array() ); if ( ! empty( $duplicates ) && ! in_array( (string) ( $assoc_args['format'] ?? '' ), array( 'json', 'yaml' ), true ) ) { WP_CLI::log( sprintf( 'Duplicate task ownership groups: %d', count( $duplicates ) ) ); @@ -2663,15 +2717,15 @@ private function render_workspace_hygiene_report( array $report, array $assoc_ar return; } - $size = (array) ( $report['size'] ?? array() ); - $disk = (array) ( $report['disk'] ?? array() ); - $worktrees = (array) ( $report['worktrees'] ?? array() ); - $locks = (array) ( $report['locks'] ?? array() ); - $database_locks = (array) ( $locks['database'] ?? array() ); + $size = (array) ( $report['size'] ?? array() ); + $disk = (array) ( $report['disk'] ?? array() ); + $worktrees = (array) ( $report['worktrees'] ?? array() ); + $locks = (array) ( $report['locks'] ?? array() ); + $database_locks = (array) ( $locks['database'] ?? array() ); $filesystem_locks = (array) ( $locks['filesystem'] ?? array() ); - $inventory = (array) ( $report['inventory']['freshness'] ?? array() ); - $cleanup = (array) ( $report['cleanup'] ?? array() ); - $cleanup_summary = (array) ( $cleanup['summary'] ?? array() ); + $inventory = (array) ( $report['inventory']['freshness'] ?? array() ); + $cleanup = (array) ( $report['cleanup'] ?? array() ); + $cleanup_summary = (array) ( $cleanup['summary'] ?? array() ); WP_CLI::log( 'Workspace hygiene:' ); $this->format_items( diff --git a/inc/Tools/WorkspaceTools.php b/inc/Tools/WorkspaceTools.php index ed1fcb9..fe07544 100644 --- a/inc/Tools/WorkspaceTools.php +++ b/inc/Tools/WorkspaceTools.php @@ -55,6 +55,10 @@ public function check_configuration( $configured, $tool_id ) { 'workspace_git_add', 'workspace_git_commit', 'workspace_git_push', + 'workspace_git_rebase', + 'workspace_git_reset', + 'workspace_pr_status', + 'workspace_pr_rebase', ); if ( ! in_array( $tool_id, $workspace_tools, true ) ) { @@ -90,6 +94,10 @@ public function __construct() { $this->registerTool( 'workspace_git_add', array( $this, 'getGitAddDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine/workspace-git-add' ) ); $this->registerTool( 'workspace_git_commit', array( $this, 'getGitCommitDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine/workspace-git-commit' ) ); $this->registerTool( 'workspace_git_push', array( $this, 'getGitPushDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine/workspace-git-push' ) ); + $this->registerTool( 'workspace_git_rebase', array( $this, 'getGitRebaseDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine/workspace-git-rebase' ) ); + $this->registerTool( 'workspace_git_reset', array( $this, 'getGitResetDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine/workspace-git-reset' ) ); + $this->registerTool( 'workspace_pr_status', array( $this, 'getPrStatusDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine/workspace-pr-status' ) ); + $this->registerTool( 'workspace_pr_rebase', array( $this, 'getPrRebaseDefinition' ), $policy_contexts, $policy_meta + array( 'ability' => 'datamachine/workspace-pr-rebase' ) ); } /** @@ -500,18 +508,84 @@ public function handleGitCommit( array $parameters ): array { /** @param array $parameters Tool parameters. @return array */ public function handleGitPush( array $parameters ): array { $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); - foreach ( array( 'remote', 'branch' ) as $key ) { + foreach ( array( 'remote', 'branch', 'expected_sha' ) as $key ) { if ( isset( $parameters[ $key ] ) ) { $input[ $key ] = $parameters[ $key ]; } } - if ( array_key_exists( 'allow_primary_mutation', $parameters ) ) { - $input['allow_primary_mutation'] = (bool) $parameters['allow_primary_mutation']; + foreach ( array( 'allow_primary_mutation', 'force_with_lease' ) as $key ) { + if ( array_key_exists( $key, $parameters ) ) { + $input[ $key ] = (bool) $parameters[ $key ]; + } } return $this->executeAbility( 'datamachine/workspace-git-push', 'workspace_git_push', $input, array( 'name' ) ); } + /** @param array $parameters Tool parameters. @return array */ + public function handleGitRebase( array $parameters ): array { + $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); + foreach ( array( 'onto', 'strategy_option' ) as $key ) { + if ( isset( $parameters[ $key ] ) ) { + $input[ $key ] = $parameters[ $key ]; + } + } + foreach ( array( 'continue', 'allow_primary_mutation' ) as $key ) { + if ( array_key_exists( $key, $parameters ) ) { + $input[ $key ] = (bool) $parameters[ $key ]; + } + } + + return $this->executeAbility( 'datamachine/workspace-git-rebase', 'workspace_git_rebase', $input, array( 'name' ) ); + } + + /** @param array $parameters Tool parameters. @return array */ + public function handleGitReset( array $parameters ): array { + $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); + foreach ( array( 'mode', 'target' ) as $key ) { + if ( isset( $parameters[ $key ] ) ) { + $input[ $key ] = $parameters[ $key ]; + } + } + foreach ( array( 'allow_destructive', 'allow_primary_mutation' ) as $key ) { + if ( array_key_exists( $key, $parameters ) ) { + $input[ $key ] = (bool) $parameters[ $key ]; + } + } + + return $this->executeAbility( 'datamachine/workspace-git-reset', 'workspace_git_reset', $input, array( 'name' ) ); + } + + /** @param array $parameters Tool parameters. @return array */ + public function handlePrStatus( array $parameters ): array { + $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); + foreach ( array( 'pr', 'branch' ) as $key ) { + if ( isset( $parameters[ $key ] ) ) { + $input[ $key ] = $parameters[ $key ]; + } + } + + return $this->executeAbility( 'datamachine/workspace-pr-status', 'workspace_pr_status', $input, array( 'name' ) ); + } + + /** @param array $parameters Tool parameters. @return array */ + public function handlePrRebase( array $parameters ): array { + $input = array( 'name' => $parameters['name'] ?? $parameters['repo'] ?? '' ); + if ( isset( $parameters['pr'] ) ) { + $input['pr'] = $parameters['pr']; + } + if ( isset( $parameters['drop_paths'] ) && is_array( $parameters['drop_paths'] ) ) { + $input['drop_paths'] = $parameters['drop_paths']; + } + foreach ( array( 'squash', 'allow_primary_mutation' ) as $key ) { + if ( array_key_exists( $key, $parameters ) ) { + $input[ $key ] = (bool) $parameters[ $key ]; + } + } + + return $this->executeAbility( 'datamachine/workspace-pr-rebase', 'workspace_pr_rebase', $input, array( 'name' ) ); + } + /** @param array $input Ability input. @return array */ private function executeAbility( string $ability_name, string $tool_name, array $input, array $handle_keys = array() ): array { $ability = wp_get_ability( $ability_name ); @@ -1010,6 +1084,46 @@ public function getGitPushDefinition(): array { 'remote' => array( 'type' => 'string', 'description' => 'Remote name. Default origin.' ), 'branch' => array( 'type' => 'string', 'description' => 'Branch override.' ), 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), + 'force_with_lease' => array( 'type' => 'boolean', 'description' => 'Use --force-with-lease. Refuses protected base/fixed branches.' ), + 'expected_sha' => array( 'type' => 'string', 'description' => 'Optional expected remote branch SHA.' ), + ), array( 'name' ), array( 'completion_signal' => 'progress' ) ); + } + + /** @return array */ + public function getGitRebaseDefinition(): array { + return $this->simpleGitDefinition( 'handleGitRebase', 'Fetch and rebase a workspace handle, returning structured conflicts without auto-resolving.', array( + 'onto' => array( 'type' => 'string', 'description' => 'Base ref to rebase onto.' ), + 'strategy_option' => array( 'type' => 'string', 'description' => 'Optional strategy option, such as theirs or ours.' ), + 'continue' => array( 'type' => 'boolean', 'description' => 'Continue an in-progress rebase.' ), + 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), + ), array( 'name' ), array( 'completion_signal' => 'progress' ) ); + } + + /** @return array */ + public function getGitResetDefinition(): array { + return $this->simpleGitDefinition( 'handleGitReset', 'Reset a workspace handle. Hard reset requires allow_destructive=true.', array( + 'mode' => array( 'type' => 'string', 'enum' => array( 'soft', 'mixed', 'hard' ), 'description' => 'Reset mode.' ), + 'target' => array( 'type' => 'string', 'description' => 'Target ref or commit.' ), + 'allow_destructive' => array( 'type' => 'boolean', 'description' => 'Required for hard reset.' ), + 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), + ), array( 'name' ), array( 'completion_signal' => 'progress' ) ); + } + + /** @return array */ + public function getPrStatusDefinition(): array { + return $this->simpleGitDefinition( 'handlePrStatus', 'Return GitHub pull request mergeability/freshness state for a workspace handle.', array( + 'pr' => array( 'type' => array( 'string', 'integer' ), 'description' => 'PR number or URL.' ), + 'branch' => array( 'type' => 'string', 'description' => 'Branch to resolve when PR is omitted.' ), + ), array( 'name' ), array( 'duplicate_policy' => 'repeatable' ) ); + } + + /** @return array */ + public function getPrRebaseDefinition(): array { + return $this->simpleGitDefinition( 'handlePrRebase', 'Bring a pull request branch up to date, optionally dropping path conflicts, squashing, and force-with-lease pushing.', array( + 'pr' => array( 'type' => array( 'string', 'integer' ), 'description' => 'PR number or URL.' ), + 'squash' => array( 'type' => 'boolean', 'description' => 'Squash rebased commits into one PR-title commit.' ), + 'drop_paths' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'description' => 'Conflict path globs to resolve by taking the base version.' ), + 'allow_primary_mutation' => array( 'type' => 'boolean', 'description' => 'Permit mutation on a primary checkout. Default false.' ), ), array( 'name' ), array( 'completion_signal' => 'progress' ) ); } diff --git a/inc/Workspace/WorkspaceGitOperations.php b/inc/Workspace/WorkspaceGitOperations.php index b448a22..7c1eace 100644 --- a/inc/Workspace/WorkspaceGitOperations.php +++ b/inc/Workspace/WorkspaceGitOperations.php @@ -435,7 +435,7 @@ public function git_commit( string $handle, string $message, bool $allow_primary * @param bool $allow_primary_mutation Whether the primary may be pushed. * @return array */ - public function git_push( string $handle, string $remote = 'origin', ?string $branch = null, bool $allow_primary_mutation = false ): array|\WP_Error { + public function git_push( string $handle, string $remote = 'origin', ?string $branch = null, bool $allow_primary_mutation = false, bool $force_with_lease = false, ?string $expected_sha = null ): array|\WP_Error { $parsed = $this->parse_handle( $handle ); $repo_name = $parsed['repo']; $repo_path = $this->resolve_repo_path( $handle ); @@ -460,7 +460,18 @@ public function git_push( string $handle, string $remote = 'origin', ?string $br $current_branch = trim( (string) $current_branch_result['output'] ); $target_branch = $branch ? trim( $branch ) : $current_branch; - $publish_files = $this->get_workspace_policy_publish_files( $repo_path, $current_branch ); + if ( '' === $target_branch || 'HEAD' === $target_branch ) { + return new \WP_Error( 'invalid_branch', 'Cannot push from a detached HEAD without an explicit branch.', array( 'status' => 400 ) ); + } + + if ( $force_with_lease ) { + $force_guard = $this->ensure_force_push_branch_allowed( $repo_name, $target_branch ); + if ( is_wp_error( $force_guard ) ) { + return $force_guard; + } + } + + $publish_files = $this->get_workspace_policy_publish_files( $repo_path, $current_branch ); if ( is_wp_error( $publish_files ) ) { return $publish_files; } @@ -478,8 +489,18 @@ public function git_push( string $handle, string $remote = 'origin', ?string $br } } - $cmd = sprintf( 'push %s %s', escapeshellarg( $remote ), escapeshellarg( $target_branch ) ); - $result = $this->run_git( $repo_path, $cmd ); + $lease_flag = ''; + if ( $force_with_lease ) { + $expected_sha = $expected_sha ? trim( $expected_sha ) : $this->git_remote_branch_sha( $repo_path, $remote, $target_branch ); + $lease_ref = 'refs/heads/' . $target_branch; + $lease_flag = '' !== (string) $expected_sha + ? sprintf( ' --force-with-lease=%s:%s', escapeshellarg( $lease_ref ), escapeshellarg( (string) $expected_sha ) ) + : sprintf( ' --force-with-lease=%s', escapeshellarg( $lease_ref ) ); + } + + $refspec = $force_with_lease ? 'HEAD:refs/heads/' . $target_branch : $target_branch; + $cmd = sprintf( 'push%s %s %s', $lease_flag, escapeshellarg( $remote ), escapeshellarg( $refspec ) ); + $result = $this->run_git( $repo_path, $cmd ); if ( is_wp_error( $result ) ) { return $result; @@ -504,6 +525,8 @@ public function git_push( string $handle, string $remote = 'origin', ?string $br 'github_repo' => $github_repo, 'remote' => $remote, 'branch' => $target_branch, + 'force_with_lease' => $force_with_lease, + 'expected_sha' => $expected_sha, 'url' => $branch_url, 'html_url' => $branch_url, 'next_required_tool' => null !== $github_repo ? 'create_github_pull_request' : null, @@ -521,6 +544,312 @@ public function git_push( string $handle, string $remote = 'origin', ?string $br return $response; } + /** + * Rebase a workspace checkout and report conflicts without resolving them. + * + * @param string $handle Workspace handle. + * @param string|null $onto Base ref to rebase onto. + * @param string|null $strategy_option Optional git rebase strategy option. + * @param bool $continue_rebase Continue an in-progress rebase. + * @param bool $allow_primary_mutation Whether the primary checkout may be mutated. + * @return array|\WP_Error + */ + public function git_rebase( string $handle, ?string $onto = null, ?string $strategy_option = null, bool $continue_rebase = false, 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; + } + + $policy_check = $this->ensure_git_mutation_allowed( $repo_name ); + if ( is_wp_error( $policy_check ) ) { + return $policy_check; + } + + $primary_check = $this->ensure_primary_mutation_allowed( $parsed, $allow_primary_mutation ); + if ( is_wp_error( $primary_check ) ) { + return $primary_check; + } + + if ( $continue_rebase ) { + $result = $this->run_git( $repo_path, '-c core.editor=true rebase --continue' ); + if ( is_wp_error( $result ) ) { + $conflicts = $this->git_conflicts( $repo_path ); + if ( ! empty( $conflicts ) ) { + return $this->git_rebase_result( $parsed, $repo_path, 'conflicting', $conflicts ); + } + $output = (string) ( $result->get_error_data()['output'] ?? $result->get_error_message() ); + if ( str_contains( $output, 'No changes' ) || str_contains( $output, 'patch contents already upstream' ) ) { + $skip = $this->run_git( $repo_path, 'rebase --skip' ); + if ( is_wp_error( $skip ) ) { + return $skip; + } + + return $this->git_rebase_result( $parsed, $repo_path, 'clean', array(), $onto ); + } + return $result; + } + + return $this->git_rebase_result( $parsed, $repo_path, 'clean', array(), $onto ); + } + + $onto = $onto && '' !== trim( $onto ) ? trim( $onto ) : $this->default_rebase_onto( $repo_path ); + $fetch = $this->run_git( $repo_path, 'fetch origin' ); + if ( is_wp_error( $fetch ) ) { + return $fetch; + } + + $args = array( 'rebase' ); + if ( null !== $strategy_option && '' !== trim( $strategy_option ) ) { + $args[] = '-X'; + $args[] = escapeshellarg( trim( $strategy_option ) ); + } + $args[] = escapeshellarg( $onto ); + + $result = $this->run_git( $repo_path, implode( ' ', $args ) ); + if ( is_wp_error( $result ) ) { + $conflicts = $this->git_conflicts( $repo_path ); + if ( ! empty( $conflicts ) ) { + return $this->git_rebase_result( $parsed, $repo_path, 'conflicting', $conflicts, $onto ); + } + return $result; + } + + return $this->git_rebase_result( $parsed, $repo_path, 'clean', array(), $onto ); + } + + /** + * Reset a workspace checkout. + * + * @return array|\WP_Error + */ + public function git_reset( string $handle, string $mode = 'mixed', ?string $target = null, bool $allow_destructive = false, 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; + } + + $policy_check = $this->ensure_git_mutation_allowed( $repo_name ); + if ( is_wp_error( $policy_check ) ) { + return $policy_check; + } + + $primary_check = $this->ensure_primary_mutation_allowed( $parsed, $allow_primary_mutation ); + if ( is_wp_error( $primary_check ) ) { + return $primary_check; + } + + $mode = trim( $mode ); + if ( ! in_array( $mode, array( 'soft', 'mixed', 'hard' ), true ) ) { + return new \WP_Error( 'invalid_reset_mode', 'Reset mode must be one of: soft, mixed, hard.', array( 'status' => 400 ) ); + } + + if ( 'hard' === $mode && ! $allow_destructive ) { + return new \WP_Error( 'destructive_reset_blocked', 'Hard reset requires allow_destructive=true.', array( 'status' => 403 ) ); + } + + $target = $target && '' !== trim( $target ) ? trim( $target ) : $this->default_rebase_onto( $repo_path ); + $before = $this->run_git( $repo_path, 'rev-parse HEAD' ); + if ( is_wp_error( $before ) ) { + return $before; + } + $affected = $this->run_git( $repo_path, 'diff --name-only ' . escapeshellarg( trim( (string) $before['output'] ) ) . ' ' . escapeshellarg( $target ) ); + + $result = $this->run_git( $repo_path, sprintf( 'reset --%s %s', $mode, escapeshellarg( $target ) ) ); + if ( is_wp_error( $result ) ) { + return $result; + } + + $after = $this->run_git( $repo_path, 'rev-parse HEAD' ); + if ( is_wp_error( $after ) ) { + return $after; + } + + $paths = is_wp_error( $affected ) ? array() : array_filter( array_map( 'trim', explode( "\n", (string) ( $affected['output'] ?? '' ) ) ) ); + return array( + 'success' => true, + 'name' => $parsed['dir_name'], + 'repo' => $repo_name, + 'mode' => $mode, + 'previous_head' => trim( (string) $before['output'] ), + 'new_head' => trim( (string) $after['output'] ), + 'paths_affected' => count( $paths ), + ); + } + + /** + * Return PR freshness details from GitHub. + * + * @return array|\WP_Error + */ + public function pr_status( string $handle, string|int|null $pr = null, ?string $branch = null ): array|\WP_Error { + $repo_path = $this->resolve_repo_path( $handle ); + if ( is_wp_error( $repo_path ) ) { + return $repo_path; + } + + $resolved = $this->resolve_pull_request( $repo_path, $pr, $branch ); + if ( is_wp_error( $resolved ) ) { + return $resolved; + } + + $base = (array) ( $resolved['base'] ?? array() ); + $head = (array) ( $resolved['head'] ?? array() ); + + return array( + 'success' => true, + 'pr' => (int) ( $resolved['number'] ?? 0 ), + 'pr_url' => (string) ( $resolved['html_url'] ?? '' ), + 'title' => (string) ( $resolved['title'] ?? '' ), + 'base_branch' => (string) ( $base['ref'] ?? '' ), + 'head_branch' => (string) ( $head['ref'] ?? '' ), + 'mergeable' => $resolved['mergeable'] ?? null, + 'merge_state' => $resolved['mergeable_state'] ?? null, + 'behind' => 'behind' === ( $resolved['mergeable_state'] ?? '' ), + 'ahead' => 'behind' !== ( $resolved['mergeable_state'] ?? '' ), + 'conflicting' => false === ( $resolved['mergeable'] ?? null ) || 'dirty' === ( $resolved['mergeable_state'] ?? '' ), + 'head_sha' => (string) ( $head['sha'] ?? '' ), + 'base_sha' => (string) ( $base['sha'] ?? '' ), + ); + } + + /** + * Bring a pull request branch up to date with its base branch. + * + * @return array|\WP_Error + */ + public function pr_rebase( string $handle, string|int|null $pr = null, bool $squash = false, array $drop_paths = array(), 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; + } + + $policy_check = $this->ensure_git_mutation_allowed( $repo_name, true ); + if ( is_wp_error( $policy_check ) ) { + return $policy_check; + } + + $primary_check = $this->ensure_primary_mutation_allowed( $parsed, $allow_primary_mutation ); + if ( is_wp_error( $primary_check ) ) { + return $primary_check; + } + + $resolved = $this->resolve_pull_request( $repo_path, $pr, null ); + if ( is_wp_error( $resolved ) ) { + return $resolved; + } + + $base_branch = (string) ( $resolved['base']['ref'] ?? '' ); + if ( '' === $base_branch ) { + return new \WP_Error( 'missing_pr_base', 'Pull request base branch could not be resolved.', array( 'status' => 400 ) ); + } + + $fetch = $this->run_git( $repo_path, 'fetch origin ' . escapeshellarg( $base_branch ) ); + if ( is_wp_error( $fetch ) ) { + return $fetch; + } + + $onto = 'origin/' . $base_branch; + $rebase = $this->git_rebase( $handle, $onto, null, false, $allow_primary_mutation ); + if ( is_wp_error( $rebase ) ) { + return $rebase; + } + + $dropped = array(); + if ( 'conflicting' === ( $rebase['state'] ?? '' ) && ! empty( $drop_paths ) ) { + foreach ( $rebase['conflicts'] as $conflict ) { + $path = (string) ( $conflict['path'] ?? '' ); + if ( '' === $path || ! $this->path_matches_any_glob( $path, $drop_paths ) ) { + continue; + } + $checkout = $this->run_git( $repo_path, 'checkout --ours -- ' . escapeshellarg( $path ) ); + if ( is_wp_error( $checkout ) ) { + return $checkout; + } + $add = $this->run_git( $repo_path, 'add -- ' . escapeshellarg( $path ) ); + if ( is_wp_error( $add ) ) { + return $add; + } + $dropped[] = $path; + } + + $remaining = $this->git_conflicts( $repo_path ); + if ( ! empty( $remaining ) ) { + return array( + 'success' => false, + 'state' => 'conflicting', + 'dropped_paths' => count( $dropped ), + 'unresolved_conflicts' => $remaining, + 'squashed' => false, + 'pushed' => false, + 'head_sha' => $this->git_head_sha( $repo_path ), + 'pr_url' => (string) ( $resolved['html_url'] ?? '' ), + ); + } + + $continue = $this->git_rebase( $handle, $onto, null, true, $allow_primary_mutation ); + if ( is_wp_error( $continue ) ) { + return $continue; + } + if ( 'conflicting' === ( $continue['state'] ?? '' ) ) { + return array( + 'success' => false, + 'state' => 'conflicting', + 'dropped_paths' => count( $dropped ), + 'unresolved_conflicts' => $continue['conflicts'] ?? array(), + 'squashed' => false, + 'pushed' => false, + 'head_sha' => $this->git_head_sha( $repo_path ), + 'pr_url' => (string) ( $resolved['html_url'] ?? '' ), + ); + } + } + + $squashed = false; + if ( $squash ) { + $reset = $this->run_git( $repo_path, 'reset --soft ' . escapeshellarg( $onto ) ); + if ( is_wp_error( $reset ) ) { + return $reset; + } + $title = trim( (string) ( $resolved['title'] ?? '' ) ); + if ( '' === $title ) { + $title = 'Update pull request branch'; + } + $body = trim( (string) ( $resolved['body'] ?? '' ) ); + $commit_cmd = 'commit -m ' . escapeshellarg( $title ); + if ( '' !== $body ) { + $commit_cmd .= ' -m ' . escapeshellarg( $body ); + } + $commit = $this->run_git( $repo_path, $commit_cmd ); + if ( is_wp_error( $commit ) ) { + return $commit; + } + $squashed = true; + } + + $head_branch = (string) ( $resolved['head']['ref'] ?? '' ); + $push = $this->git_push( $handle, 'origin', $head_branch, $allow_primary_mutation, true ); + if ( is_wp_error( $push ) ) { + return $push; + } + + return array( + 'success' => true, + 'state' => 'clean', + 'dropped_paths' => count( $dropped ), + 'unresolved_conflicts' => array(), + 'squashed' => $squashed, + 'pushed' => true, + 'head_sha' => $this->git_head_sha( $repo_path ), + 'pr_url' => (string) ( $resolved['html_url'] ?? '' ), + ); + } + /** * Read git log entries for a workspace repository. * @@ -1091,6 +1420,198 @@ private function get_repo_fixed_branch( string $repo_name ): ?string { return '' === $branch ? null : $branch; } + /** + * Refuse force-with-lease pushes to base/fixed branches. + * + * @return true|\WP_Error + */ + private function ensure_force_push_branch_allowed( string $repo_name, string $target_branch ): true|\WP_Error { + $fixed_branch = $this->get_repo_fixed_branch( $repo_name ); + $base_branches = array_filter( array_unique( array( + $fixed_branch, + 'main', + 'master', + 'trunk', + 'develop', + 'dev', + ) ) ); + + if ( in_array( $target_branch, $base_branches, true ) ) { + return new \WP_Error( + 'force_push_base_branch_blocked', + sprintf( 'Force-with-lease push blocked for protected/base branch "%s".', $target_branch ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** @return array> */ + private function git_conflicts( string $repo_path ): array { + $result = $this->run_git( $repo_path, 'diff --name-only --diff-filter=U' ); + if ( is_wp_error( $result ) ) { + return array(); + } + + $paths = array_filter( array_map( 'trim', explode( "\n", (string) ( $result['output'] ?? '' ) ) ) ); + $conflicts = array(); + foreach ( $paths as $path ) { + $absolute = $repo_path . '/' . $path; + $content = ''; + if ( is_file( $absolute ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local workspace file read for conflict-marker counting. + $content = (string) file_get_contents( $absolute ); + } + $conflicts[] = array( + 'path' => $path, + 'conflict_markers' => substr_count( $content, '<<<<<<< ' ), + 'has_conflict_markers' => str_contains( $content, '<<<<<<< ' ), + ); + } + + return $conflicts; + } + + /** @param array $parsed @param array> $conflicts @return array */ + private function git_rebase_result( array $parsed, string $repo_path, string $state, array $conflicts, ?string $onto = null ): array { + return array( + 'success' => 'clean' === $state, + 'state' => $state, + 'name' => $parsed['dir_name'], + 'repo' => $parsed['repo'], + 'onto' => $onto, + 'conflicts' => $conflicts, + 'applied' => $this->git_rebase_count( $repo_path, 'done' ), + 'pending' => $this->git_rebase_count( $repo_path, 'git-rebase-todo' ), + 'head_sha' => $this->git_head_sha( $repo_path ), + ); + } + + private function git_rebase_count( string $repo_path, string $file ): int { + $path = $this->run_git( $repo_path, 'rev-parse --git-path rebase-merge/' . escapeshellarg( $file ) ); + if ( is_wp_error( $path ) ) { + return 0; + } + + $file_path = trim( (string) ( $path['output'] ?? '' ) ); + if ( '' !== $file_path && ! str_starts_with( $file_path, '/' ) ) { + $file_path = $repo_path . '/' . $file_path; + } + if ( '' === $file_path || ! is_file( $file_path ) ) { + return 0; + } + + $lines = file( $file_path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); + return is_array( $lines ) ? count( $lines ) : 0; + } + + private function default_rebase_onto( string $repo_path ): string { + $origin_head = $this->run_git( $repo_path, 'symbolic-ref --short refs/remotes/origin/HEAD' ); + if ( ! is_wp_error( $origin_head ) ) { + $ref = trim( (string) ( $origin_head['output'] ?? '' ) ); + if ( '' !== $ref ) { + return $ref; + } + } + + $branch = $this->git_get_branch( $repo_path ); + return $branch ? 'origin/' . $branch : 'origin/main'; + } + + private function git_head_sha( string $repo_path ): string { + $result = $this->run_git( $repo_path, 'rev-parse HEAD' ); + return is_wp_error( $result ) ? '' : trim( (string) ( $result['output'] ?? '' ) ); + } + + private function git_remote_branch_sha( string $repo_path, string $remote, string $branch ): ?string { + $result = $this->run_git( $repo_path, 'ls-remote --heads ' . escapeshellarg( $remote ) . ' ' . escapeshellarg( $branch ) ); + if ( is_wp_error( $result ) ) { + return null; + } + + $line = trim( (string) ( $result['output'] ?? '' ) ); + if ( preg_match( '/^([a-f0-9]{40})\s+refs\/heads\//', $line, $matches ) ) { + return $matches[1]; + } + + return null; + } + + /** @return array|\WP_Error */ + private function resolve_pull_request( string $repo_path, string|int|null $pr = null, ?string $branch = null ): array|\WP_Error { + $remote_url = $this->git_get_remote( $repo_path ); + $slug = $remote_url ? GitHubRemote::slug( $remote_url ) : null; + + if ( is_string( $pr ) && preg_match( '#github\.com/([\w.-]+/[\w.-]+)/pull/(\d+)#', $pr, $matches ) ) { + $slug = $matches[1]; + $pr = (int) $matches[2]; + } + + if ( null === $slug ) { + return new \WP_Error( 'missing_github_repo', 'Workspace remote does not resolve to a GitHub repository.', array( 'status' => 400 ) ); + } + + if ( ! class_exists( '\DataMachineCode\Abilities\GitHubAbilities' ) ) { + return new \WP_Error( 'github_abilities_unavailable', 'GitHubAbilities class is not loaded.', array( 'status' => 500 ) ); + } + + $pat = \DataMachineCode\Abilities\GitHubAbilities::getPat( array( 'repo' => $slug ) ); + if ( empty( $pat ) ) { + return new \WP_Error( 'missing_github_token', sprintf( 'No GitHub token is configured for %s.', $slug ), array( 'status' => 403 ) ); + } + + if ( null !== $pr && '' !== (string) $pr ) { + $response = \DataMachineCode\Abilities\GitHubAbilities::apiGet( GitHubRemote::apiUrl( $slug, 'pulls/' . (int) $pr ), array(), $pat ); + if ( is_wp_error( $response ) ) { + return $response; + } + $data = $response['data'] ?? null; + return is_array( $data ) ? $data : new \WP_Error( 'invalid_github_response', 'GitHub PR response was not an object.', array( 'status' => 502 ) ); + } + + $branch = $branch && '' !== trim( $branch ) ? trim( $branch ) : (string) $this->git_get_branch( $repo_path ); + if ( '' === $branch ) { + return new \WP_Error( 'missing_branch', 'Cannot resolve a pull request without a branch.', array( 'status' => 400 ) ); + } + + $owner = explode( '/', $slug, 2 )[0]; + $response = \DataMachineCode\Abilities\GitHubAbilities::apiGet( + GitHubRemote::apiUrl( $slug, 'pulls' ), + array( + 'head' => $owner . ':' . $branch, + 'state' => 'open', + ), + $pat + ); + if ( is_wp_error( $response ) ) { + return $response; + } + + $data = $response['data'] ?? null; + if ( ! is_array( $data ) || empty( $data[0] ) || ! is_array( $data[0] ) ) { + return new \WP_Error( 'pr_not_found', sprintf( 'No open pull request found for %s:%s.', $owner, $branch ), array( 'status' => 404 ) ); + } + + return $data[0]; + } + + /** @param array $patterns */ + private function path_matches_any_glob( string $path, array $patterns ): bool { + foreach ( $patterns as $pattern ) { + $pattern = trim( (string) $pattern ); + if ( '' === $pattern ) { + continue; + } + $regex = '#^' . str_replace( '\*\*', '.*', str_replace( '\*', '[^/]*', preg_quote( $pattern, '#' ) ) ) . '$#'; + if ( preg_match( $regex, $path ) ) { + return true; + } + } + + return false; + } + /** * Check if a relative path is within the allowlist. * diff --git a/tests/smoke-workspace-policy-tools.php b/tests/smoke-workspace-policy-tools.php index 1ce743b..798ccdf 100644 --- a/tests/smoke-workspace-policy-tools.php +++ b/tests/smoke-workspace-policy-tools.php @@ -101,6 +101,10 @@ static function ( array $tools ) use ( $tool_id, $definition_callback, $contexts 'workspace_git_add', 'workspace_git_commit', 'workspace_git_push', + 'workspace_git_rebase', + 'workspace_git_reset', + 'workspace_pr_status', + 'workspace_pr_rebase', ); foreach ( $policy_tools as $tool ) { diff --git a/tests/smoke-workspace-pr-rebase.php b/tests/smoke-workspace-pr-rebase.php new file mode 100644 index 0000000..c3029d4 --- /dev/null +++ b/tests/smoke-workspace-pr-rebase.php @@ -0,0 +1,146 @@ + $context */ + public static function getPat( array $context = array() ): string { return 'fake-token'; } + + /** @param array $query */ + public static function apiGet( string $url, array $query = array(), string $pat = '', int $timeout = 0 ): array|\WP_Error { + self::$requests[] = compact( 'url', 'query', 'pat', 'timeout' ); + return array( + 'success' => true, + 'data' => array( + 'number' => 410, + 'html_url' => 'https://github.com/Extra-Chill/demo/pull/410', + 'title' => 'feat: update daily note', + 'body' => 'Mock PR body.', + 'mergeable' => false, + 'mergeable_state' => 'dirty', + 'base' => array( 'ref' => 'main', 'sha' => 'base-sha' ), + 'head' => array( 'ref' => 'feature/pr-rebase', 'sha' => 'head-sha' ), + ), + ); + } + } +} + +namespace { + $root = sys_get_temp_dir() . '/dmc-pr-rebase-' . getmypid(); + if ( ! defined( 'ABSPATH' ) ) { + define( 'ABSPATH', __DIR__ . '/' ); + } + if ( ! defined( 'DATAMACHINE_WORKSPACE_PATH' ) ) { + define( 'DATAMACHINE_WORKSPACE_PATH', $root . '/workspace' ); + } + + if ( ! class_exists( 'WP_Error' ) ) { + class WP_Error { + public function __construct( private string $code, private string $message, private array $data = array() ) {} + public function get_error_code(): string { return $this->code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } + } + } + + function is_wp_error( $thing ): bool { return $thing instanceof WP_Error; } + function get_option( string $name, $default_value = null ) { return $default_value; } + function apply_filters( string $name, $value ) { return $value; } + + require __DIR__ . '/../inc/Support/GitHubRemote.php'; + require __DIR__ . '/../inc/Support/GitRunner.php'; + require __DIR__ . '/../inc/Support/PathSecurity.php'; + require __DIR__ . '/../inc/Workspace/Workspace.php'; + + use DataMachineCode\Workspace\Workspace; + + $failures = array(); + $assert = function ( string $label, bool $condition ) use ( &$failures ): void { + if ( $condition ) { + echo " ok {$label}\n"; + return; + } + $failures[] = $label; + echo " fail {$label}\n"; + }; + $run = function ( string $command ) use ( &$failures ): void { + $output = array(); + $code = 0; + exec( $command . ' 2>&1', $output, $code ); + if ( 0 !== $code ) { + $failures[] = 'command failed: ' . $command . ' :: ' . implode( "\n", $output ); + } + }; + + echo "Workspace PR rebase - smoke\n"; + + @mkdir( DATAMACHINE_WORKSPACE_PATH . '/demo', 0777, true ); + @mkdir( $root . '/remote.git', 0777, true ); + $repo = DATAMACHINE_WORKSPACE_PATH . '/demo'; + $bare = $root . '/remote.git'; + + $run( 'git init --bare ' . escapeshellarg( $bare ) ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' init -b main' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' config user.email test@example.com' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' config user.name Test' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' remote add origin ' . escapeshellarg( $bare ) ); + @mkdir( $repo . '/memory/daily', 0777, true ); + file_put_contents( $repo . '/memory/daily/note.md', "initial\n" ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' add memory/daily/note.md' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' commit -m initial' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' push origin main' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' checkout -b feature/pr-rebase' ); + file_put_contents( $repo . '/memory/daily/note.md', "feature\n" ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' commit -am feature-note' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' push origin feature/pr-rebase' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' checkout main' ); + file_put_contents( $repo . '/memory/daily/note.md', "base\n" ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' commit -am base-note' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' push origin main' ); + $run( 'git -C ' . escapeshellarg( $repo ) . ' checkout feature/pr-rebase' ); + + $result = ( new Workspace() )->pr_rebase( + 'demo', + 'https://github.com/Extra-Chill/demo/pull/410', + false, + array( 'memory/daily/**' ), + true + ); + + $assert( 'workspace_pr_rebase succeeds after dropping configured conflict path', is_array( $result ) && true === ( $result['success'] ?? false ) ); + $assert( 'workspace_pr_rebase reports clean state', is_array( $result ) && 'clean' === ( $result['state'] ?? '' ) ); + $assert( 'workspace_pr_rebase counts dropped path', is_array( $result ) && 1 === ( $result['dropped_paths'] ?? null ) ); + $assert( 'workspace_pr_rebase force-with-lease pushed', is_array( $result ) && true === ( $result['pushed'] ?? false ) ); + $assert( 'drop path kept base content', "base\n" === file_get_contents( $repo . '/memory/daily/note.md' ) ); + + $log = array(); + exec( 'git -C ' . escapeshellarg( $bare ) . ' log --oneline feature/pr-rebase 2>&1', $log ); + $assert( 'remote branch was updated', ! empty( $log ) && str_contains( implode( "\n", $log ), 'base-note' ) ); + + if ( ! empty( $failures ) ) { + echo "\nFAIL: " . count( $failures ) . " assertion(s)\n"; + foreach ( $failures as $failure ) { + echo " - {$failure}\n"; + } + exit( 1 ); + } + + echo "\nOK\n"; + exit( 0 ); +} From 5af32eec0477ad02a269ebba9a340ec942edddd3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 16 May 2026 12:04:53 -0400 Subject: [PATCH 2/3] fix: resolve phpstan findings in workspace PR rebase abilities --- inc/Abilities/WorkspaceAbilities.php | 15 +++++++++++++-- inc/Cli/Commands/WorkspaceCommand.php | 17 +++++------------ inc/Tools/WorkspaceTools.php | 15 +++++++++++++++ 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index fc64c2b..299abb8 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2178,6 +2178,10 @@ public static function getPath( array $input ): array|\WP_Error { if ( ! empty( $input['ensure'] ) ) { $result = $workspace->ensure_exists(); + if ( is_wp_error( $result ) ) { + return $result; + } + return array( 'success' => $result['success'], 'path' => $workspace->get_path(), @@ -2877,11 +2881,18 @@ public static function workspaceCleanupRun( array $input ): array|\WP_Error { $context['agent_id'] = (int) $input['agent_id']; } - $job_id = \DataMachine\Engine\Tasks\TaskScheduler::schedule( $task_type, $params, $context ); - if ( false === $job_id ) { + $batch_result = \DataMachine\Engine\Tasks\TaskScheduler::scheduleBatch( + $task_type, + array( $params ), + $context + ); + if ( false === $batch_result ) { return new \WP_Error( 'workspace_cleanup_schedule_failed', 'Failed to schedule workspace cleanup task.', array( 'status' => 500 ) ); } + $job_ids = is_array( $batch_result['job_ids'] ?? null ) ? $batch_result['job_ids'] : array(); + $job_id = (int) ( $job_ids[0] ?? 0 ); + return array( 'success' => true, 'state' => 'jobs_queued', diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 541d613..63c1926 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -215,8 +215,8 @@ public function clone_repo( array $args, array $assoc_args ): void { return; } - WP_CLI::success( $result['message'] ); - WP_CLI::log( sprintf( 'Path: %s', $result['path'] ) ); + WP_CLI::success( (string) ( $result['message'] ?? 'Repository cloned.' ) ); + WP_CLI::log( sprintf( 'Path: %s', (string) ( $result['path'] ?? '' ) ) ); } /** @@ -610,14 +610,6 @@ private function render_worktree_cleanup_result_from_ability( array|\WP_Error $r $this->render_worktree_cleanup_result( $result, $assoc_args ); } - private function render_worktree_metadata_reconciliation_result_from_ability( array|\WP_Error $result, array $assoc_args ): void { - if ( is_wp_error( $result ) ) { - WP_CLI::error( $result->get_error_message() ); - return; - } - $this->render_worktree_metadata_reconciliation_result( $result, $assoc_args ); - } - private function render_worktree_artifact_cleanup_result_from_ability( array|\WP_Error $result, array $assoc_args ): void { if ( is_wp_error( $result ) ) { WP_CLI::error( $result->get_error_message() ); @@ -685,7 +677,7 @@ private function render_cleanup_control_result( array $result, array $assoc_args return; } if ( 'yaml' === $format && class_exists( 'Spyc' ) ) { - WP_CLI::log( (string) call_user_func( array( 'Spyc', 'YAMLDump' ), $result, false, false, true ) ); + WP_CLI::log( (string) \Spyc::YAMLDump( $result, false, false, true ) ); return; } @@ -1631,6 +1623,7 @@ private function resolveAtFile( string $value ): string { if ( false === $content ) { WP_CLI::error( sprintf( 'Failed to read file: %s', $file_path ) ); + return ''; } return $content; @@ -2536,7 +2529,7 @@ function ( $wt ) { 'owner' => isset( $wt['owner']['user'] ) ? (string) $wt['owner']['user'] : 'unknown', 'agent' => isset( $wt['owner']['agent'] ) ? (string) $wt['owner']['agent'] : 'unknown', 'site' => isset( $wt['owner']['site'] ) ? (string) $wt['owner']['site'] : 'unknown', - 'session' => isset( $wt['session']['primary_id'] ) && null !== $wt['session']['primary_id'] ? (string) $wt['session']['primary_id'] : '', + 'session' => isset( $wt['session']['primary_id'] ) ? (string) $wt['session']['primary_id'] : '', 'task' => is_array( $wt['task'] ?? null ) ? (string) ( $wt['task']['task_url'] ?? $wt['task']['task_ref'] ?? '' ) : '', 'pr' => $wt['pr_url'] ?? null, 'age_days' => $wt['age_days'] ?? null, diff --git a/inc/Tools/WorkspaceTools.php b/inc/Tools/WorkspaceTools.php index fe07544..78488a9 100644 --- a/inc/Tools/WorkspaceTools.php +++ b/inc/Tools/WorkspaceTools.php @@ -117,6 +117,21 @@ public function handle_tool_call( array $parameters, array $tool_def = array() ) return $this->{$method}( $parameters, $tool_def ); } + /** + * Build a standard workspace tool error response. + * + * @param string $message Error message. + * @param string $tool_name Tool name. + * @return array + */ + protected function buildErrorResponse( string $message, string $tool_name ): array { + return array( + 'success' => false, + 'error' => $message, + 'tool_name' => $tool_name, + ); + } + /** * Handle workspace_path tool call. * From 09cdec10c351cbb18967f09e0443b942fc9f0bbd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 16 May 2026 12:09:53 -0400 Subject: [PATCH 3/3] fix: drop unreachable message offset in getPath result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ensure_exists() success array is typed as {success: bool, path: string, created?: bool} — no message key exists, so the null-coalescing read was statically unreachable. PHPStan rule phpstan.nullCoalesce.offset surfaced this. No consumer (CLI workspace path command, smoke tests) reads the message key off the getPath() ability response, so removing it is safe. --- inc/Abilities/WorkspaceAbilities.php | 1 - 1 file changed, 1 deletion(-) diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 299abb8..fcb818c 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -2187,7 +2187,6 @@ public static function getPath( array $input ): array|\WP_Error { 'path' => $workspace->get_path(), 'exists' => $result['success'], 'created' => $result['created'] ?? false, - 'message' => $result['message'] ?? null, ); }