Skip to content

Commit 0cb67ad

Browse files
fix: exclude submodules from package bootstrap
1 parent 91a89e2 commit 0cb67ad

5 files changed

Lines changed: 213 additions & 16 deletions

File tree

docs/worktree-bootstrap.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Worktree Bootstrap
2+
3+
DMC bootstraps the worktree root and ordinary one-level monorepo dependency
4+
roots detected from lockfiles. Git submodule roots are excluded by default:
5+
they are independent repositories with their own dependency lifecycle.
6+
7+
To deliberately let DMC bootstrap a submodule dependency root, commit this
8+
superproject-owned contract:
9+
10+
```json
11+
{
12+
"submodule_dependency_roots": ["vendor/example package"]
13+
}
14+
```
15+
16+
Save it as `.datamachine/worktree-bootstrap.json`. Entries are relative paths
17+
and only take effect when the path is also declared in `.gitmodules`. Bootstrap
18+
evidence reports every package-shaped submodule root skipped by the default
19+
boundary policy in `skipped_package_roots`.

inc/Abilities/WorkspaceAbilities.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,7 +1295,7 @@ private function registerAbilities(): void {
12951295
'datamachine-code/workspace-worktree-add',
12961296
array(
12971297
'label' => 'Add Workspace Worktree',
1298-
'description' => 'Create a git worktree for a branch under `<repo>@<branch-slug>`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally. Creation fails closed when remote freshness cannot be verified; set `allow_unverified_freshness=true` only for intentional offline work. When `inject_context` is true (default), the originating site\'s composed AGENTS.md is made visible to OpenCode: symlinked into the worktree root when no repo-owned AGENTS.md exists, otherwise added via local OpenCode instructions so both files load. Site agent memory is snapshotted into `.claude/CLAUDE.local.md`, and injected paths are added to the worktree\'s per-checkout `info/exclude`. When `bootstrap` is true (default), submodule init plus root or one-level nested package-manager/composer installs run after creation so the worktree is immediately test/build-ready; set false to create a bare checkout.',
1298+
'description' => 'Create a git worktree for a branch under `<repo>@<branch-slug>`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally. Creation fails closed when remote freshness cannot be verified; set `allow_unverified_freshness=true` only for intentional offline work. When `inject_context` is true (default), the originating site\'s composed AGENTS.md is made visible to OpenCode: symlinked into the worktree root when no repo-owned AGENTS.md exists, otherwise added via local OpenCode instructions so both files load. Site agent memory is snapshotted into `.claude/CLAUDE.local.md`, and injected paths are added to the worktree\'s per-checkout `info/exclude`. When `bootstrap` is true (default), submodule init plus root or one-level nested package-manager/composer installs run after creation. Git submodule package roots are excluded unless `.datamachine/worktree-bootstrap.json` explicitly opts them in; set false to create a bare checkout.',
12991299
'category' => 'datamachine-code-workspace',
13001300
'input_schema' => array(
13011301
'type' => 'object',
@@ -1318,7 +1318,7 @@ private function registerAbilities(): void {
13181318
),
13191319
'bootstrap' => array(
13201320
'type' => 'boolean',
1321-
'description' => 'Run detected bootstrap steps (submodule init plus root or one-level nested package-manager/composer installs) after creating the worktree. Default true. Steps are skipped gracefully when their trigger file or tool is missing. Set false for a bare checkout (e.g. when only reading code).',
1321+
'description' => 'Run detected bootstrap steps (submodule init plus root or one-level nested package-manager/composer installs) after creating the worktree. Git submodule package roots are excluded unless `.datamachine/worktree-bootstrap.json` explicitly opts them in. Default true. Steps are skipped gracefully when their trigger file or tool is missing. Set false for a bare checkout (e.g. when only reading code).',
13221322
),
13231323
'allow_stale' => array(
13241324
'type' => 'boolean',

inc/Workspace/WorkspaceWorktreeLifecycle.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ trait WorkspaceWorktreeLifecycle {
3535
* When `$bootstrap` is true (default), a bootstrap pass runs after the
3636
* worktree is created: `git submodule update --init --recursive` if
3737
* `.gitmodules` is present, package-manager installs for root or one-level
38-
* nested dependency roots with lockfiles (pnpm/bun/yarn/npm), and
38+
* nested dependency roots with lockfiles (pnpm/bun/yarn/npm; submodule roots
39+
* are excluded unless `.datamachine/worktree-bootstrap.json` opts them in), and
3940
* `composer install` for root or one-level nested dependency roots with
4041
* `composer.lock`. Steps are independent and each one is skipped gracefully
4142
* when its tool is unavailable. A failing step is surfaced in the result

inc/Workspace/WorktreeBootstrapper.php

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
* if `composer.lock` exists
2727
*
2828
* Dependency roots include the worktree root plus one-level child directories
29-
* with lockfiles. This covers lightweight monorepos where each top-level
30-
* component is installable on its own without requiring repo-specific config.
29+
* with lockfiles. Git submodule roots are excluded: they own independent
30+
* dependency lifecycles. A repository may explicitly opt a submodule in via
31+
* `.datamachine/worktree-bootstrap.json`; see `submodule_dependency_roots`.
3132
*
3233
* Each step is optional. Missing binaries (no `pnpm` on PATH, etc.) downgrade
3334
* to a `skipped` result rather than failing. Command failures are returned as
@@ -91,13 +92,17 @@ final class WorktreeBootstrapper {
9192
'vendor',
9293
);
9394

95+
/** Repository-owned opt-in contract for submodule dependency roots. */
96+
private const SUBMODULE_BOOTSTRAP_CONFIG = '.datamachine/worktree-bootstrap.json';
97+
9498
/**
9599
* Run all applicable bootstrap steps inside the given worktree.
96100
*
97101
* @param string $worktree_path Absolute path to the worktree root.
98102
* @return array{
99103
* success: bool,
100104
* ran_any: bool,
105+
* skipped_package_roots: array<int, array{relative: string, manager: string, reason: string}>,
101106
* steps: array<int, array{
102107
* step: string,
103108
* status: string,
@@ -109,10 +114,11 @@ final class WorktreeBootstrapper {
109114
* }
110115
*/
111116
public static function bootstrap( string $worktree_path ): array {
112-
$steps = array();
117+
$package_discovery = self::discover_package_roots($worktree_path);
118+
$steps = array();
113119

114120
$steps[] = self::run_submodules($worktree_path);
115-
$steps = array_merge($steps, self::run_packages($worktree_path));
121+
$steps = array_merge($steps, self::run_packages($package_discovery['roots']));
116122
$steps = array_merge($steps, self::run_composer($worktree_path));
117123

118124
$failed = array_filter($steps, fn( $s ) => self::STATUS_FAILED === ( $s['status'] ?? '' ));
@@ -121,6 +127,7 @@ public static function bootstrap( string $worktree_path ): array {
121127
return array(
122128
'success' => empty($failed),
123129
'ran_any' => $ran_any,
130+
'skipped_package_roots' => $package_discovery['skipped'],
124131
'steps' => $steps,
125132
);
126133
}
@@ -135,18 +142,20 @@ public static function bootstrap( string $worktree_path ): array {
135142
* packages: ?string, // Root package manager slug or null.
136143
* composer: bool,
137144
* package_roots: array<int, array{path: string, relative: string, manager: string}>,
145+
* skipped_package_roots: array<int, array{relative: string, manager: string, reason: string}>,
138146
* composer_roots: array<int, array{path: string, relative: string}>,
139147
* }
140148
*/
141149
public static function detect( string $worktree_path ): array {
142-
$package_roots = self::discover_package_roots($worktree_path);
150+
$package_discovery = self::discover_package_roots($worktree_path);
143151
$composer_roots = self::discover_composer_roots($worktree_path);
144152

145153
return array(
146154
'submodules' => is_file(rtrim($worktree_path, '/') . '/.gitmodules'),
147155
'packages' => self::detect_package_manager($worktree_path),
148156
'composer' => is_file(rtrim($worktree_path, '/') . '/composer.lock'),
149-
'package_roots' => $package_roots,
157+
'package_roots' => $package_discovery['roots'],
158+
'skipped_package_roots' => $package_discovery['skipped'],
150159
'composer_roots' => $composer_roots,
151160
);
152161
}
@@ -226,8 +235,7 @@ private static function run_submodules( string $worktree_path ): array {
226235
/**
227236
* Run the detected package manager's install command, if any.
228237
*/
229-
private static function run_packages( string $worktree_path ): array {
230-
$roots = self::discover_package_roots($worktree_path);
238+
private static function run_packages( array $roots ): array {
231239
if ( empty($roots) ) {
232240
return array(
233241
array(
@@ -317,22 +325,34 @@ private static function run_composer( string $worktree_path ): array {
317325
/**
318326
* Discover package-manager roots at the repo root and one directory deep.
319327
*
320-
* @return array<int, array{path: string, relative: string, manager: string}>
328+
* @return array{roots: array<int, array{path: string, relative: string, manager: string}>, skipped: array<int, array{relative: string, manager: string, reason: string}>}
321329
*/
322330
private static function discover_package_roots( string $worktree_path ): array {
323331
$roots = array();
332+
$skipped = array();
324333
foreach ( self::candidate_dependency_roots($worktree_path) as $candidate ) {
325334
$manager = self::detect_package_manager($candidate['path']);
326335
if ( null === $manager ) {
327336
continue;
328337
}
338+
if ( ! empty($candidate['submodule']) && empty($candidate['submodule_opted_in']) ) {
339+
$skipped[] = array(
340+
'relative' => $candidate['relative'],
341+
'manager' => $manager,
342+
'reason' => 'git submodule dependency root is excluded by default',
343+
);
344+
continue;
345+
}
329346
$roots[] = array(
330347
'path' => $candidate['path'],
331348
'relative' => $candidate['relative'],
332349
'manager' => $manager,
333350
);
334351
}
335-
return $roots;
352+
return array(
353+
'roots' => $roots,
354+
'skipped' => $skipped,
355+
);
336356
}
337357

338358
/**
@@ -343,6 +363,9 @@ private static function discover_package_roots( string $worktree_path ): array {
343363
private static function discover_composer_roots( string $worktree_path ): array {
344364
$roots = array();
345365
foreach ( self::candidate_dependency_roots($worktree_path) as $candidate ) {
366+
if ( ! empty($candidate['submodule']) && empty($candidate['submodule_opted_in']) ) {
367+
continue;
368+
}
346369
if ( ! is_file($candidate['path'] . '/composer.lock') ) {
347370
continue;
348371
}
@@ -354,10 +377,12 @@ private static function discover_composer_roots( string $worktree_path ): array
354377
/**
355378
* Return the repo root plus one-level child directories that may own deps.
356379
*
357-
* @return array<int, array{path: string, relative: string}>
380+
* @return array<int, array{path: string, relative: string, submodule?: bool, submodule_opted_in?: bool}>
358381
*/
359382
private static function candidate_dependency_roots( string $worktree_path ): array {
360383
$root = rtrim($worktree_path, '/');
384+
$submodules = self::submodule_paths($root);
385+
$opted_in = self::opted_in_submodule_dependency_roots($root);
361386
$candidates = array(
362387
array(
363388
'path' => $root,
@@ -380,14 +405,75 @@ private static function candidate_dependency_roots( string $worktree_path ): arr
380405
continue;
381406
}
382407
$candidates[] = array(
383-
'path' => $path,
384-
'relative' => $entry,
408+
'path' => $path,
409+
'relative' => $entry,
410+
'submodule' => isset($submodules[$entry]),
411+
'submodule_opted_in' => isset($opted_in[$entry]),
385412
);
386413
}
387414

388415
return $candidates;
389416
}
390417

418+
/**
419+
* Read declared submodule paths without depending on an initialized checkout.
420+
*
421+
* `.gitmodules` is tracked by the superproject and is available even when a
422+
* submodule's pinned commit cannot be fetched or checked out.
423+
*
424+
* @return array<string, true> Relative submodule paths keyed by path.
425+
*/
426+
private static function submodule_paths( string $worktree_path ): array {
427+
$gitmodules = $worktree_path . '/.gitmodules';
428+
if ( ! is_file($gitmodules) ) {
429+
return array();
430+
}
431+
432+
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid or unreadable declarations simply provide no boundaries.
433+
$sections = @parse_ini_file($gitmodules, true, INI_SCANNER_RAW);
434+
if ( ! is_array($sections) ) {
435+
return array();
436+
}
437+
438+
$paths = array();
439+
foreach ( $sections as $section ) {
440+
$path = is_array($section) ? (string) ($section['path'] ?? '') : '';
441+
$path = trim($path, '/');
442+
if ( '' !== $path && ! str_contains($path, '..') ) {
443+
$paths[$path] = true;
444+
}
445+
}
446+
return $paths;
447+
}
448+
449+
/**
450+
* Read the explicit repository contract for submodules DMC may bootstrap.
451+
*
452+
* @return array<string, true> Relative submodule paths keyed by path.
453+
*/
454+
private static function opted_in_submodule_dependency_roots( string $worktree_path ): array {
455+
$config = $worktree_path . '/' . self::SUBMODULE_BOOTSTRAP_CONFIG;
456+
if ( ! is_file($config) ) {
457+
return array();
458+
}
459+
460+
$decoded = json_decode((string) file_get_contents($config), true);
461+
$declared = is_array($decoded) && is_array($decoded['submodule_dependency_roots'] ?? null)
462+
? $decoded['submodule_dependency_roots']
463+
: array();
464+
$paths = array();
465+
foreach ( $declared as $path ) {
466+
if ( ! is_string($path) ) {
467+
continue;
468+
}
469+
$path = trim($path, '/');
470+
if ( '' !== $path && ! str_contains($path, '..') ) {
471+
$paths[$path] = true;
472+
}
473+
}
474+
return $paths;
475+
}
476+
391477
/**
392478
* Detect the active package manager for a checkout based on lockfile
393479
* presence. Order: pnpm > bun > yarn > npm. A repo with multiple lockfiles
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
if ( ! defined('ABSPATH') ) {
6+
define('ABSPATH', __DIR__ . '/fixtures/');
7+
}
8+
9+
if ( ! class_exists('WP_Error') ) {
10+
class WP_Error {
11+
public function __construct( private string $code = '', private string $message = '', private array $data = array() ) {}
12+
public function get_error_message(): string { return $this->message; }
13+
public function get_error_data(): array { return $this->data; }
14+
}
15+
}
16+
17+
require_once dirname(__DIR__) . '/inc/Workspace/WorktreeBootstrapper.php';
18+
19+
use DataMachineCode\Workspace\WorktreeBootstrapper;
20+
21+
function worktree_bootstrap_submodules_assert_same( mixed $expected, mixed $actual, string $message ): void {
22+
if ( $expected !== $actual ) {
23+
throw new RuntimeException(sprintf('%s Expected %s, got %s.', $message, var_export($expected, true), var_export($actual, true)));
24+
}
25+
}
26+
27+
function worktree_bootstrap_submodules_write( string $path, string $contents = '' ): void {
28+
if ( ! is_dir(dirname($path)) && ! mkdir(dirname($path), 0777, true) && ! is_dir(dirname($path)) ) {
29+
throw new RuntimeException(sprintf('Unable to create fixture directory for %s.', $path));
30+
}
31+
file_put_contents($path, $contents);
32+
}
33+
34+
$base = sys_get_temp_dir() . '/dmc-bootstrap-submodules-' . bin2hex(random_bytes(6));
35+
36+
$submodule_fixture = $base . '/submodule-fixture';
37+
worktree_bootstrap_submodules_write($submodule_fixture . '/package-lock.json', '{}');
38+
worktree_bootstrap_submodules_write(
39+
$submodule_fixture . '/.gitmodules',
40+
"[submodule \"space\"]\n\tpath = with space\n\turl = https://example.invalid/space.git\n[submodule \"uninitialized\"]\n\tpath = missing commit\n\turl = https://example.invalid/missing.git\n"
41+
);
42+
worktree_bootstrap_submodules_write($submodule_fixture . '/with space/package-lock.json', '{}');
43+
44+
$default = WorktreeBootstrapper::detect($submodule_fixture);
45+
worktree_bootstrap_submodules_assert_same(true, $default['submodules'], 'Declared but uninitialized submodules remain visible to package discovery.');
46+
worktree_bootstrap_submodules_assert_same(
47+
array( array( 'path' => $submodule_fixture, 'relative' => '.', 'manager' => 'npm' ) ),
48+
$default['package_roots'],
49+
'Default discovery selects only the superproject package.'
50+
);
51+
worktree_bootstrap_submodules_assert_same(
52+
array(
53+
array(
54+
'relative' => 'with space',
55+
'manager' => 'npm',
56+
'reason' => 'git submodule dependency root is excluded by default',
57+
),
58+
),
59+
$default['skipped_package_roots'],
60+
'Submodule package roots with spaces are retained as structured skip evidence.'
61+
);
62+
63+
worktree_bootstrap_submodules_write(
64+
$submodule_fixture . '/.datamachine/worktree-bootstrap.json',
65+
"{\n \"submodule_dependency_roots\": [\"with space\"]\n}\n"
66+
);
67+
$opted_in = WorktreeBootstrapper::detect($submodule_fixture);
68+
worktree_bootstrap_submodules_assert_same(
69+
array(
70+
array( 'path' => $submodule_fixture, 'relative' => '.', 'manager' => 'npm' ),
71+
array( 'path' => $submodule_fixture . '/with space', 'relative' => 'with space', 'manager' => 'npm' ),
72+
),
73+
$opted_in['package_roots'],
74+
'Explicit superproject contract enables discovery of the declared submodule package.'
75+
);
76+
worktree_bootstrap_submodules_assert_same(array(), $opted_in['skipped_package_roots'], 'Opted-in submodule is not reported as skipped.');
77+
78+
$monorepo_fixture = $base . '/monorepo-fixture';
79+
worktree_bootstrap_submodules_write($monorepo_fixture . '/package-lock.json', '{}');
80+
worktree_bootstrap_submodules_write($monorepo_fixture . '/ordinary/package-lock.json', '{}');
81+
$monorepo = WorktreeBootstrapper::detect($monorepo_fixture);
82+
worktree_bootstrap_submodules_assert_same(
83+
array(
84+
array( 'path' => $monorepo_fixture, 'relative' => '.', 'manager' => 'npm' ),
85+
array( 'path' => $monorepo_fixture . '/ordinary', 'relative' => 'ordinary', 'manager' => 'npm' ),
86+
),
87+
$monorepo['package_roots'],
88+
'Ordinary nested monorepo packages remain discoverable.'
89+
);
90+
91+
echo "worktree-bootstrap-submodules: ok\n";

0 commit comments

Comments
 (0)