diff --git a/README.md b/README.md index c1621ff6..80c6eb4e 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ wp datamachine-code workspace worktree list wp datamachine-code workspace worktree remove repo-name fix/foo wp datamachine-code workspace worktree prune +# Typed Homeboy promotion apply (explicit handle supports {handle} argv substitution) +wp datamachine-code workspace promotion-apply repo-name@fix-foo < promotion-request.json + # Daily workspace cleanup — task-backed, inspectable by run_id wp datamachine-code workspace cleanup run --mode=retention wp datamachine-code workspace cleanup status cleanup-run-123 @@ -111,6 +114,11 @@ wp datamachine-code workspace git log repo-name@fix-foo wp datamachine-code workspace git diff repo-name@fix-foo ``` +`promotion-apply` reads one `homeboy/agent-task-promotion-apply-request/v1` +object from stdin and emits `homeboy/agent-task-promotion-apply-response/v1` on +stdout. It only applies through the managed worktree patch primitive and refuses +primary, dirty, mismatched, and untrusted-unpushed targets. + ### Worktrees: parallel-safe branch work The workspace is **worktree-native**. Each branch lives in its own directory at diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index cb37ec8f..91d3c842 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -19,6 +19,7 @@ use DataMachine\Cli\BaseCommand; use DataMachineCode\Cli\CliResponseRenderer; use DataMachineCode\Cli\CliRepeatableOptionParser; +use DataMachineCode\Cli\HomeboyPromotionApplyAdapter; use DataMachineCode\Cli\WorkspaceCompactOutput; use DataMachineCode\Cleanup\CompositeCleanupRunEvidenceStore; use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface; @@ -3101,6 +3102,50 @@ public function patch( array $args, array $assoc_args ): void { } } + /** + * Apply a typed Homeboy promotion request to one managed worktree. + * + * Reads exactly one `homeboy/agent-task-promotion-apply-request/v1` JSON + * object from stdin and writes its response object to stdout. The explicit + * handle is intended for Homeboy's `{handle}` argv substitution. + * + * ## OPTIONS + * + * + * : Exact DMC-managed non-primary worktree handle. + * + * ## EXAMPLES + * + * wp datamachine-code workspace promotion-apply data-machine-code@feat-945 < request.json + * + * @subcommand promotion-apply + */ + public function promotion_apply( array $args, array $assoc_args ): void { + $handle = (string) ( $args[0] ?? '' ); + if ( '' === trim($handle) || isset($args[1]) ) { + WP_CLI::error('Usage: wp datamachine-code workspace promotion-apply < request.json'); + return; + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $input = file_get_contents('php://stdin'); + if ( false === $input ) { + WP_CLI::error('Failed to read promotion request from stdin.'); + return; + } + $request = json_decode($input, true); + if ( ! is_array($request) || JSON_ERROR_NONE !== json_last_error() ) { + WP_CLI::error('Promotion request stdin must contain one JSON object.'); + return; + } + + $result = HomeboyPromotionApplyAdapter::create()->execute($handle, $request); + if ( is_wp_error($result) ) { + WP_CLI::error($result->get_error_code() . ': ' . $result->get_error_message()); + return; + } + $this->renderer()->json($result); + } + /** * Delete a tracked or untracked path from a workspace repo. * diff --git a/inc/Cli/HomeboyPromotionApplyAdapter.php b/inc/Cli/HomeboyPromotionApplyAdapter.php new file mode 100644 index 00000000..3e2784bd --- /dev/null +++ b/inc/Cli/HomeboyPromotionApplyAdapter.php @@ -0,0 +1,159 @@ + $request + * @return array|\WP_Error + */ + public function execute( string $expected_handle, array $request ): array|\WP_Error { + $expected_handle = trim($expected_handle); + if ( '' === $expected_handle ) { + return $this->error('missing_expected_handle', 'The expected managed worktree handle is required.'); + } + if ( self::REQUEST_SCHEMA !== ( $request['schema'] ?? null ) ) { + return $this->error('invalid_promotion_schema', 'Request schema must be ' . self::REQUEST_SCHEMA . '.'); + } + if ( $expected_handle !== ( $request['to_workspace'] ?? null ) ) { + return $this->error('promotion_handle_mismatch', 'Request to_workspace must exactly match the expected handle.'); + } + if ( ! is_string($request['patch_path'] ?? null) || '' === trim($request['patch_path']) ) { + return $this->error('invalid_patch_path', 'Request patch_path must be a non-empty string.'); + } + if ( ! is_array($request['changed_files'] ?? null) || empty($request['changed_files']) || array_filter($request['changed_files'], static fn( $path ): bool => ! is_string($path) || '' === trim($path)) ) { + return $this->error('invalid_changed_files', 'Request changed_files must be a non-empty list of paths.'); + } + if ( ! is_bool($request['dry_run'] ?? null) ) { + return $this->error('invalid_dry_run', 'Request dry_run must be a boolean.'); + } + + $target = $this->resolve_target($expected_handle, $request); + if ( is_wp_error($target) ) { + return $target; + } + $patch = $this->resolve_patch($request); + if ( is_wp_error($patch) ) { + return $patch; + } + + $preflight = $this->writer->apply_patch($expected_handle, $patch, false, true); + if ( is_wp_error($preflight) ) { + return $preflight; + } + $expected_files = $this->normalized_paths($request['changed_files']); + $actual_files = $this->normalized_paths((array) ( $preflight['changed_files'] ?? array() )); + if ( $expected_files !== $actual_files ) { + return $this->error('changed_files_mismatch', 'Request changed_files must exactly match the patch paths.'); + } + $result = $preflight; + if ( ! $request['dry_run'] ) { + $result = $this->writer->apply_patch($expected_handle, $patch); + if ( is_wp_error($result) ) { + return $result; + } + } + + return array( + 'schema' => self::RESPONSE_SCHEMA, + 'workspace_path' => (string) $target['path'], + 'command_evidence' => $this->evidence((bool) $request['dry_run'], (string) ( $result['check_output'] ?? '' ), (string) ( $result['apply_output'] ?? '' )), + ); + } + + /** @return array|\WP_Error */ + private function resolve_target( string $handle, array $request ): array|\WP_Error { + $parsed = $this->workspace->parse_handle($handle); + if ( empty($parsed['is_worktree']) ) { + return $this->error('primary_worktree', 'Promotion apply only permits managed non-primary worktrees.'); + } + $target = $this->workspace->managed_worktree_get($handle); + if ( is_wp_error($target) ) { + return $target; + } + if ( $handle !== ( $target['handle'] ?? null ) || empty($target['is_worktree']) || ! empty($target['is_primary']) || ! empty($target['external']) || ! is_string($target['path'] ?? null) ) { + return $this->error('unmanaged_worktree', 'The requested handle is not an exact DMC-managed worktree registry entry.'); + } + if ( 0 !== (int) ( $target['dirty'] ?? -1 ) ) { + return $this->error('dirty_worktree', 'Promotion apply refuses a dirty destination worktree.'); + } + if ( (int) ( $target['unpushed'] ?? 0 ) > 0 && ! $this->is_trusted_unpushed_target($target, $request['trusted_unpushed_candidate_destination'] ?? null) ) { + return $this->error('untrusted_unpushed_target', 'Promotion apply refuses unpushed commits unless the request attests to this exact destination path and HEAD.'); + } + + return $target; + } + + /** @param array $target */ + private function is_trusted_unpushed_target( array $target, mixed $candidate ): bool { + return is_array($candidate) + && ( $target['path'] ?? null ) === ( $candidate['path'] ?? null ) + && ( $target['head'] ?? null ) === ( $candidate['head'] ?? null ); + } + + /** @param array $request */ + private function resolve_patch( array $request ): string|\WP_Error { + if ( isset($request['patch']) ) { + if ( ! is_string($request['patch']) || '' === trim($request['patch']) ) { + return $this->error('invalid_patch', 'Request patch must be a non-empty unified diff when supplied.'); + } + return $request['patch']; + } + $patch_path = realpath($request['patch_path']); + if ( false === $patch_path || ! is_file($patch_path) || ! is_readable($patch_path) ) { + return $this->error('invalid_patch_path', 'Request patch_path must resolve to a readable regular file when patch is omitted.'); + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $patch = file_get_contents($patch_path); + return false === $patch || '' === trim($patch) ? $this->error('invalid_patch_path', 'Request patch_path did not contain a patch.') : $patch; + } + + /** @param array $paths @return array */ + private function normalized_paths( array $paths ): array { + $paths = array_map(static fn( $path ): string => ltrim(str_replace('\\', '/', trim((string) $path)), '/'), $paths); + $paths = array_values(array_unique(array_filter($paths))); + sort($paths); + return $paths; + } + + /** @return array> */ + private function evidence( bool $dry_run, string $check_output, string $apply_output ): array { + $evidence = array( $this->command_evidence(array( 'git', 'apply', '--check', '--whitespace=nowarn', '' ), $check_output) ); + if ( ! $dry_run ) { + $evidence[] = $this->command_evidence(array( 'git', 'apply', '--whitespace=nowarn', '' ), $apply_output); + } + return $evidence; + } + + /** @param array $command @return array */ + private function command_evidence( array $command, string $stdout ): array { + return array( 'command' => $command, 'exit_code' => 0, 'stdout' => substr($stdout, 0, self::EVIDENCE_LIMIT), 'stderr' => '' ); + } + + private function error( string $code, string $message ): \WP_Error { + return new \WP_Error($code, $message, array( 'status' => 400 )); + } +} diff --git a/inc/Workspace/WorkspaceWorktreeLifecycle.php b/inc/Workspace/WorkspaceWorktreeLifecycle.php index 80481973..4767bdc9 100644 --- a/inc/Workspace/WorkspaceWorktreeLifecycle.php +++ b/inc/Workspace/WorkspaceWorktreeLifecycle.php @@ -1044,6 +1044,42 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra ); } + /** + * Resolve a live worktree only when it has the matching DMC inventory row. + * + * This is the ownership boundary for external callers that must not operate + * on a worktree created outside DMC, even when it happens to sit below the + * configured workspace root. + * + * @return array|\WP_Error + */ + public function managed_worktree_get( string $handle ): array|\WP_Error { + $parsed = $this->parse_handle($handle); + if ( ! $parsed['is_worktree'] || $handle !== $parsed['dir_name'] ) { + return new \WP_Error('unmanaged_worktree', 'The requested handle is not a managed non-primary worktree.', array( 'status' => 403 )); + } + $stored = $this->worktree_inventory()->get($handle); + if ( ! is_array($stored) || ! empty($stored['missing_path']) || $handle !== ( $stored['handle'] ?? null ) || ! empty($stored['is_primary']) ) { + return new \WP_Error('unmanaged_worktree', 'The requested handle has no active DMC worktree registry entry.', array( 'status' => 403 )); + } + $listing = $this->worktree_list((string) $parsed['repo'], null, array( 'handle' => $handle, 'include_status' => true, 'include_disk' => false )); + if ( is_wp_error($listing) ) { + return $listing; + } + $rows = (array) ( $listing['worktrees'] ?? array() ); + if ( 1 !== count($rows) || ! is_array($rows[0]) ) { + return new \WP_Error('unmanaged_worktree', 'The requested handle is not a live DMC-managed worktree.', array( 'status' => 403 )); + } + $row = $rows[0]; + $stored_path = realpath((string) ( $stored['path'] ?? '' )); + $live_path = realpath((string) ( $row['path'] ?? '' )); + if ( $handle !== ( $row['handle'] ?? null ) || empty($row['is_worktree']) || ! empty($row['is_primary']) || ! empty($row['external']) || false === $stored_path || false === $live_path || $stored_path !== $live_path ) { + return new \WP_Error('unmanaged_worktree', 'The requested worktree does not match its DMC registry path.', array( 'status' => 403 )); + } + $row['path'] = $live_path; + return $row; + } + /** * Return warning metadata when a non-primary worktree holds a base branch. * diff --git a/inc/Workspace/WorkspaceWriter.php b/inc/Workspace/WorkspaceWriter.php index 9eb1f268..9d21c89e 100644 --- a/inc/Workspace/WorkspaceWriter.php +++ b/inc/Workspace/WorkspaceWriter.php @@ -241,9 +241,10 @@ public function edit_file( string $name, string $path, string $old_string, strin * @param string $name Workspace handle. * @param string $patch Unified diff to apply. * @param bool $allow_primary_mutation Permit mutation on a primary checkout. - * @return array{success: bool, name: string, path: string, changed_files: string[], diff: string, status: string, check_output: string, apply_output: string}|\WP_Error + * @param bool $dry_run Check the patch without applying it. + * @return array{success: bool, name: string, path: string, changed_files: string[], diff: string, status: string, check_output: string, apply_output: string, dry_run?: bool}|\WP_Error */ - public function apply_patch( string $name, string $patch, bool $allow_primary_mutation = false ): array|\WP_Error { + public function apply_patch( string $name, string $patch, bool $allow_primary_mutation = false, bool $dry_run = false ): array|\WP_Error { $mutation_check = $this->ensure_workspace_mutation_allowed($name, 'apply-patch', $allow_primary_mutation); if ( is_wp_error($mutation_check) ) { return $mutation_check; @@ -305,6 +306,20 @@ public function apply_patch( string $name, string $patch, bool $allow_primary_mu ); } + if ( $dry_run ) { + return array( + 'success' => true, + 'name' => $name, + 'path' => $repo_path, + 'changed_files' => $patch_paths, + 'diff' => '', + 'status' => '', + 'check_output' => $check['output'], + 'apply_output' => '', + 'dry_run' => true, + ); + } + $apply = GitRunner::run($repo_path, 'apply --whitespace=nowarn ' . $patch_arg); if ( is_wp_error($apply) ) { return new \WP_Error( diff --git a/tests/homeboy-promotion-apply-cli.php b/tests/homeboy-promotion-apply-cli.php new file mode 100644 index 00000000..5bff3233 --- /dev/null +++ b/tests/homeboy-promotion-apply-cli.php @@ -0,0 +1,87 @@ +code; } + public function get_error_message(): string { return $this->message; } +} + +function is_wp_error( mixed $value ): bool { return $value instanceof WP_Error; } + +define('ABSPATH', __DIR__ . '/fixtures/'); +require_once dirname(__DIR__) . '/inc/Cli/HomeboyPromotionApplyAdapter.php'; + +final class Promotion_Apply_Fake_Workspace { + public function __construct( public array $target ) {} + public function parse_handle( string $handle ): array { + $parts = explode('@', $handle, 2); + return array( 'repo' => $parts[0], 'is_worktree' => isset($parts[1]), 'dir_name' => $handle ); + } + public function managed_worktree_get( string $handle ): array { return $this->target; } +} + +final class Promotion_Apply_Fake_Writer { + public array $calls = array(); + public bool $mutated = false; + public function apply_patch( string $handle, string $patch, bool $allow_primary = false, bool $dry_run = false ): array { + $this->calls[] = array( $handle, $patch, $allow_primary, $dry_run ); + if ( ! $dry_run ) { $this->mutated = true; } + return array( 'changed_files' => array( 'README.md' ), 'check_output' => 'checked', 'apply_output' => $dry_run ? '' : 'applied' ); + } +} + +function promotion_apply_assert( bool $condition, string $message ): void { if ( ! $condition ) { throw new RuntimeException($message); } } +function promotion_apply_request( array $overrides = array() ): array { + return array_merge(array( + 'schema' => 'homeboy/agent-task-promotion-apply-request/v1', 'to_workspace' => 'repo@feature', 'patch_path' => '/tmp/promotion.patch', + 'changed_files' => array( 'README.md' ), 'patch' => "diff --git a/README.md b/README.md\n", 'dry_run' => false, + ), $overrides); +} +function promotion_apply_adapter( array $target, ?Promotion_Apply_Fake_Writer &$writer = null ): \DataMachineCode\Cli\HomeboyPromotionApplyAdapter { + $writer = new Promotion_Apply_Fake_Writer(); + return new \DataMachineCode\Cli\HomeboyPromotionApplyAdapter(new Promotion_Apply_Fake_Workspace($target), $writer); +} + +$target = array( 'handle' => 'repo@feature', 'is_worktree' => true, 'is_primary' => false, 'external' => false, 'path' => '/workspace/repo@feature', 'head' => 'abc123', 'dirty' => 0, 'unpushed' => 0 ); + +$adapter = promotion_apply_adapter($target, $writer); +$result = $adapter->execute('repo@feature', promotion_apply_request()); +promotion_apply_assert(! is_wp_error($result), 'valid non-empty promotion patch should apply'); +promotion_apply_assert($writer->mutated, 'valid promotion patch should mutate after preflight'); +promotion_apply_assert(2 === count($writer->calls) && true === $writer->calls[0][3], 'valid apply should preflight before mutation'); +promotion_apply_assert('homeboy/agent-task-promotion-apply-response/v1' === $result['schema'], 'valid response schema mismatch'); +promotion_apply_assert('/workspace/repo@feature' === $result['workspace_path'], 'response must use canonical managed workspace path'); + +$adapter = promotion_apply_adapter($target, $writer); +$result = $adapter->execute('repo@feature', promotion_apply_request(array( 'dry_run' => true ))); +promotion_apply_assert(! is_wp_error($result) && ! $writer->mutated && 1 === count($writer->calls), 'dry run must validate without mutation'); + +$adapter = promotion_apply_adapter($target, $writer); +$result = $adapter->execute('repo@feature', promotion_apply_request(array( 'schema' => 'invalid/v1' ))); +promotion_apply_assert(is_wp_error($result) && 'invalid_promotion_schema' === $result->get_error_code(), 'wrong schema must be rejected'); + +$adapter = promotion_apply_adapter($target, $writer); +$result = $adapter->execute('repo@other', promotion_apply_request()); +promotion_apply_assert(is_wp_error($result) && 'promotion_handle_mismatch' === $result->get_error_code(), 'wrong explicit handle must be rejected'); + +$adapter = promotion_apply_adapter($target, $writer); +$result = $adapter->execute('repo@feature', promotion_apply_request(array( 'patch_path' => '' ))); +promotion_apply_assert(is_wp_error($result) && 'invalid_patch_path' === $result->get_error_code(), 'empty patch path must be rejected'); + +$adapter = promotion_apply_adapter($target, $writer); +$result = $adapter->execute('repo', promotion_apply_request(array( 'to_workspace' => 'repo' ))); +promotion_apply_assert(is_wp_error($result) && 'primary_worktree' === $result->get_error_code(), 'primary destination must be rejected'); + +$dirty = $target; $dirty['dirty'] = 1; +$adapter = promotion_apply_adapter($dirty, $writer); +$result = $adapter->execute('repo@feature', promotion_apply_request()); +promotion_apply_assert(is_wp_error($result) && 'dirty_worktree' === $result->get_error_code() && ! $writer->mutated, 'dirty destination must be rejected before mutation'); + +$unpushed = $target; $unpushed['unpushed'] = 1; +$adapter = promotion_apply_adapter($unpushed, $writer); +$result = $adapter->execute('repo@feature', promotion_apply_request()); +promotion_apply_assert(is_wp_error($result) && 'untrusted_unpushed_target' === $result->get_error_code() && ! $writer->mutated, 'untrusted unpushed destination must be rejected before mutation'); + +echo "homeboy-promotion-apply-cli: ok\n";