Skip to content

Commit bd0c9a9

Browse files
committed
Add typed Homeboy promotion adapter
1 parent 431e57d commit bd0c9a9

6 files changed

Lines changed: 352 additions & 2 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ wp datamachine-code workspace worktree list
9191
wp datamachine-code workspace worktree remove repo-name fix/foo
9292
wp datamachine-code workspace worktree prune
9393

94+
# Typed Homeboy promotion apply (explicit handle supports {handle} argv substitution)
95+
wp datamachine-code workspace promotion-apply repo-name@fix-foo < promotion-request.json
96+
9497
# Daily workspace cleanup — task-backed, inspectable by run_id
9598
wp datamachine-code workspace cleanup run --mode=retention
9699
wp datamachine-code workspace cleanup status cleanup-run-123
@@ -111,6 +114,11 @@ wp datamachine-code workspace git log repo-name@fix-foo
111114
wp datamachine-code workspace git diff repo-name@fix-foo
112115
```
113116

117+
`promotion-apply` reads one `homeboy/agent-task-promotion-apply-request/v1`
118+
object from stdin and emits `homeboy/agent-task-promotion-apply-response/v1` on
119+
stdout. It only applies through the managed worktree patch primitive and refuses
120+
primary, dirty, mismatched, and untrusted-unpushed targets.
121+
114122
### Worktrees: parallel-safe branch work
115123

116124
The workspace is **worktree-native**. Each branch lives in its own directory at

inc/Cli/Commands/WorkspaceCommand.php

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use DataMachine\Cli\BaseCommand;
2020
use DataMachineCode\Cli\CliResponseRenderer;
2121
use DataMachineCode\Cli\CliRepeatableOptionParser;
22+
use DataMachineCode\Cli\HomeboyPromotionApplyAdapter;
2223
use DataMachineCode\Cli\WorkspaceCompactOutput;
2324
use DataMachineCode\Cleanup\CompositeCleanupRunEvidenceStore;
2425
use DataMachineCode\Cleanup\CleanupRunEvidenceStoreInterface;
@@ -3101,6 +3102,50 @@ public function patch( array $args, array $assoc_args ): void {
31013102
}
31023103
}
31033104

3105+
/**
3106+
* Apply a typed Homeboy promotion request to one managed worktree.
3107+
*
3108+
* Reads exactly one `homeboy/agent-task-promotion-apply-request/v1` JSON
3109+
* object from stdin and writes its response object to stdout. The explicit
3110+
* handle is intended for Homeboy's `{handle}` argv substitution.
3111+
*
3112+
* ## OPTIONS
3113+
*
3114+
* <handle>
3115+
* : Exact DMC-managed non-primary worktree handle.
3116+
*
3117+
* ## EXAMPLES
3118+
*
3119+
* wp datamachine-code workspace promotion-apply data-machine-code@feat-945 < request.json
3120+
*
3121+
* @subcommand promotion-apply
3122+
*/
3123+
public function promotion_apply( array $args, array $assoc_args ): void {
3124+
$handle = (string) ( $args[0] ?? '' );
3125+
if ( '' === trim($handle) || isset($args[1]) ) {
3126+
WP_CLI::error('Usage: wp datamachine-code workspace promotion-apply <handle> < request.json');
3127+
return;
3128+
}
3129+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
3130+
$input = file_get_contents('php://stdin');
3131+
if ( false === $input ) {
3132+
WP_CLI::error('Failed to read promotion request from stdin.');
3133+
return;
3134+
}
3135+
$request = json_decode($input, true);
3136+
if ( ! is_array($request) || JSON_ERROR_NONE !== json_last_error() ) {
3137+
WP_CLI::error('Promotion request stdin must contain one JSON object.');
3138+
return;
3139+
}
3140+
3141+
$result = HomeboyPromotionApplyAdapter::create()->execute($handle, $request);
3142+
if ( is_wp_error($result) ) {
3143+
WP_CLI::error($result->get_error_code() . ': ' . $result->get_error_message());
3144+
return;
3145+
}
3146+
$this->renderer()->json($result);
3147+
}
3148+
31043149
/**
31053150
* Delete a tracked or untracked path from a workspace repo.
31063151
*
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<?php
2+
/**
3+
* Typed Homeboy promotion patch adapter.
4+
*
5+
* @package DataMachineCode\Cli
6+
*/
7+
8+
namespace DataMachineCode\Cli;
9+
10+
use DataMachineCode\Workspace\Workspace;
11+
use DataMachineCode\Workspace\WorkspaceWriter;
12+
13+
defined('ABSPATH') || exit;
14+
15+
final class HomeboyPromotionApplyAdapter {
16+
17+
private const REQUEST_SCHEMA = 'homeboy/agent-task-promotion-apply-request/v1';
18+
private const RESPONSE_SCHEMA = 'homeboy/agent-task-promotion-apply-response/v1';
19+
private const EVIDENCE_LIMIT = 1024;
20+
21+
public function __construct( private object $workspace, private object $writer ) {
22+
}
23+
24+
public static function create(): self {
25+
$workspace = new Workspace();
26+
return new self($workspace, new WorkspaceWriter($workspace));
27+
}
28+
29+
/**
30+
* @param array<string,mixed> $request
31+
* @return array<string,mixed>|\WP_Error
32+
*/
33+
public function execute( string $expected_handle, array $request ): array|\WP_Error {
34+
$expected_handle = trim($expected_handle);
35+
if ( '' === $expected_handle ) {
36+
return $this->error('missing_expected_handle', 'The expected managed worktree handle is required.');
37+
}
38+
if ( self::REQUEST_SCHEMA !== ( $request['schema'] ?? null ) ) {
39+
return $this->error('invalid_promotion_schema', 'Request schema must be ' . self::REQUEST_SCHEMA . '.');
40+
}
41+
if ( $expected_handle !== ( $request['to_workspace'] ?? null ) ) {
42+
return $this->error('promotion_handle_mismatch', 'Request to_workspace must exactly match the expected handle.');
43+
}
44+
if ( ! is_string($request['patch_path'] ?? null) || '' === trim($request['patch_path']) ) {
45+
return $this->error('invalid_patch_path', 'Request patch_path must be a non-empty string.');
46+
}
47+
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)) ) {
48+
return $this->error('invalid_changed_files', 'Request changed_files must be a non-empty list of paths.');
49+
}
50+
if ( ! is_bool($request['dry_run'] ?? null) ) {
51+
return $this->error('invalid_dry_run', 'Request dry_run must be a boolean.');
52+
}
53+
54+
$target = $this->resolve_target($expected_handle, $request);
55+
if ( is_wp_error($target) ) {
56+
return $target;
57+
}
58+
$patch = $this->resolve_patch($request);
59+
if ( is_wp_error($patch) ) {
60+
return $patch;
61+
}
62+
63+
$preflight = $this->writer->apply_patch($expected_handle, $patch, false, true);
64+
if ( is_wp_error($preflight) ) {
65+
return $preflight;
66+
}
67+
$expected_files = $this->normalized_paths($request['changed_files']);
68+
$actual_files = $this->normalized_paths((array) ( $preflight['changed_files'] ?? array() ));
69+
if ( $expected_files !== $actual_files ) {
70+
return $this->error('changed_files_mismatch', 'Request changed_files must exactly match the patch paths.');
71+
}
72+
$result = $preflight;
73+
if ( ! $request['dry_run'] ) {
74+
$result = $this->writer->apply_patch($expected_handle, $patch);
75+
if ( is_wp_error($result) ) {
76+
return $result;
77+
}
78+
}
79+
80+
return array(
81+
'schema' => self::RESPONSE_SCHEMA,
82+
'workspace_path' => (string) $target['path'],
83+
'command_evidence' => $this->evidence((bool) $request['dry_run'], (string) ( $result['check_output'] ?? '' ), (string) ( $result['apply_output'] ?? '' )),
84+
);
85+
}
86+
87+
/** @return array<string,mixed>|\WP_Error */
88+
private function resolve_target( string $handle, array $request ): array|\WP_Error {
89+
$parsed = $this->workspace->parse_handle($handle);
90+
if ( empty($parsed['is_worktree']) ) {
91+
return $this->error('primary_worktree', 'Promotion apply only permits managed non-primary worktrees.');
92+
}
93+
$target = $this->workspace->managed_worktree_get($handle);
94+
if ( is_wp_error($target) ) {
95+
return $target;
96+
}
97+
if ( $handle !== ( $target['handle'] ?? null ) || empty($target['is_worktree']) || ! empty($target['is_primary']) || ! empty($target['external']) || ! is_string($target['path'] ?? null) ) {
98+
return $this->error('unmanaged_worktree', 'The requested handle is not an exact DMC-managed worktree registry entry.');
99+
}
100+
if ( 0 !== (int) ( $target['dirty'] ?? -1 ) ) {
101+
return $this->error('dirty_worktree', 'Promotion apply refuses a dirty destination worktree.');
102+
}
103+
if ( (int) ( $target['unpushed'] ?? 0 ) > 0 && ! $this->is_trusted_unpushed_target($target, $request['trusted_unpushed_candidate_destination'] ?? null) ) {
104+
return $this->error('untrusted_unpushed_target', 'Promotion apply refuses unpushed commits unless the request attests to this exact destination path and HEAD.');
105+
}
106+
107+
return $target;
108+
}
109+
110+
/** @param array<string,mixed> $target */
111+
private function is_trusted_unpushed_target( array $target, mixed $candidate ): bool {
112+
return is_array($candidate)
113+
&& ( $target['path'] ?? null ) === ( $candidate['path'] ?? null )
114+
&& ( $target['head'] ?? null ) === ( $candidate['head'] ?? null );
115+
}
116+
117+
/** @param array<string,mixed> $request */
118+
private function resolve_patch( array $request ): string|\WP_Error {
119+
if ( isset($request['patch']) ) {
120+
if ( ! is_string($request['patch']) || '' === trim($request['patch']) ) {
121+
return $this->error('invalid_patch', 'Request patch must be a non-empty unified diff when supplied.');
122+
}
123+
return $request['patch'];
124+
}
125+
$patch_path = realpath($request['patch_path']);
126+
if ( false === $patch_path || ! is_file($patch_path) || ! is_readable($patch_path) ) {
127+
return $this->error('invalid_patch_path', 'Request patch_path must resolve to a readable regular file when patch is omitted.');
128+
}
129+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
130+
$patch = file_get_contents($patch_path);
131+
return false === $patch || '' === trim($patch) ? $this->error('invalid_patch_path', 'Request patch_path did not contain a patch.') : $patch;
132+
}
133+
134+
/** @param array<int,mixed> $paths @return array<int,string> */
135+
private function normalized_paths( array $paths ): array {
136+
$paths = array_map(static fn( $path ): string => ltrim(str_replace('\\', '/', trim((string) $path)), '/'), $paths);
137+
$paths = array_values(array_unique(array_filter($paths)));
138+
sort($paths);
139+
return $paths;
140+
}
141+
142+
/** @return array<int,array<string,mixed>> */
143+
private function evidence( bool $dry_run, string $check_output, string $apply_output ): array {
144+
$evidence = array( $this->command_evidence(array( 'git', 'apply', '--check', '--whitespace=nowarn', '<patch>' ), $check_output) );
145+
if ( ! $dry_run ) {
146+
$evidence[] = $this->command_evidence(array( 'git', 'apply', '--whitespace=nowarn', '<patch>' ), $apply_output);
147+
}
148+
return $evidence;
149+
}
150+
151+
/** @param array<int,string> $command @return array<string,mixed> */
152+
private function command_evidence( array $command, string $stdout ): array {
153+
return array( 'command' => $command, 'exit_code' => 0, 'stdout' => substr($stdout, 0, self::EVIDENCE_LIMIT), 'stderr' => '' );
154+
}
155+
156+
private function error( string $code, string $message ): \WP_Error {
157+
return new \WP_Error($code, $message, array( 'status' => 400 ));
158+
}
159+
}

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,42 @@ public function worktree_list( ?string $repo = null, ?string $state = null, arra
10441044
);
10451045
}
10461046

1047+
/**
1048+
* Resolve a live worktree only when it has the matching DMC inventory row.
1049+
*
1050+
* This is the ownership boundary for external callers that must not operate
1051+
* on a worktree created outside DMC, even when it happens to sit below the
1052+
* configured workspace root.
1053+
*
1054+
* @return array<string,mixed>|\WP_Error
1055+
*/
1056+
public function managed_worktree_get( string $handle ): array|\WP_Error {
1057+
$parsed = $this->parse_handle($handle);
1058+
if ( ! $parsed['is_worktree'] || $handle !== $parsed['dir_name'] ) {
1059+
return new \WP_Error('unmanaged_worktree', 'The requested handle is not a managed non-primary worktree.', array( 'status' => 403 ));
1060+
}
1061+
$stored = $this->worktree_inventory()->get($handle);
1062+
if ( ! is_array($stored) || ! empty($stored['missing_path']) || $handle !== ( $stored['handle'] ?? null ) || ! empty($stored['is_primary']) ) {
1063+
return new \WP_Error('unmanaged_worktree', 'The requested handle has no active DMC worktree registry entry.', array( 'status' => 403 ));
1064+
}
1065+
$listing = $this->worktree_list((string) $parsed['repo'], null, array( 'handle' => $handle, 'include_status' => true, 'include_disk' => false ));
1066+
if ( is_wp_error($listing) ) {
1067+
return $listing;
1068+
}
1069+
$rows = (array) ( $listing['worktrees'] ?? array() );
1070+
if ( 1 !== count($rows) || ! is_array($rows[0]) ) {
1071+
return new \WP_Error('unmanaged_worktree', 'The requested handle is not a live DMC-managed worktree.', array( 'status' => 403 ));
1072+
}
1073+
$row = $rows[0];
1074+
$stored_path = realpath((string) ( $stored['path'] ?? '' ));
1075+
$live_path = realpath((string) ( $row['path'] ?? '' ));
1076+
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 ) {
1077+
return new \WP_Error('unmanaged_worktree', 'The requested worktree does not match its DMC registry path.', array( 'status' => 403 ));
1078+
}
1079+
$row['path'] = $live_path;
1080+
return $row;
1081+
}
1082+
10471083
/**
10481084
* Return warning metadata when a non-primary worktree holds a base branch.
10491085
*

inc/Workspace/WorkspaceWriter.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,10 @@ public function edit_file( string $name, string $path, string $old_string, strin
241241
* @param string $name Workspace handle.
242242
* @param string $patch Unified diff to apply.
243243
* @param bool $allow_primary_mutation Permit mutation on a primary checkout.
244-
* @return array{success: bool, name: string, path: string, changed_files: string[], diff: string, status: string, check_output: string, apply_output: string}|\WP_Error
244+
* @param bool $dry_run Check the patch without applying it.
245+
* @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
245246
*/
246-
public function apply_patch( string $name, string $patch, bool $allow_primary_mutation = false ): array|\WP_Error {
247+
public function apply_patch( string $name, string $patch, bool $allow_primary_mutation = false, bool $dry_run = false ): array|\WP_Error {
247248
$mutation_check = $this->ensure_workspace_mutation_allowed($name, 'apply-patch', $allow_primary_mutation);
248249
if ( is_wp_error($mutation_check) ) {
249250
return $mutation_check;
@@ -305,6 +306,20 @@ public function apply_patch( string $name, string $patch, bool $allow_primary_mu
305306
);
306307
}
307308

309+
if ( $dry_run ) {
310+
return array(
311+
'success' => true,
312+
'name' => $name,
313+
'path' => $repo_path,
314+
'changed_files' => $patch_paths,
315+
'diff' => '',
316+
'status' => '',
317+
'check_output' => $check['output'],
318+
'apply_output' => '',
319+
'dry_run' => true,
320+
);
321+
}
322+
308323
$apply = GitRunner::run($repo_path, 'apply --whitespace=nowarn ' . $patch_arg);
309324
if ( is_wp_error($apply) ) {
310325
return new \WP_Error(

0 commit comments

Comments
 (0)