Skip to content

Commit 0accd51

Browse files
refactor: register worktree context projections (#754)
Co-authored-by: homeboy-ci[bot] <266378653+homeboy-ci[bot]@users.noreply.github.com>
1 parent bb87f54 commit 0accd51

2 files changed

Lines changed: 361 additions & 41 deletions

File tree

inc/Workspace/WorktreeContextInjector.php

Lines changed: 237 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ class WorktreeContextInjector {
168168
*/
169169
private const MEMORY_FILES = array( 'MEMORY.md', 'USER.md', 'RULES.md' );
170170

171+
/**
172+
* Filterable registry hook for worktree context projection targets.
173+
*/
174+
public const PROJECTION_TARGETS_FILTER = 'datamachine_code_worktree_context_projection_targets';
175+
176+
/**
177+
* Filterable registry hook for projection cleanup markers.
178+
*/
179+
public const PROJECTION_CLEANUP_FILTER = 'datamachine_code_worktree_context_projection_cleanup';
180+
171181
/**
172182
* Site-provided sections that should be visible before long memory snapshots.
173183
*/
@@ -1085,46 +1095,24 @@ public static function inject( string $worktree_path, array $payload ): array|\W
10851095
);
10861096
}
10871097

1088-
$body = self::render($payload);
1089-
$written = array();
1098+
$written = array();
1099+
$exclude_entries = array();
10901100

1091-
foreach ( self::INJECTED_PATHS as $relative ) {
1092-
$abs = rtrim($worktree_path, '/') . '/' . $relative;
1093-
$dir = dirname($abs);
1094-
if ( ! is_dir($dir) ) {
1095-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
1096-
if ( ! wp_mkdir_p($dir) ) {
1097-
return new \WP_Error(
1098-
'mkdir_failed',
1099-
sprintf('Failed to create directory: %s', $dir),
1100-
array( 'status' => 500 )
1101-
);
1102-
}
1103-
}
1104-
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
1105-
$bytes = file_put_contents($abs, $body);
1106-
if ( false === $bytes ) {
1107-
return new \WP_Error(
1108-
'write_failed',
1109-
sprintf('Failed to write injected file: %s', $abs),
1110-
array( 'status' => 500 )
1111-
);
1101+
foreach ( self::get_projection_targets($payload) as $target ) {
1102+
if ( ! is_array($target) ) {
1103+
continue;
11121104
}
1113-
$written[] = $abs;
1114-
}
11151105

1116-
$agents_projection = self::project_site_agents_md($worktree_path, $payload);
1117-
if ( is_wp_error($agents_projection) ) {
1118-
return $agents_projection;
1119-
}
1120-
$written = array_merge($written, $agents_projection);
1106+
$result = self::project_context_target($worktree_path, $payload, $target);
1107+
if ( is_wp_error($result) ) {
1108+
return $result;
1109+
}
11211110

1122-
$exclude_entries = self::INJECTED_PATHS;
1123-
if ( ! empty($agents_projection) ) {
1124-
$exclude_entries[] = self::PROJECTED_AGENTS_PATH;
1125-
$exclude_entries[] = self::PROJECTED_AGENTS_MARKER_PATH;
1126-
$exclude_entries[] = self::PROJECTED_OPENCODE_CONFIG_PATH;
1127-
$exclude_entries[] = self::PROJECTED_OPENCODE_CONFIG_MARKER_PATH;
1111+
$target_written = $result['written'] ?? array();
1112+
$written = array_merge($written, $target_written);
1113+
if ( ! empty($result['exclude']) ) {
1114+
$exclude_entries = array_merge($exclude_entries, $result['exclude']);
1115+
}
11281116
}
11291117

11301118
$exclude_path = self::append_exclude_entries($worktree_path, $exclude_entries);
@@ -1136,6 +1124,162 @@ public static function inject( string $worktree_path, array $payload ): array|\W
11361124
);
11371125
}
11381126

