diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index be5ba4e..fcb818c 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' ), @@ -1999,12 +2178,15 @@ 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(), 'exists' => $result['success'], 'created' => $result['created'] ?? false, - 'message' => $result['message'] ?? null, ); } @@ -2267,6 +2449,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 +2591,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 +2838,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 +2861,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'] ) ) { @@ -2597,11 +2880,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', @@ -2815,9 +3105,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 +3152,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..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'] ?? '' ) ) ); } /** @@ -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'] ), @@ -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; } @@ -1459,7 +1451,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'] ), ) ); @@ -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; @@ -1713,13 +1706,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 +1734,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 +1765,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 +2335,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 +2355,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 +2389,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 +2454,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']; } @@ -2482,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, @@ -2514,7 +2561,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 +2710,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..78488a9 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' ) ); } /** @@ -109,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. * @@ -500,18 +523,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 +1099,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 ); +}