Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/worktree-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Worktree Bootstrap

DMC bootstraps the worktree root and ordinary one-level monorepo dependency
roots detected from lockfiles. Git submodule roots are excluded by default:
they are independent repositories with their own dependency lifecycle.

To deliberately let DMC bootstrap a submodule dependency root, commit this
superproject-owned contract:

```json
{
"submodule_dependency_roots": ["vendor/example package"]
}
```

Save it as `.datamachine/worktree-bootstrap.json`. Entries are relative paths
and only take effect when the path is also declared in `.gitmodules`. Bootstrap
evidence reports every package-shaped submodule root skipped by the default
boundary policy in `skipped_package_roots`.
4 changes: 2 additions & 2 deletions inc/Abilities/WorkspaceAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -1295,7 +1295,7 @@ private function registerAbilities(): void {
'datamachine-code/workspace-worktree-add',
array(
'label' => 'Add Workspace Worktree',
'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.',
'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.',
'category' => 'datamachine-code-workspace',
'input_schema' => array(
'type' => 'object',
Expand All @@ -1318,7 +1318,7 @@ private function registerAbilities(): void {
),
'bootstrap' => array(
'type' => 'boolean',
'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).',
'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).',
),
'allow_stale' => array(
'type' => 'boolean',
Expand Down
3 changes: 2 additions & 1 deletion inc/Workspace/WorkspaceWorktreeLifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ trait WorkspaceWorktreeLifecycle {
* When `$bootstrap` is true (default), a bootstrap pass runs after the
* worktree is created: `git submodule update --init --recursive` if
* `.gitmodules` is present, package-manager installs for root or one-level
* nested dependency roots with lockfiles (pnpm/bun/yarn/npm), and
* nested dependency roots with lockfiles (pnpm/bun/yarn/npm; submodule roots
* are excluded unless `.datamachine/worktree-bootstrap.json` opts them in), and
* `composer install` for root or one-level nested dependency roots with
* `composer.lock`. Steps are independent and each one is skipped gracefully
* when its tool is unavailable. A failing step is surfaced in the result
Expand Down
112 changes: 99 additions & 13 deletions inc/Workspace/WorktreeBootstrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@
* if `composer.lock` exists
*
* Dependency roots include the worktree root plus one-level child directories
* with lockfiles. This covers lightweight monorepos where each top-level
* component is installable on its own without requiring repo-specific config.
* with lockfiles. Git submodule roots are excluded: they own independent
* dependency lifecycles. A repository may explicitly opt a submodule in via
* `.datamachine/worktree-bootstrap.json`; see `submodule_dependency_roots`.
*
* Each step is optional. Missing binaries (no `pnpm` on PATH, etc.) downgrade
* to a `skipped` result rather than failing. Command failures are returned as
Expand Down Expand Up @@ -91,13 +92,17 @@ final class WorktreeBootstrapper {
'vendor',
);

/** Repository-owned opt-in contract for submodule dependency roots. */
private const SUBMODULE_BOOTSTRAP_CONFIG = '.datamachine/worktree-bootstrap.json';

/**
* Run all applicable bootstrap steps inside the given worktree.
*
* @param string $worktree_path Absolute path to the worktree root.
* @return array{
* success: bool,
* ran_any: bool,
* skipped_package_roots: array<int, array{relative: string, manager: string, reason: string}>,
* steps: array<int, array{
* step: string,
* status: string,
Expand All @@ -109,10 +114,11 @@ final class WorktreeBootstrapper {
* }
*/
public static function bootstrap( string $worktree_path ): array {
$steps = array();
$package_discovery = self::discover_package_roots($worktree_path);
$steps = array();

$steps[] = self::run_submodules($worktree_path);
$steps = array_merge($steps, self::run_packages($worktree_path));
$steps = array_merge($steps, self::run_packages($package_discovery['roots']));
$steps = array_merge($steps, self::run_composer($worktree_path));

$failed = array_filter($steps, fn( $s ) => self::STATUS_FAILED === ( $s['status'] ?? '' ));
Expand All @@ -121,6 +127,7 @@ public static function bootstrap( string $worktree_path ): array {
return array(
'success' => empty($failed),
'ran_any' => $ran_any,
'skipped_package_roots' => $package_discovery['skipped'],
'steps' => $steps,
);
}
Expand All @@ -135,18 +142,20 @@ public static function bootstrap( string $worktree_path ): array {
* packages: ?string, // Root package manager slug or null.
* composer: bool,
* package_roots: array<int, array{path: string, relative: string, manager: string}>,
* skipped_package_roots: array<int, array{relative: string, manager: string, reason: string}>,
* composer_roots: array<int, array{path: string, relative: string}>,
* }
*/
public static function detect( string $worktree_path ): array {
$package_roots = self::discover_package_roots($worktree_path);
$package_discovery = self::discover_package_roots($worktree_path);
$composer_roots = self::discover_composer_roots($worktree_path);

return array(
'submodules' => is_file(rtrim($worktree_path, '/') . '/.gitmodules'),
'packages' => self::detect_package_manager($worktree_path),
'composer' => is_file(rtrim($worktree_path, '/') . '/composer.lock'),
'package_roots' => $package_roots,
'package_roots' => $package_discovery['roots'],
'skipped_package_roots' => $package_discovery['skipped'],
'composer_roots' => $composer_roots,
);
}
Expand Down Expand Up @@ -226,8 +235,7 @@ private static function run_submodules( string $worktree_path ): array {
/**
* Run the detected package manager's install command, if any.
*/
private static function run_packages( string $worktree_path ): array {
$roots = self::discover_package_roots($worktree_path);
private static function run_packages( array $roots ): array {
if ( empty($roots) ) {
return array(
array(
Expand Down Expand Up @@ -317,22 +325,34 @@ private static function run_composer( string $worktree_path ): array {
/**
* Discover package-manager roots at the repo root and one directory deep.
*
* @return array<int, array{path: string, relative: string, manager: string}>
* @return array{roots: array<int, array{path: string, relative: string, manager: string}>, skipped: array<int, array{relative: string, manager: string, reason: string}>}
*/
private static function discover_package_roots( string $worktree_path ): array {
$roots = array();
$skipped = array();
foreach ( self::candidate_dependency_roots($worktree_path) as $candidate ) {
$manager = self::detect_package_manager($candidate['path']);
if ( null === $manager ) {
continue;
}
if ( ! empty($candidate['submodule']) && empty($candidate['submodule_opted_in']) ) {
$skipped[] = array(
'relative' => $candidate['relative'],
'manager' => $manager,
'reason' => 'git submodule dependency root is excluded by default',
);
continue;
}
$roots[] = array(
'path' => $candidate['path'],
'relative' => $candidate['relative'],
'manager' => $manager,
);
}
return $roots;
return array(
'roots' => $roots,
'skipped' => $skipped,
);
}

/**
Expand All @@ -343,6 +363,9 @@ private static function discover_package_roots( string $worktree_path ): array {
private static function discover_composer_roots( string $worktree_path ): array {
$roots = array();
foreach ( self::candidate_dependency_roots($worktree_path) as $candidate ) {
if ( ! empty($candidate['submodule']) && empty($candidate['submodule_opted_in']) ) {
continue;
}
if ( ! is_file($candidate['path'] . '/composer.lock') ) {
continue;
}
Expand All @@ -354,10 +377,12 @@ private static function discover_composer_roots( string $worktree_path ): array
/**
* Return the repo root plus one-level child directories that may own deps.
*
* @return array<int, array{path: string, relative: string}>
* @return array<int, array{path: string, relative: string, submodule?: bool, submodule_opted_in?: bool}>
*/
private static function candidate_dependency_roots( string $worktree_path ): array {
$root = rtrim($worktree_path, '/');
$submodules = self::submodule_paths($root);
$opted_in = self::opted_in_submodule_dependency_roots($root);
$candidates = array(
array(
'path' => $root,
Expand All @@ -380,14 +405,75 @@ private static function candidate_dependency_roots( string $worktree_path ): arr
continue;
}
$candidates[] = array(
'path' => $path,
'relative' => $entry,
'path' => $path,
'relative' => $entry,
'submodule' => isset($submodules[$entry]),
'submodule_opted_in' => isset($opted_in[$entry]),
);
}

return $candidates;
}

/**
* Read declared submodule paths without depending on an initialized checkout.
*
* `.gitmodules` is tracked by the superproject and is available even when a
* submodule's pinned commit cannot be fetched or checked out.
*
* @return array<string, true> Relative submodule paths keyed by path.
*/
private static function submodule_paths( string $worktree_path ): array {
$gitmodules = $worktree_path . '/.gitmodules';
if ( ! is_file($gitmodules) ) {
return array();
}

// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid or unreadable declarations simply provide no boundaries.
$sections = @parse_ini_file($gitmodules, true, INI_SCANNER_RAW);
if ( ! is_array($sections) ) {
return array();
}

$paths = array();
foreach ( $sections as $section ) {
$path = is_array($section) ? (string) ($section['path'] ?? '') : '';
$path = trim($path, '/');
if ( '' !== $path && ! str_contains($path, '..') ) {
$paths[$path] = true;
}
}
return $paths;
}

/**
* Read the explicit repository contract for submodules DMC may bootstrap.
*
* @return array<string, true> Relative submodule paths keyed by path.
*/
private static function opted_in_submodule_dependency_roots( string $worktree_path ): array {
$config = $worktree_path . '/' . self::SUBMODULE_BOOTSTRAP_CONFIG;
if ( ! is_file($config) ) {
return array();
}

$decoded = json_decode((string) file_get_contents($config), true);
$declared = is_array($decoded) && is_array($decoded['submodule_dependency_roots'] ?? null)
? $decoded['submodule_dependency_roots']
: array();
$paths = array();
foreach ( $declared as $path ) {
if ( ! is_string($path) ) {
continue;
}
$path = trim($path, '/');
if ( '' !== $path && ! str_contains($path, '..') ) {
$paths[$path] = true;
}
}
return $paths;
}

/**
* Detect the active package manager for a checkout based on lockfile
* presence. Order: pnpm > bun > yarn > npm. A repo with multiple lockfiles
Expand Down
91 changes: 91 additions & 0 deletions tests/worktree-bootstrap-submodules.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

if ( ! defined('ABSPATH') ) {
define('ABSPATH', __DIR__ . '/fixtures/');
}

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_message(): string { return $this->message; }
public function get_error_data(): array { return $this->data; }
}
}

require_once dirname(__DIR__) . '/inc/Workspace/WorktreeBootstrapper.php';

use DataMachineCode\Workspace\WorktreeBootstrapper;

function worktree_bootstrap_submodules_assert_same( mixed $expected, mixed $actual, string $message ): void {
if ( $expected !== $actual ) {
throw new RuntimeException(sprintf('%s Expected %s, got %s.', $message, var_export($expected, true), var_export($actual, true)));
}
}

function worktree_bootstrap_submodules_write( string $path, string $contents = '' ): void {
if ( ! is_dir(dirname($path)) && ! mkdir(dirname($path), 0777, true) && ! is_dir(dirname($path)) ) {
throw new RuntimeException(sprintf('Unable to create fixture directory for %s.', $path));
}
file_put_contents($path, $contents);
}

$base = sys_get_temp_dir() . '/dmc-bootstrap-submodules-' . bin2hex(random_bytes(6));

$submodule_fixture = $base . '/submodule-fixture';
worktree_bootstrap_submodules_write($submodule_fixture . '/package-lock.json', '{}');
worktree_bootstrap_submodules_write(
$submodule_fixture . '/.gitmodules',
"[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"
);
worktree_bootstrap_submodules_write($submodule_fixture . '/with space/package-lock.json', '{}');

$default = WorktreeBootstrapper::detect($submodule_fixture);
worktree_bootstrap_submodules_assert_same(true, $default['submodules'], 'Declared but uninitialized submodules remain visible to package discovery.');
worktree_bootstrap_submodules_assert_same(
array( array( 'path' => $submodule_fixture, 'relative' => '.', 'manager' => 'npm' ) ),
$default['package_roots'],
'Default discovery selects only the superproject package.'
);
worktree_bootstrap_submodules_assert_same(
array(
array(
'relative' => 'with space',
'manager' => 'npm',
'reason' => 'git submodule dependency root is excluded by default',
),
),
$default['skipped_package_roots'],
'Submodule package roots with spaces are retained as structured skip evidence.'
);

worktree_bootstrap_submodules_write(
$submodule_fixture . '/.datamachine/worktree-bootstrap.json',
"{\n \"submodule_dependency_roots\": [\"with space\"]\n}\n"
);
$opted_in = WorktreeBootstrapper::detect($submodule_fixture);
worktree_bootstrap_submodules_assert_same(
array(
array( 'path' => $submodule_fixture, 'relative' => '.', 'manager' => 'npm' ),
array( 'path' => $submodule_fixture . '/with space', 'relative' => 'with space', 'manager' => 'npm' ),
),
$opted_in['package_roots'],
'Explicit superproject contract enables discovery of the declared submodule package.'
);
worktree_bootstrap_submodules_assert_same(array(), $opted_in['skipped_package_roots'], 'Opted-in submodule is not reported as skipped.');

$monorepo_fixture = $base . '/monorepo-fixture';
worktree_bootstrap_submodules_write($monorepo_fixture . '/package-lock.json', '{}');
worktree_bootstrap_submodules_write($monorepo_fixture . '/ordinary/package-lock.json', '{}');
$monorepo = WorktreeBootstrapper::detect($monorepo_fixture);
worktree_bootstrap_submodules_assert_same(
array(
array( 'path' => $monorepo_fixture, 'relative' => '.', 'manager' => 'npm' ),
array( 'path' => $monorepo_fixture . '/ordinary', 'relative' => 'ordinary', 'manager' => 'npm' ),
),
$monorepo['package_roots'],
'Ordinary nested monorepo packages remain discoverable.'
);

echo "worktree-bootstrap-submodules: ok\n";
Loading