1127+
/**
1128+
* Return registered worktree context projection targets.
1129+
*
1130+
* Registry entry shape:
1131+
* - `path`: relative file path for rendered payload writes.
1132+
* - `renderer`: optional callable receiving payload and returning string.
1133+
* - `projector`: optional callable receiving worktree path and payload.
1134+
* - `exclude`: bool or relative path list to add to per-checkout exclude.
1135+
* - `exclude_paths`: relative path list added when a projector writes files.
1136+
*
1137+
* @param array $payload Payload from {@see self::build_payload()}.
1138+
* @return array<string,array<string,mixed>> Projection target registry.
1139+
*/
1140+
public static function get_projection_targets( array $payload = array() ): array {
1141+
$targets = array(
1142+
'claude_local_context' => array(
1143+
'path' => self::INJECTED_PATHS[0],
1144+
'renderer' => array( self::class, 'render' ),
1145+
'exclude' => true,
1146+
),
1147+
'site_agents_md' => array(
1148+
'projector' => array( self::class, 'project_site_agents_md' ),
1149+
'exclude_paths' => array(
1150+
self::PROJECTED_AGENTS_PATH,
1151+
self::PROJECTED_AGENTS_MARKER_PATH,
1152+
self::PROJECTED_OPENCODE_CONFIG_PATH,
1153+
self::PROJECTED_OPENCODE_CONFIG_MARKER_PATH,
1154+
),
1155+
),
1156+
);
1157+
1158+
if ( function_exists('apply_filters') ) {
1159+
$targets = apply_filters(self::PROJECTION_TARGETS_FILTER, $targets, $payload);
1160+
}
1161+
1162+
return is_array($targets) ? $targets : array();
1163+
}
1164+
1165+
/**
1166+
* Return registered cleanup handlers and marker paths.
1167+
*
1168+
* @return array<string,array<string,mixed>> Projection cleanup registry.
1169+
*/
1170+
public static function get_projection_cleanup_registry(): array {
1171+
$cleanup = array(
1172+
'site_agents_md' => array(
1173+
'cleanup' => array( self::class, 'cleanup_site_agents_projection' ),
1174+
),
1175+
'opencode_config' => array(
1176+
'cleanup' => array( self::class, 'cleanup_opencode_projection' ),
1177+
),
1178+
'claude_local_context' => array(
1179+
'paths' => self::INJECTED_PATHS,
1180+
),
1181+
'legacy_opencode' => array(
1182+
'paths' => self::LEGACY_INJECTED_PATHS,
1183+
),
1184+
);
1185+
1186+
if ( function_exists('apply_filters') ) {
1187+
$cleanup = apply_filters(self::PROJECTION_CLEANUP_FILTER, $cleanup);
1188+
}
1189+
1190+
return is_array($cleanup) ? $cleanup : array();
1191+
}
1192+
1193+
/**
1194+
* Apply one projection target from the registry.
1195+
*
1196+
* @param string $worktree_path Absolute path to the worktree directory.
1197+
* @param array $payload Payload from {@see self::build_payload()}.
1198+
* @param array $target Projection target configuration.
1199+
* @return array{written:string[],exclude:string[]}|\WP_Error Projection result.
1200+
*/
1201+
private static function project_context_target( string $worktree_path, array $payload, array $target ): array|\WP_Error {
1202+
$written = array();
1203+
$exclude = array();
1204+
1205+
if ( isset($target['path']) && is_string($target['path']) && '' !== trim($target['path']) ) {
1206+
$renderer = $target['renderer'] ?? array( self::class, 'render' );
1207+
if ( ! is_callable($renderer) ) {
1208+
return new \WP_Error('context_projection_renderer_invalid', 'Context projection renderer is not callable.', array( 'status' => 500 ));
1209+
}
1210+
1211+
$body = call_user_func($renderer, $payload, $worktree_path, $target);
1212+
if ( ! is_string($body) ) {
1213+
return new \WP_Error('context_projection_renderer_invalid', 'Context projection renderer must return a string.', array( 'status' => 500 ));
1214+
}
1215+
1216+
$result = self::write_projection_file($worktree_path, $target['path'], $body);
1217+
if ( is_wp_error($result) ) {
1218+
return $result;
1219+
}
1220+
$written[] = $result;
1221+
1222+
if ( true === ( $target['exclude'] ?? false ) ) {
1223+
$exclude[] = $target['path'];
1224+
}
1225+
}
1226+
1227+
if ( isset($target['projector']) ) {
1228+
if ( ! is_callable($target['projector']) ) {
1229+
return new \WP_Error('context_projection_projector_invalid', 'Context projection projector is not callable.', array( 'status' => 500 ));
1230+
}
1231+
1232+
$result = call_user_func($target['projector'], $worktree_path, $payload, $target);
1233+
if ( is_wp_error($result) ) {
1234+
return $result;
1235+
}
1236+
if ( ! is_array($result) ) {
1237+
return new \WP_Error('context_projection_projector_invalid', 'Context projection projector must return an array of written paths.', array( 'status' => 500 ));
1238+
}
1239+
$written = array_merge($written, array_values($result));
1240+
1241+
if ( ! empty($result) && ! empty($target['exclude_paths']) && is_array($target['exclude_paths']) ) {
1242+
$exclude = array_merge($exclude, array_values($target['exclude_paths']));
1243+
}
1244+
}
1245+
1246+
if ( isset($target['exclude']) && is_array($target['exclude']) ) {
1247+
$exclude = array_merge($exclude, array_values($target['exclude']));
1248+
}
1249+
1250+
return array(
1251+
'written' => $written,
1252+
'exclude' => $exclude,
1253+
);
1254+
}
1255+
1256+
/**
1257+
* Write a generated projection file under a worktree.
1258+
*
1259+
* @param string $worktree_path Absolute path to the worktree directory.
1260+
* @param string $relative Relative projection path.
1261+
* @param string $body Projection content.
1262+
* @return string|\WP_Error Absolute written path or error.
1263+
*/
1264+
private static function write_projection_file( string $worktree_path, string $relative, string $body ): string|\WP_Error {
1265+
$abs = rtrim($worktree_path, '/') . '/' . ltrim($relative, '/');
1266+
$dir = dirname($abs);
1267+
if ( ! is_dir($dir) ) {
1268+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir
1269+
if ( ! wp_mkdir_p($dir) ) {
1270+
return new \WP_Error('mkdir_failed', sprintf('Failed to create directory: %s', $dir), array( 'status' => 500 ));
1271+
}
1272+
}
1273+
1274+
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
1275+
$bytes = file_put_contents($abs, $body);
1276+
if ( false === $bytes ) {
1277+
return new \WP_Error('write_failed', sprintf('Failed to write injected file: %s', $abs), array( 'status' => 500 ));
1278+
}
1279+
1280+
return $abs;
1281+
}
1282+
11391283
/**
11401284
* Project the originating site's composed AGENTS.md into OpenCode discovery.
11411285
*
@@ -1327,6 +1471,38 @@ private static function project_site_agents_md_via_opencode_config( string $work
13271471
* @return array{success: bool, removed: string[]}
13281472
*/
13291473
public static function uninject( string $worktree_path ): array {
1474+
$removed = array();
1475+
1476+
foreach ( self::get_projection_cleanup_registry() as $entry ) {
1477+
if ( ! is_array($entry) ) {
1478+
continue;
1479+
}
1480+
1481+
if ( isset($entry['cleanup']) && is_callable($entry['cleanup']) ) {
1482+
$result = call_user_func($entry['cleanup'], $worktree_path, $entry);
1483+
if ( is_array($result) ) {
1484+
$removed = array_merge($removed, array_values($result));
1485+
}
1486+
}
1487+
1488+
if ( ! empty($entry['paths']) && is_array($entry['paths']) ) {
1489+
$removed = array_merge($removed, self::remove_projection_paths($worktree_path, $entry['paths']));
1490+
}
1491+
}
1492+
1493+
return array(
1494+
'success' => true,
1495+
'removed' => $removed,
1496+
);
1497+
}
1498+
1499+
/**
1500+
* Remove DMC-owned AGENTS.md projections from a worktree.
1501+
*
1502+
* @param string $worktree_path Worktree directory.
1503+
* @return string[] Removed absolute paths.
1504+
*/
1505+
private static function cleanup_site_agents_projection( string $worktree_path ): array {
13301506
$removed = array();
13311507
$projected_agents = rtrim($worktree_path, '/') . '/' . self::PROJECTED_AGENTS_PATH;
13321508
$projection_marker = rtrim($worktree_path, '/') . '/' . self::PROJECTED_AGENTS_MARKER_PATH;
@@ -1358,6 +1534,17 @@ public static function uninject( string $worktree_path ): array {
13581534
$removed[] = $projection_marker;
13591535
}
13601536

1537+
return $removed;
1538+
}
1539+
1540+
/**
1541+
* Restore or remove DMC-owned OpenCode config projections from a worktree.
1542+
*
1543+
* @param string $worktree_path Worktree directory.
1544+
* @return string[] Removed absolute paths.
1545+
*/
1546+
private static function cleanup_opencode_projection( string $worktree_path ): array {
1547+
$removed = array();
13611548
$opencode_config = rtrim($worktree_path, '/') . '/' . self::PROJECTED_OPENCODE_CONFIG_PATH;
13621549
$opencode_marker = rtrim($worktree_path, '/') . '/' . self::PROJECTED_OPENCODE_CONFIG_MARKER_PATH;
13631550
if ( is_file($opencode_marker) ) {
@@ -1376,7 +1563,19 @@ public static function uninject( string $worktree_path ): array {
13761563
$removed[] = $opencode_marker;
13771564
}
13781565

1379-
foreach ( array_merge(self::INJECTED_PATHS, self::LEGACY_INJECTED_PATHS) as $relative ) {
1566+
return $removed;
1567+
}
1568+
1569+
/**
1570+
* Remove relative projection paths from a worktree.
1571+
*
1572+
* @param string $worktree_path Worktree directory.
1573+
* @param string[] $paths Relative paths to remove.
1574+
* @return string[] Removed absolute paths.
1575+
*/
1576+
private static function remove_projection_paths( string $worktree_path, array $paths ): array {
1577+
$removed = array();
1578+
foreach ( $paths as $relative ) {
13801579
$abs = rtrim($worktree_path, '/') . '/' . $relative;
13811580
if ( is_file($abs) ) {
13821581
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes DMC-injected local-only context files from a worktree.
@@ -1385,10 +1584,7 @@ public static function uninject( string $worktree_path ): array {
13851584
}
13861585
}
13871586

1388-
return array(
1389-
'success' => true,
1390-
'removed' => $removed,
1391-
);
1587+
return $removed;
13921588
}
13931589

13941590
/**

0 commit comments

Comments
 (0)