diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index ae1bf46..6a0be13 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -701,36 +701,47 @@ private function registerAbilities(): void { 'datamachine/workspace-worktree-add', array( 'label' => 'Add Workspace Worktree', - 'description' => 'Create a git worktree for a branch under `@`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally.', + 'description' => 'Create a git worktree for a branch under `@`. Branches are created off the supplied `from` ref (default `origin/HEAD`) when they do not yet exist locally. When `inject_context` is true (default), the originating site\'s agent memory is snapshotted into `.claude/CLAUDE.local.md` and `.opencode/AGENTS.local.md` and added to the worktree\'s per-checkout `info/exclude`.', 'category' => 'datamachine-code-workspace', 'input_schema' => array( 'type' => 'object', 'properties' => array( - 'repo' => array( + 'repo' => array( 'type' => 'string', 'description' => 'Primary repo name (no @-suffix).', ), - 'branch' => array( + 'branch' => array( 'type' => 'string', 'description' => 'Branch to check out in the worktree (e.g. fix/foo-bar). Slashes become dashes in the on-disk slug.', ), - 'from' => array( + 'from' => array( 'type' => 'string', 'description' => 'Base ref when creating the branch (default origin/HEAD).', ), + 'inject_context' => array( + 'type' => 'boolean', + 'description' => 'Inject the originating site\'s agent context (MEMORY.md, USER.md, RULES.md) into the new worktree. Default true. Set false to create a bare worktree.', + ), ), 'required' => array( 'repo', 'branch' ), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( - 'success' => array( 'type' => 'boolean' ), - 'handle' => array( 'type' => 'string' ), - 'path' => array( 'type' => 'string' ), - 'branch' => array( 'type' => 'string' ), - 'slug' => array( 'type' => 'string' ), - 'created_branch' => array( 'type' => 'boolean' ), - 'message' => array( 'type' => 'string' ), + 'success' => array( 'type' => 'boolean' ), + 'handle' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + 'branch' => array( 'type' => 'string' ), + 'slug' => array( 'type' => 'string' ), + 'created_branch' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'context_injected' => array( 'type' => 'boolean' ), + 'context_files' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'context_exclude_path' => array( 'type' => 'string' ), + 'context_skip_reason' => array( 'type' => 'string' ), ), ), 'execute_callback' => array( self::class, 'worktreeAdd' ), @@ -739,6 +750,43 @@ private function registerAbilities(): void { ) ); + wp_register_ability( + 'datamachine/workspace-worktree-refresh-context', + array( + 'label' => 'Refresh Worktree Context', + 'description' => 'Re-read the originating site\'s agent memory and rewrite the injected context files (`.claude/CLAUDE.local.md`, `.opencode/AGENTS.local.md`) in an existing worktree. Must be run from the site that created the worktree — cross-machine refresh is not supported.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'handle' => array( + 'type' => 'string', + 'description' => 'Worktree handle (`@`).', + ), + ), + 'required' => array( 'handle' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'handle' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + 'written' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + 'exclude_path' => array( 'type' => 'string' ), + 'metadata' => array( 'type' => 'object' ), + 'message' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( self::class, 'worktreeRefreshContext' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => false ), + ) + ); + wp_register_ability( 'datamachine/workspace-worktree-list', array( @@ -1126,13 +1174,27 @@ public static function gitPush( array $input ): array|\WP_Error { */ public static function worktreeAdd( array $input ): array|\WP_Error { $workspace = new Workspace(); + // Default inject_context=true; only false when explicitly provided. + $inject_context = array_key_exists( 'inject_context', $input ) ? (bool) $input['inject_context'] : true; return $workspace->worktree_add( $input['repo'] ?? '', $input['branch'] ?? '', - $input['from'] ?? null + $input['from'] ?? null, + $inject_context ); } + /** + * Refresh a worktree's injected context from the originating site. + * + * @param array $input Input parameters with 'handle'. + * @return array|\WP_Error + */ + public static function worktreeRefreshContext( array $input ): array|\WP_Error { + $workspace = new Workspace(); + return $workspace->worktree_refresh_context( $input['handle'] ?? '' ); + } + /** * List worktrees in the workspace. * diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 4a64003..b6ec28d 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -887,10 +887,11 @@ private function renderGitOperationResult( string $operation, array $result, arr * ## OPTIONS * * - * : Worktree operation: add, list, remove, prune, cleanup. + * : Worktree operation: add, list, remove, prune, cleanup, refresh-context. * * [] - * : Primary repo name (required for add and remove). + * : Primary repo name (required for add and remove). For refresh-context, + * pass the full worktree handle (`@`) here instead. * * [] * : Branch name (required for add and remove). @@ -898,6 +899,14 @@ private function renderGitOperationResult( string $operation, array $result, arr * [--from=] * : Base ref when creating a branch on add (default origin/HEAD). * + * [--skip-context-injection] + * : Skip injecting the originating site's agent context into a new + * worktree (applies to `add` only). Default behavior is to write + * `.claude/CLAUDE.local.md` and `.opencode/AGENTS.local.md` containing + * the site's MEMORY.md / USER.md / RULES.md snapshot, and add both + * paths to the repository's `info/exclude`. The ability-level input + * is `inject_context=false`; this flag is the CLI shorthand. + * * [--force] * : Force-remove a worktree even if it is dirty (applies to `remove` and * `cleanup`). Does NOT override the unpushed-commits safety in cleanup. @@ -948,23 +957,30 @@ private function renderGitOperationResult( string $operation, array $result, arr * # Ignore dirty working-tree safety (caution) * wp datamachine workspace worktree cleanup --force * + * # Create a worktree without injecting site-agent context + * wp datamachine workspace worktree add data-machine fix/foo --skip-context-injection + * + * # Re-read the originating site's agent memory into an existing worktree + * wp datamachine workspace worktree refresh-context data-machine@fix-foo + * * @subcommand worktree */ public function worktree( array $args, array $assoc_args ): void { $operation = $args[0] ?? ''; if ( '' === $operation ) { - WP_CLI::error( 'Usage: wp datamachine workspace worktree [] [] [--flags]' ); + WP_CLI::error( 'Usage: wp datamachine workspace worktree [] [] [--flags]' ); return; } $ability_name = match ( $operation ) { - 'add' => 'datamachine/workspace-worktree-add', - 'list' => 'datamachine/workspace-worktree-list', - 'remove' => 'datamachine/workspace-worktree-remove', - 'prune' => 'datamachine/workspace-worktree-prune', - 'cleanup' => 'datamachine/workspace-worktree-cleanup', - default => '', + 'add' => 'datamachine/workspace-worktree-add', + 'list' => 'datamachine/workspace-worktree-list', + 'remove' => 'datamachine/workspace-worktree-remove', + 'prune' => 'datamachine/workspace-worktree-prune', + 'cleanup' => 'datamachine/workspace-worktree-cleanup', + 'refresh-context' => 'datamachine/workspace-worktree-refresh-context', + default => '', }; if ( '' === $ability_name ) { @@ -983,7 +999,7 @@ public function worktree( array $args, array $assoc_args ): void { switch ( $operation ) { case 'add': if ( empty( $args[1] ) || empty( $args[2] ) ) { - WP_CLI::error( 'Usage: worktree add [--from=]' ); + WP_CLI::error( 'Usage: worktree add [--from=] [--skip-context-injection]' ); return; } $input['repo'] = $args[1]; @@ -991,6 +1007,16 @@ public function worktree( array $args, array $assoc_args ): void { if ( ! empty( $assoc_args['from'] ) ) { $input['from'] = (string) $assoc_args['from']; } + // --skip-context-injection disables the default-on injection step. + $input['inject_context'] = empty( $assoc_args['skip-context-injection'] ); + break; + + case 'refresh-context': + if ( empty( $args[1] ) ) { + WP_CLI::error( 'Usage: worktree refresh-context ' ); + return; + } + $input['handle'] = (string) $args[1]; break; case 'list': @@ -1118,6 +1144,36 @@ private function renderWorktreeResult( string $operation, array $result, array $ WP_CLI::log( sprintf( 'Path: %s', $result['path'] ?? '-' ) ); WP_CLI::log( sprintf( 'Branch: %s%s', $result['branch'] ?? '-', ! empty( $result['created_branch'] ) ? ' (created)' : '' ) ); } + if ( isset( $result['context_injected'] ) ) { + if ( ! empty( $result['context_injected'] ) ) { + $written = $result['context_files'] ?? array(); + WP_CLI::log( sprintf( 'Context: injected (%d file%s)', count( $written ), 1 === count( $written ) ? '' : 's' ) ); + foreach ( $written as $file ) { + WP_CLI::log( ' - ' . $file ); + } + if ( ! empty( $result['context_exclude_path'] ) ) { + WP_CLI::log( sprintf( 'Excluded via: %s', $result['context_exclude_path'] ) ); + } + } else { + $reason = $result['context_skip_reason'] ?? 'unknown'; + WP_CLI::log( sprintf( 'Context: not injected (%s)', $reason ) ); + } + } + return; + + case 'refresh-context': + WP_CLI::success( $result['message'] ?? 'Worktree context refreshed.' ); + WP_CLI::log( sprintf( 'Handle: %s', $result['handle'] ?? '-' ) ); + WP_CLI::log( sprintf( 'Path: %s', $result['path'] ?? '-' ) ); + foreach ( (array) ( $result['written'] ?? array() ) as $file ) { + WP_CLI::log( ' - ' . $file ); + } + if ( ! empty( $result['exclude_path'] ) ) { + WP_CLI::log( sprintf( 'Exclude file: %s', $result['exclude_path'] ) ); + } + if ( ! empty( $result['metadata']['site_url'] ) ) { + WP_CLI::log( sprintf( 'Originating site: %s', $result['metadata']['site_url'] ) ); + } return; case 'remove': diff --git a/inc/Workspace/Workspace.php b/inc/Workspace/Workspace.php index 657fa82..10cd84d 100644 --- a/inc/Workspace/Workspace.php +++ b/inc/Workspace/Workspace.php @@ -853,12 +853,21 @@ public function git_diff( string $name, ?string $from = null, ?string $to = null * `/` checked out to ``. If the branch does not * exist locally, it is created from `` (default `origin/HEAD`). * - * @param string $repo Primary repo name (no @-suffix). - * @param string $branch Branch to check out (e.g. "fix/foo-bar"). - * @param string|null $from Base ref when creating the branch. - * @return array{success: bool, handle: string, path: string, branch: string, slug: string, created_branch: bool, message: string}|\WP_Error + * When `$inject_context` is true (default) and Data Machine's agent memory + * layer is available, the originating site's MEMORY.md / USER.md / RULES.md + * are snapshotted into the new worktree as runtime-agnostic local-only + * files (`.claude/CLAUDE.local.md`, `.opencode/AGENTS.local.md`) and added + * to the worktree's per-checkout `info/exclude`. When the memory layer is + * absent the worktree is still created successfully; injection silently + * skips. + * + * @param string $repo Primary repo name (no @-suffix). + * @param string $branch Branch to check out (e.g. "fix/foo-bar"). + * @param string|null $from Base ref when creating the branch. + * @param bool $inject_context Whether to inject site-agent context (default true). + * @return array{success: bool, handle: string, path: string, branch: string, slug: string, created_branch: bool, message: string, context_injected?: bool, context_files?: string[], context_skip_reason?: string}|\WP_Error */ - public function worktree_add( string $repo, string $branch, ?string $from = null ): array|\WP_Error { + public function worktree_add( string $repo, string $branch, ?string $from = null, bool $inject_context = true ): array|\WP_Error { $repo = $this->sanitize_name( $repo ); $branch = trim( $branch ); @@ -907,7 +916,7 @@ public function worktree_add( string $repo, string $branch, ?string $from = null return $result; } - return array( + $response = array( 'success' => true, 'handle' => $wt_handle, 'path' => $wt_path, @@ -916,6 +925,95 @@ public function worktree_add( string $repo, string $branch, ?string $from = null 'created_branch' => $created_branch, 'message' => sprintf( 'Worktree "%s" added at %s (branch %s).', $wt_handle, $wt_path, $branch ), ); + + if ( ! $inject_context ) { + $response['context_injected'] = false; + $response['context_skip_reason'] = 'inject_context flag disabled'; + return $response; + } + + $payload = WorktreeContextInjector::build_payload(); + if ( null === $payload ) { + $response['context_injected'] = false; + $response['context_skip_reason'] = 'agent memory layer unavailable'; + return $response; + } + + $injection = WorktreeContextInjector::inject( $wt_path, $payload ); + if ( is_wp_error( $injection ) ) { + $response['context_injected'] = false; + $response['context_skip_reason'] = 'inject failed: ' . $injection->get_error_message(); + return $response; + } + + WorktreeContextInjector::store_metadata( $wt_handle, $payload ); + + $response['context_injected'] = true; + $response['context_files'] = $injection['written']; + if ( ! empty( $injection['exclude_path'] ) ) { + $response['context_exclude_path'] = $injection['exclude_path']; + } + + return $response; + } + + /** + * Rewrite a worktree's injected context files from the originating site's + * current memory state. + * + * Uses the site option snapshot stored at worktree-creation time for + * logging / diagnostics, then re-reads memory from the currently active + * Data Machine agent layer. Cross-machine refresh is deliberately not + * supported: callers must invoke this from the same site that created + * the worktree. + * + * @param string $handle Workspace handle (`@`). + * @return array{success: bool, handle: string, path: string, written: string[], exclude_path: ?string, metadata: ?array, message: string}|\WP_Error + */ + public function worktree_refresh_context( string $handle ): array|\WP_Error { + $parsed = $this->parse_handle( $handle ); + if ( ! $parsed['is_worktree'] ) { + return new \WP_Error( + 'not_a_worktree', + sprintf( 'Handle "%s" is a primary checkout, not a worktree. Context injection is worktree-only.', $handle ), + array( 'status' => 400 ) + ); + } + + $wt_path = $this->workspace_path . '/' . $parsed['dir_name']; + if ( ! is_dir( $wt_path ) ) { + return new \WP_Error( + 'worktree_not_found', + sprintf( 'Worktree "%s" does not exist on disk.', $parsed['dir_name'] ), + array( 'status' => 404 ) + ); + } + + $payload = WorktreeContextInjector::build_payload(); + if ( null === $payload ) { + return new \WP_Error( + 'agent_layer_unavailable', + 'Data Machine agent memory layer is not available — cannot refresh context. Ensure this command is run from the site that created the worktree.', + array( 'status' => 500 ) + ); + } + + $injection = WorktreeContextInjector::inject( $wt_path, $payload ); + if ( is_wp_error( $injection ) ) { + return $injection; + } + + WorktreeContextInjector::store_metadata( $parsed['dir_name'], $payload ); + + return array( + 'success' => true, + 'handle' => $parsed['dir_name'], + 'path' => $wt_path, + 'written' => $injection['written'], + 'exclude_path' => $injection['exclude_path'] ?? null, + 'metadata' => WorktreeContextInjector::get_metadata( $parsed['dir_name'] ), + 'message' => sprintf( 'Refreshed injected context in "%s" (%d file%s).', $parsed['dir_name'], count( $injection['written'] ), 1 === count( $injection['written'] ) ? '' : 's' ), + ); } /** @@ -1048,6 +1146,8 @@ public function worktree_remove( string $repo, string $branch, bool $force = fal return $result; } + WorktreeContextInjector::forget_metadata( $wt_handle ); + return array( 'success' => true, 'handle' => $wt_handle, @@ -1351,6 +1451,8 @@ private function remove_worktree_by_path( string $repo, string $branch, string $ exec( sprintf( 'rm -rf %s 2>&1', $escaped ) ); } + WorktreeContextInjector::forget_metadata( basename( $wt_path ) ); + return array( 'success' => true, 'handle' => basename( $wt_path ), diff --git a/inc/Workspace/WorktreeContextInjector.php b/inc/Workspace/WorktreeContextInjector.php new file mode 100644 index 0000000..455c5cc --- /dev/null +++ b/inc/Workspace/WorktreeContextInjector.php @@ -0,0 +1,460 @@ +/.claude/CLAUDE.local.md — Claude Code convention + * /.opencode/AGENTS.local.md — OpenCode convention + * + * Both files receive the same payload. They are ignored per-checkout via the + * worktree's `info/exclude` file, so the tracked `.gitignore` is never touched + * and other worktrees / the primary checkout are unaffected. + * + * Per-worktree metadata (`created_from_site`) is persisted in the + * `datamachine_worktree_metadata` site option so later `refresh-context` + * calls can resolve back to the originating site. + * + * Cross-machine federation is explicitly out of scope: the originating site + * is always the site invoking this code; if that site is not reachable + * (plugin removed, agent layer absent), injection and refresh become + * graceful no-ops. + * + * @package DataMachineCode\Workspace + * @since 0.8.0 + */ + +namespace DataMachineCode\Workspace; + +defined( 'ABSPATH' ) || exit; + +class WorktreeContextInjector { + + /** + * Site option key used to persist per-worktree metadata. + * + * Shape: array keyed by workspace handle. + */ + public const METADATA_OPTION = 'datamachine_worktree_metadata'; + + /** + * Files injected into the worktree, relative to the worktree root. + */ + public const INJECTED_PATHS = array( + '.claude/CLAUDE.local.md', + '.opencode/AGENTS.local.md', + ); + + /** + * Memory files snapshotted into the payload, in render order. + */ + private const MEMORY_FILES = array( 'MEMORY.md', 'USER.md', 'RULES.md' ); + + /** + * Build a payload capturing the originating site's agent context. + * + * Returns null when Data Machine's agent memory layer is unavailable + * (plugin inactive, running outside a WordPress context, etc.). Callers + * should treat null as "nothing to inject" — never as an error. + * + * @return array{ + * site_url: string, + * site_name: string, + * agent_slug: string, + * abspath: string, + * files: array, + * timestamp: string, + * }|null + */ + public static function build_payload(): ?array { + if ( ! class_exists( '\\DataMachine\\Core\\FilesRepository\\AgentMemory' ) ) { + return null; + } + if ( ! class_exists( '\\DataMachine\\Core\\FilesRepository\\DirectoryManager' ) ) { + return null; + } + + $dm = new \DataMachine\Core\FilesRepository\DirectoryManager(); + $user_id = $dm->get_effective_user_id( 0 ); + $agent_slug = $dm->resolve_agent_slug( array( 'user_id' => $user_id ) ); + + $files = array(); + foreach ( self::MEMORY_FILES as $filename ) { + $memory = new \DataMachine\Core\FilesRepository\AgentMemory( $user_id, 0, $filename ); + $result = $memory->get_all(); + if ( ! empty( $result['success'] ) && is_string( $result['content'] ?? null ) && '' !== trim( $result['content'] ) ) { + $files[ $filename ] = $result['content']; + } + } + + return array( + 'site_url' => function_exists( 'home_url' ) ? (string) home_url() : '', + 'site_name' => function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'name' ) : '', + 'agent_slug' => $agent_slug, + 'abspath' => defined( 'ABSPATH' ) ? rtrim( ABSPATH, '/' ) : '', + 'files' => $files, + 'timestamp' => gmdate( 'c' ), + ); + } + + /** + * Render a payload into the markdown body written to both injected files. + * + * @param array $payload Payload from {@see self::build_payload()}. + * @return string Markdown document. + */ + public static function render( array $payload ): string { + $site_name = trim( (string) ( $payload['site_name'] ?? '' ) ); + $site_url = (string) ( $payload['site_url'] ?? '' ); + $agent_slug = (string) ( $payload['agent_slug'] ?? '' ); + $abspath = (string) ( $payload['abspath'] ?? '' ); + $timestamp = (string) ( $payload['timestamp'] ?? gmdate( 'c' ) ); + $files = is_array( $payload['files'] ?? null ) ? $payload['files'] : array(); + + $heading = '' !== $site_name ? $site_name : ( '' !== $site_url ? $site_url : 'the originating site' ); + + $out = "# Injected context from {$heading}\n\n"; + $out .= "This worktree was created from the {$heading} WordPress site\n"; + $out .= "on {$timestamp}. The agent that created it has the following\n"; + $out .= "persistent context snapshotted below.\n\n"; + + foreach ( self::MEMORY_FILES as $filename ) { + if ( empty( $files[ $filename ] ) ) { + continue; + } + $body = rtrim( (string) $files[ $filename ], "\n" ); + $out .= "## {$filename}\n\n{$body}\n\n"; + } + + $out .= "## Fetching fresher context\n\n"; + $out .= "The source site has `studio wp` available. Run:\n\n"; + $out .= " studio wp datamachine agent read MEMORY.md\n"; + $out .= " studio wp datamachine agent search \n\n"; + $out .= "to pull updates that accumulated after this worktree was created.\n"; + $out .= "You can also rewrite these injected files in-place via:\n\n"; + $out .= " studio wp datamachine-code workspace worktree refresh-context \n\n"; + + $out .= "## Source site\n\n"; + $out .= '- Slug: ' . ( '' !== $agent_slug ? $agent_slug : '(unresolved)' ) . "\n"; + $out .= '- Site URL: ' . ( '' !== $site_url ? $site_url : '(unknown)' ) . "\n"; + $out .= '- Studio path: ' . ( '' !== $abspath ? $abspath : '(unknown)' ) . "\n"; + + return $out; + } + + /** + * Inject a rendered payload into the worktree filesystem. + * + * Idempotent: reruns overwrite the injected files and deduplicate the + * `info/exclude` entries. + * + * @param string $worktree_path Absolute path to the worktree directory. + * @param array $payload Payload from {@see self::build_payload()}. + * @return array{success: bool, written: string[], exclude_path: ?string, message?: string}|\WP_Error + */ + public static function inject( string $worktree_path, array $payload ): array|\WP_Error { + if ( '' === $worktree_path || ! is_dir( $worktree_path ) ) { + return new \WP_Error( + 'worktree_not_found', + sprintf( 'Worktree path does not exist: %s', $worktree_path ), + array( 'status' => 404 ) + ); + } + + $body = self::render( $payload ); + $written = array(); + + foreach ( self::INJECTED_PATHS as $relative ) { + $abs = rtrim( $worktree_path, '/' ) . '/' . $relative; + $dir = dirname( $abs ); + if ( ! is_dir( $dir ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + if ( ! wp_mkdir_p( $dir ) ) { + return new \WP_Error( + 'mkdir_failed', + sprintf( 'Failed to create directory: %s', $dir ), + array( 'status' => 500 ) + ); + } + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $bytes = file_put_contents( $abs, $body ); + if ( false === $bytes ) { + return new \WP_Error( + 'write_failed', + sprintf( 'Failed to write injected file: %s', $abs ), + array( 'status' => 500 ) + ); + } + $written[] = $abs; + } + + $exclude_path = self::append_exclude_entries( $worktree_path, self::INJECTED_PATHS ); + + return array( + 'success' => true, + 'written' => $written, + 'exclude_path' => $exclude_path, + ); + } + + /** + * Remove injected files from a worktree and best-effort strip the + * per-checkout exclude entries. Used by `--no-inject-context` reruns + * and by future un-inject flows. + * + * @param string $worktree_path Worktree directory. + * @return array{success: bool, removed: string[]} + */ + public static function uninject( string $worktree_path ): array { + $removed = array(); + + foreach ( self::INJECTED_PATHS as $relative ) { + $abs = rtrim( $worktree_path, '/' ) . '/' . $relative; + if ( is_file( $abs ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_unlink + unlink( $abs ); + $removed[] = $abs; + } + } + + return array( + 'success' => true, + 'removed' => $removed, + ); + } + + /** + * Persist `created_from_site` metadata for a worktree handle. + * + * @param string $handle Workspace handle. + * @param array $payload Payload from {@see self::build_payload()}. + */ + public static function store_metadata( string $handle, array $payload ): void { + if ( ! function_exists( 'get_option' ) || ! function_exists( 'update_option' ) ) { + return; + } + + $all = get_option( self::METADATA_OPTION, array() ); + if ( ! is_array( $all ) ) { + $all = array(); + } + + $all[ $handle ] = array( + 'site_url' => (string) ( $payload['site_url'] ?? '' ), + 'site_name' => (string) ( $payload['site_name'] ?? '' ), + 'agent_slug' => (string) ( $payload['agent_slug'] ?? '' ), + 'abspath' => (string) ( $payload['abspath'] ?? '' ), + 'created_at' => (string) ( $payload['timestamp'] ?? gmdate( 'c' ) ), + ); + + update_option( self::METADATA_OPTION, $all, false ); + } + + /** + * Fetch persisted metadata for a handle, or null if none stored. + * + * @param string $handle Workspace handle. + * @return array|null + */ + public static function get_metadata( string $handle ): ?array { + if ( ! function_exists( 'get_option' ) ) { + return null; + } + $all = get_option( self::METADATA_OPTION, array() ); + if ( ! is_array( $all ) || empty( $all[ $handle ] ) || ! is_array( $all[ $handle ] ) ) { + return null; + } + return $all[ $handle ]; + } + + /** + * Drop persisted metadata for a handle. Called when a worktree is removed. + * + * @param string $handle Workspace handle. + */ + public static function forget_metadata( string $handle ): void { + if ( ! function_exists( 'get_option' ) || ! function_exists( 'update_option' ) ) { + return; + } + $all = get_option( self::METADATA_OPTION, array() ); + if ( ! is_array( $all ) || ! array_key_exists( $handle, $all ) ) { + return; + } + unset( $all[ $handle ] ); + update_option( self::METADATA_OPTION, $all, false ); + } + + /** + * Resolve the `info/` directory used by git for this worktree's exclude + * file. + * + * Important subtlety: git's `info/exclude` is ALWAYS read from the + * repository's *common* git directory, not the per-worktree private + * gitdir. A worktree's `.git` file points at `/.git/worktrees/ + * /` but exclude patterns are resolved from `/.git/info/`. + * Writing to the per-worktree `info/exclude` is a silent no-op — git + * never reads it. See `man git-config` under GIT_COMMON_DIR. + * + * Consequence: the injected exclude entries are technically visible to + * every worktree + the primary checkout. In practice this is harmless — + * the patterns (`.claude/CLAUDE.local.md`, `.opencode/AGENTS.local.md`) + * only match files we deliberately create in worktrees via injection. + * No injected files exist in non-injected checkouts, so there is no + * behavior change there. + * + * @param string $worktree_path Worktree directory. + * @return string|null Info directory path, or null if the common git dir + * cannot be resolved. + */ + private static function resolve_info_dir( string $worktree_path ): ?string { + $common_dir = self::resolve_common_git_dir( $worktree_path ); + if ( null === $common_dir ) { + return null; + } + return $common_dir . '/info'; + } + + /** + * Resolve the repository's common git directory from a worktree path. + * + * For a worktree: + * `.git` is a file containing `gitdir: /.git/worktrees/`, + * and that per-worktree gitdir contains a `commondir` file with the + * path (relative or absolute) to the common `.git` dir. + * + * For a primary checkout: + * `.git` is itself a directory and is the common dir. + * + * @param string $worktree_path Worktree directory. + * @return string|null Absolute path to the common git directory. + */ + private static function resolve_common_git_dir( string $worktree_path ): ?string { + $dot_git = rtrim( $worktree_path, '/' ) . '/.git'; + + if ( is_dir( $dot_git ) ) { + $real = realpath( $dot_git ); + return false === $real ? null : $real; + } + + if ( ! is_file( $dot_git ) ) { + return null; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents( $dot_git ); + if ( false === $content ) { + return null; + } + + if ( ! preg_match( '/^gitdir:\s*(.+)$/m', $content, $matches ) ) { + return null; + } + + $gitdir = trim( $matches[1] ); + if ( '' === $gitdir ) { + return null; + } + + if ( ! str_starts_with( $gitdir, '/' ) ) { + $gitdir = rtrim( $worktree_path, '/' ) . '/' . $gitdir; + } + + $gitdir_real = realpath( $gitdir ); + if ( false === $gitdir_real ) { + return null; + } + + // Per git layout, worktree gitdirs contain a `commondir` file with + // the path (often relative) to the repository's shared .git dir. + $commondir_file = $gitdir_real . '/commondir'; + if ( ! is_file( $commondir_file ) ) { + // Older git versions may omit this file; fall back to the + // conventional layout: /.git/worktrees//.git. + return dirname( dirname( $gitdir_real ) ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $commondir = trim( (string) file_get_contents( $commondir_file ) ); + if ( '' === $commondir ) { + return null; + } + + if ( ! str_starts_with( $commondir, '/' ) ) { + $commondir = $gitdir_real . '/' . $commondir; + } + + $real = realpath( $commondir ); + return false === $real ? null : $real; + } + + /** + * Append the injected paths to the worktree's per-checkout `info/exclude`, + * creating the file if needed. Deduplicates existing entries. + * + * @param string $worktree_path Worktree directory. + * @param string[] $paths Relative paths to ensure are excluded. + * @return string|null The `info/exclude` path touched, or null when the + * worktree's gitdir could not be resolved. + */ + private static function append_exclude_entries( string $worktree_path, array $paths ): ?string { + $info_dir = self::resolve_info_dir( $worktree_path ); + if ( null === $info_dir ) { + return null; + } + + if ( ! is_dir( $info_dir ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir + if ( ! wp_mkdir_p( $info_dir ) ) { + return null; + } + } + + $exclude = $info_dir . '/exclude'; + $current = ''; + if ( is_file( $exclude ) ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $current = (string) file_get_contents( $exclude ); + } + + $existing = array_filter( array_map( 'trim', explode( "\n", $current ) ), 'strlen' ); + $missing = array(); + foreach ( $paths as $path ) { + $needle = trim( $path ); + if ( '' === $needle ) { + continue; + } + if ( ! in_array( $needle, $existing, true ) ) { + $missing[] = $needle; + } + } + + if ( empty( $missing ) ) { + return $exclude; + } + + $append = ''; + if ( '' !== $current && ! str_ends_with( $current, "\n" ) ) { + $append .= "\n"; + } + $append .= "# Data Machine: injected worktree context (per-checkout)\n"; + $append .= implode( "\n", $missing ) . "\n"; + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $result = file_put_contents( $exclude, $append, FILE_APPEND ); + if ( false === $result ) { + return null; + } + + return $exclude; + } +}