From 36efabf68ddd3de3ef1842a3011d67d50248b41c Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 27 Mar 2026 03:52:35 +0000 Subject: [PATCH] feat(code): add .worktreeinclude support --- .../components/EnvironmentSelector.tsx | 8 +- .../agent/src/adapters/claude/claude-agent.ts | 2 +- packages/git/src/queries.ts | 15 +- packages/git/src/sagas/worktree.ts | 45 +++- packages/git/src/utils.ts | 49 ++++ packages/git/src/worktree.ts | 247 +++++++++++++++++- 6 files changed, 353 insertions(+), 13 deletions(-) diff --git a/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx b/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx index 7fa9fbb71..1a669db7f 100644 --- a/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx +++ b/apps/code/src/renderer/features/environments/components/EnvironmentSelector.tsx @@ -4,7 +4,7 @@ import { HardDrives, Plus } from "@phosphor-icons/react"; import { Flex, Tooltip } from "@radix-ui/themes"; import { useTRPC } from "@renderer/trpc/client"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useEffect, useState } from "react"; interface EnvironmentSelectorProps { repoPath: string | null; @@ -29,6 +29,12 @@ export function EnvironmentSelector({ enabled: !!repoPath, }); + useEffect(() => { + if (value === null && environments.length > 0) { + onChange(environments[0].id); + } + }, [value, environments, onChange]); + const selectedEnvironment = environments.find((env) => env.id === value); const displayText = selectedEnvironment?.name ?? "No environment"; diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 92f9ddf1a..8b0892e14 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -87,7 +87,7 @@ import type { ToolUseCache, } from "./types"; -const SESSION_VALIDATION_TIMEOUT_MS = 10_000; +const SESSION_VALIDATION_TIMEOUT_MS = 30_000; const MAX_TITLE_LENGTH = 256; const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); diff --git a/packages/git/src/queries.ts b/packages/git/src/queries.ts index 0135284fd..4781ad284 100644 --- a/packages/git/src/queries.ts +++ b/packages/git/src/queries.ts @@ -944,8 +944,17 @@ export async function addToLocalExclude( pattern: string, options?: CreateGitClientOptions, ): Promise { - const gitDir = await resolveGitDir(baseDir, options); - const excludePath = path.join(gitDir, "info", "exclude"); + const manager = getGitOperationManager(); + const excludePath = await manager.executeRead( + baseDir, + async (git) => { + // --git-path resolves to the correct location for both regular repos + // and worktrees (where info/exclude is shared via the common dir) + const rel = await git.revparse(["--git-path", "info/exclude"]); + return path.resolve(baseDir, rel); + }, + { signal: options?.abortSignal }, + ); let content = ""; try { @@ -961,7 +970,7 @@ export async function addToLocalExclude( return; } - const infoDir = path.join(gitDir, "info"); + const infoDir = path.dirname(excludePath); await fs.mkdir(infoDir, { recursive: true }); const newContent = content.trimEnd() diff --git a/packages/git/src/sagas/worktree.ts b/packages/git/src/sagas/worktree.ts index 74778f20e..4af5e5947 100644 --- a/packages/git/src/sagas/worktree.ts +++ b/packages/git/src/sagas/worktree.ts @@ -3,6 +3,7 @@ import * as path from "node:path"; import { GitSaga, type GitSagaInput } from "../git-saga"; import { addToLocalExclude, branchExists, getDefaultBranch } from "../queries"; import { safeSymlink } from "../utils"; +import { processWorktreeInclude, runPostCheckoutHook } from "../worktree"; export interface CreateWorktreeInput extends GitSagaInput { worktreePath: string; @@ -35,7 +36,16 @@ export class CreateWorktreeSaga extends GitSaga< await this.step({ name: "create-worktree", execute: () => - this.git.raw(["worktree", "add", "-b", branchName, worktreePath, base]), + this.git.raw([ + "-c", + "core.hooksPath=/dev/null", + "worktree", + "add", + "-b", + branchName, + worktreePath, + base, + ]), rollback: async () => { try { await this.git.raw(["worktree", "remove", worktreePath, "--force"]); @@ -86,6 +96,18 @@ export class CreateWorktreeSaga extends GitSaga< }, }); + await this.step({ + name: "process-worktree-include", + execute: () => processWorktreeInclude(baseDir, worktreePath), + rollback: async () => {}, + }); + + await this.step({ + name: "run-post-checkout-hook", + execute: () => runPostCheckoutHook(baseDir, worktreePath), + rollback: async () => {}, + }); + return { worktreePath, branchName, baseBranch: base }; } } @@ -123,7 +145,14 @@ export class CreateWorktreeForBranchSaga extends GitSaga< await this.step({ name: "create-worktree", execute: () => - this.git.raw(["worktree", "add", worktreePath, branchName]), + this.git.raw([ + "-c", + "core.hooksPath=/dev/null", + "worktree", + "add", + worktreePath, + branchName, + ]), rollback: async () => { try { await this.git.raw(["worktree", "remove", worktreePath, "--force"]); @@ -171,6 +200,18 @@ export class CreateWorktreeForBranchSaga extends GitSaga< }, }); + await this.step({ + name: "process-worktree-include", + execute: () => processWorktreeInclude(baseDir, worktreePath), + rollback: async () => {}, + }); + + await this.step({ + name: "run-post-checkout-hook", + execute: () => runPostCheckoutHook(baseDir, worktreePath), + rollback: async () => {}, + }); + return { worktreePath, branchName }; } } diff --git a/packages/git/src/utils.ts b/packages/git/src/utils.ts index 22589c194..0cc30790a 100644 --- a/packages/git/src/utils.ts +++ b/packages/git/src/utils.ts @@ -1,3 +1,4 @@ +import { execFile } from "node:child_process"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; @@ -58,6 +59,54 @@ export async function safeSymlink( } } +/** + * copy file or directory, use copy-on-write, fall back to cp + */ +export async function clonePath( + source: string, + destination: string, +): Promise { + try { + await fs.access(source); + } catch { + return false; + } + + const parentDir = path.dirname(destination); + await fs.mkdir(parentDir, { recursive: true }); + + const platform = os.platform(); + + try { + if (platform === "darwin") { + await execFileAsync("cp", ["-c", "-a", source, destination]); + } else { + await execFileAsync("cp", ["--reflink=auto", "-a", source, destination]); + } + return true; + } catch { + // CoW not supported, fall back to regular copy + } + + await fs.cp(source, destination, { recursive: true }); + return true; +} + +function execFileAsync( + command: string, + args: string[], +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(command, args, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve({ stdout, stderr }); + }); + }); +} + export function parseGitHubUrl(url: string): GitHubRepo | null { // Trim whitespace/newlines that git commands may include const trimmedUrl = url.trim(); diff --git a/packages/git/src/worktree.ts b/packages/git/src/worktree.ts index 2a66f6811..65e57da89 100644 --- a/packages/git/src/worktree.ts +++ b/packages/git/src/worktree.ts @@ -1,4 +1,4 @@ -import { spawn } from "node:child_process"; +import { execFile, spawn } from "node:child_process"; import * as crypto from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -7,9 +7,10 @@ import { addToLocalExclude, branchExists, getDefaultBranch, + getHeadSha, listWorktrees as listWorktreesRaw, } from "./queries"; -import { safeSymlink } from "./utils"; +import { clonePath, safeSymlink } from "./utils"; export interface WorktreeInfo { worktreePath: string; @@ -154,6 +155,7 @@ export class WorktreeManager { ? worktreePath : `./${WORKTREE_FOLDER_NAME}/${worktreeName}/${this.repoName}`; + options?.onOutput?.(`Creating worktree from ${baseBranch}...\n`); const output = await manager.executeWrite(this.mainRepoPath, async () => { return this.spawnWorktreeAdd(["--detach", targetPath, baseBranch], { onOutput: options?.onOutput, @@ -161,6 +163,15 @@ export class WorktreeManager { }); await this.symlinkClaudeConfig(worktreePath); + await processWorktreeLink(this.mainRepoPath, worktreePath, { + onOutput: options?.onOutput, + }); + await processWorktreeInclude(this.mainRepoPath, worktreePath, { + onOutput: options?.onOutput, + }); + await runPostCheckoutHook(this.mainRepoPath, worktreePath, { + onOutput: options?.onOutput, + }); return { worktreePath, @@ -221,6 +232,15 @@ export class WorktreeManager { }); await this.symlinkClaudeConfig(worktreePath); + await processWorktreeLink(this.mainRepoPath, worktreePath, { + onOutput: options?.onOutput, + }); + await processWorktreeInclude(this.mainRepoPath, worktreePath, { + onOutput: options?.onOutput, + }); + await runPostCheckoutHook(this.mainRepoPath, worktreePath, { + onOutput: options?.onOutput, + }); return { worktreePath, @@ -275,6 +295,15 @@ export class WorktreeManager { }); await this.symlinkClaudeConfig(worktreePath); + await processWorktreeLink(this.mainRepoPath, worktreePath, { + onOutput: options?.onOutput, + }); + await processWorktreeInclude(this.mainRepoPath, worktreePath, { + onOutput: options?.onOutput, + }); + await runPostCheckoutHook(this.mainRepoPath, worktreePath, { + onOutput: options?.onOutput, + }); return { worktreePath, @@ -292,10 +321,14 @@ export class WorktreeManager { ): Promise { return new Promise((resolve, reject) => { const chunks: string[] = []; - const proc = spawn("git", ["worktree", "add", ...args], { - cwd: this.mainRepoPath, - stdio: ["ignore", "pipe", "pipe"], - }); + const proc = spawn( + "git", + ["-c", "core.hooksPath=/dev/null", "worktree", "add", ...args], + { + cwd: this.mainRepoPath, + stdio: ["ignore", "pipe", "pipe"], + }, + ); const handleData = (data: Buffer) => { const text = data.toString("utf-8"); @@ -458,3 +491,205 @@ export class WorktreeManager { return { deleted, errors }; } } + +/** + * get all gitignored paths matching patterns from an exclude file + */ +function getIgnoredPathsFromExcludeFile( + mainRepoPath: string, + excludeFile: string, +): Promise { + return new Promise((resolve) => { + execFile( + "git", + [ + "ls-files", + "--ignored", + "--others", + "--directory", + `--exclude-from=${excludeFile}`, + ], + { cwd: mainRepoPath }, + (error, stdout) => { + if (error || !stdout) { + resolve([]); + return; + } + resolve( + stdout + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => line.replace(/\/$/, "")), + ); + }, + ); + }); +} + +export interface WorktreeSetupWarning { + path: string; + error: string; +} + +/** + * copy gitignored files to workspace, per .worktreeinclude + */ +export async function processWorktreeInclude( + mainRepoPath: string, + worktreePath: string, + options?: { onOutput?: (data: string) => void }, +): Promise { + const paths = await getIgnoredPathsFromExcludeFile( + mainRepoPath, + ".worktreeinclude", + ); + if (paths.length === 0) return []; + + const warnings: WorktreeSetupWarning[] = []; + + for (const relativePath of paths) { + const source = path.join(mainRepoPath, relativePath); + const destination = path.join(worktreePath, relativePath); + + try { + options?.onOutput?.(`Copying ${relativePath}...\n`); + const copied = await clonePath(source, destination); + if (copied) { + await addToLocalExclude(worktreePath, relativePath); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + options?.onOutput?.( + `Warning: failed to copy ${relativePath}: ${message}\n`, + ); + warnings.push({ + path: relativePath, + error: message, + }); + } + } + + return warnings; +} + +/** + * symlink gitignored paths into workspace, per .worktreelink + */ +export async function processWorktreeLink( + mainRepoPath: string, + worktreePath: string, + options?: { onOutput?: (data: string) => void }, +): Promise { + const paths = await getIgnoredPathsFromExcludeFile( + mainRepoPath, + ".worktreelink", + ); + if (paths.length === 0) return []; + + const warnings: WorktreeSetupWarning[] = []; + + for (const relativePath of paths) { + const source = path.join(mainRepoPath, relativePath); + const destination = path.join(worktreePath, relativePath); + + try { + const stat = await fs.stat(source); + const type = stat.isDirectory() ? "dir" : "file"; + + options?.onOutput?.(`Linking ${relativePath}...\n`); + const linked = await safeSymlink(source, destination, type); + if (linked) { + await addToLocalExclude(worktreePath, relativePath); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + options?.onOutput?.( + `Warning: failed to link ${relativePath}: ${message}\n`, + ); + warnings.push({ + path: relativePath, + error: message, + }); + } + } + + return warnings; +} + +function findPostCheckoutHook(mainRepoPath: string): Promise { + return new Promise((resolve) => { + execFile( + "git", + ["rev-parse", "--git-path", "hooks/post-checkout"], + { cwd: mainRepoPath }, + async (error, stdout) => { + if (error || !stdout.trim()) { + resolve(null); + return; + } + const resolved = stdout.trim(); + const hookPath = path.isAbsolute(resolved) + ? resolved + : path.join(mainRepoPath, resolved); + + try { + await fs.access(hookPath, fs.constants.X_OK); + resolve(hookPath); + } catch { + resolve(null); + } + }, + ); + }); +} + +/** + * run post-checkout hook in the worktree + * + * hooks are intentionally skipped during worktree creation to avoid + * potentially wonky behavior + */ +export async function runPostCheckoutHook( + mainRepoPath: string, + worktreePath: string, + options?: { onOutput?: (data: string) => void }, +): Promise { + const hookPath = await findPostCheckoutHook(mainRepoPath); + if (!hookPath) return null; + + options?.onOutput?.(`Running post-checkout hook...\n`); + + const head = await getHeadSha(worktreePath); + const nullSha = "0000000000000000000000000000000000000000"; + + return new Promise((resolve) => { + const chunks: string[] = []; + const shell = process.env.SHELL || "/bin/sh"; + const proc = spawn(shell, ["-lc", `${hookPath} ${nullSha} ${head} 1`], { + cwd: worktreePath, + stdio: ["ignore", "pipe", "pipe"], + }); + + const handleData = (data: Buffer) => { + const text = data.toString(); + chunks.push(text); + options?.onOutput?.(text); + }; + + proc.stdout.on("data", handleData); + proc.stderr.on("data", handleData); + proc.on("error", (err) => resolve({ path: hookPath, error: err.message })); + proc.on("close", (code) => { + if (code !== 0) { + resolve({ + path: hookPath, + error: + `post-checkout hook exited with code ${code}: ${chunks.join("")}`.trim(), + }); + return; + } + resolve(null); + }); + }); +}