From 2aa2a449e5fcd249fb48003750ab584c67e3499e Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Mon, 18 May 2026 14:02:14 -0700 Subject: [PATCH] chore: route worktree startup through Worktrunk (#2297) * fix: add worktree preflight checks * fix: remove worktree preflight spec note * chore: compare worktrunk worktree startup * fix: stop worktrunk dev process group * chore: route worktree startup through worktrunk * chore: keep dev seeding as project command * chore: clarify worktrunk worktree script boundaries * fix: keep worktree CI green after main merge --- .config/wt.toml | 20 +++++++++++++ .worktreeinclude | 1 + bun.lock | 6 ++-- package.json | 5 ++-- scripts/dev-worktree.test.ts | 39 +++++++++--------------- scripts/dev-worktree.ts | 58 ++++++++++++++---------------------- scripts/setup-worktree.ts | 24 +-------------- 7 files changed, 65 insertions(+), 88 deletions(-) create mode 100644 .config/wt.toml create mode 100644 .worktreeinclude diff --git a/.config/wt.toml b/.config/wt.toml new file mode 100644 index 00000000..0446ad3d --- /dev/null +++ b/.config/wt.toml @@ -0,0 +1,20 @@ +[list] +url = "http://127.0.0.1:{{ (repo ~ '-' ~ branch) | hash_port }}" + +[[pre-start]] +env = "bun run setup:worktree -- --quiet" + +[[pre-start]] +deps = "wt step copy-ignored || true; test -x node_modules/.bin/vite || bun install" + +[post-start] +dev = "bun scripts/dev-worktree.ts --detach --port {{ (repo ~ '-' ~ branch) | hash_port }}" + +[pre-remove] +dev = "if test -f .codex/runtime/dev-worktree.pid; then pid=$(cat .codex/runtime/dev-worktree.pid); kill -TERM -$pid 2>/dev/null || kill $pid 2>/dev/null || true; rm -f .codex/runtime/dev-worktree.pid; fi" + +[aliases] +dev = "wt --yes hook pre-start && bun scripts/dev-worktree.ts --detach --port {{ (repo ~ '-' ~ branch) | hash_port }}" +setup = "wt --yes hook pre-start" +stop = "if test -f .codex/runtime/dev-worktree.pid; then pid=$(cat .codex/runtime/dev-worktree.pid); kill -TERM -$pid 2>/dev/null || kill $pid 2>/dev/null || true; rm -f .codex/runtime/dev-worktree.pid; fi" +url = "echo http://127.0.0.1:{{ (repo ~ '-' ~ branch) | hash_port }}" diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1 @@ +node_modules/ diff --git a/bun.lock b/bun.lock index b4d0de95..09916c59 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "clawhub", @@ -141,6 +142,7 @@ "dompurify": "3.4.1", "next": "16.2.6", "postcss": "8.5.12", + "ws": "8.20.1", }, "packages": { "@ark/schema": ["@ark/schema@0.56.0", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA=="], @@ -1379,7 +1381,7 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], @@ -1473,8 +1475,6 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@tanstack/devtools-event-bus/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="], - "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/package.json b/package.json index eaa6ccac..2f470658 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "deadcode:files": "bunx knip@6.8.0 --config knip.config.ts --production --no-progress --reporter compact --files --no-config-hints", "deadcode:knip": "bun run deadcode:files && bun run deadcode:dependencies && bun run deadcode:exports", "dev": "bun --bun vite dev --port 3000", - "dev:worktree": "bun run setup:worktree -- --quiet && bun scripts/dev-worktree.ts", + "dev:worktree": "wt --yes dev", "docs:list": "bun scripts/docs-list.ts", "docs:run": "bun scripts/docs-run.ts", "eval:clawscan:security-signals": "bun scripts/eval/clawscan-security-signals.ts", @@ -143,6 +143,7 @@ "overrides": { "dompurify": "3.4.1", "next": "16.2.6", - "postcss": "8.5.12" + "postcss": "8.5.12", + "ws": "8.20.1" } } diff --git a/scripts/dev-worktree.test.ts b/scripts/dev-worktree.test.ts index 4931cf47..b169f0c1 100644 --- a/scripts/dev-worktree.test.ts +++ b/scripts/dev-worktree.test.ts @@ -2,11 +2,11 @@ import { describe, expect, it } from "vitest"; import { buildForegroundArgs, buildEnvFileCandidates, + buildViteArgs, isConvexFunctionUnavailableOutput, isRunningPid, parseArgs, parseEnv, - parseGitWorktreeList, } from "./dev-worktree"; describe("dev-worktree helpers", () => { @@ -26,7 +26,7 @@ describe("dev-worktree helpers", () => { }); }); - it("discovers the primary worktree env file after the current checkout", () => { + it("uses only the local checkout env unless an env file is explicit", () => { expect( buildEnvFileCandidates({ explicit: null, @@ -37,16 +37,6 @@ describe("dev-worktree helpers", () => { "/tmp/worktrees/other-feature", ], }), - ).toEqual([".env.local", "/Users/me/Git/openclaw/clawhub/.env.local"]); - }); - - it("does not scan every sibling worktree for env files", () => { - expect( - buildEnvFileCandidates({ - explicit: null, - cwd: "/tmp/worktrees/feature", - worktrees: ["/tmp/worktrees/feature", "/tmp/worktrees/other-feature"], - }), ).toEqual([".env.local"]); }); @@ -60,19 +50,6 @@ describe("dev-worktree helpers", () => { ).toEqual(["/secure/shared.env"]); }); - it("parses git worktree porcelain output", () => { - expect( - parseGitWorktreeList(`worktree /Users/me/Git/openclaw/clawhub -HEAD abc123 -branch refs/heads/main - -worktree /tmp/worktrees/feature -HEAD def456 -branch refs/heads/feature -`), - ).toEqual(["/Users/me/Git/openclaw/clawhub", "/tmp/worktrees/feature"]); - }); - it("recognizes Convex functions that are not queryable yet", () => { expect( isConvexFunctionUnavailableOutput(` @@ -96,6 +73,18 @@ branch refs/heads/feature expect(buildForegroundArgs(["--detach", "--port", "3999"])).toEqual(["--port", "3999"]); }); + it("binds Vite to the same loopback host advertised by Worktrunk", () => { + expect(buildViteArgs("3999")).toEqual([ + "--bun", + "vite", + "dev", + "--host", + "127.0.0.1", + "--port", + "3999", + ]); + }); + it("treats invalid detached pid file values as not running", () => { expect(isRunningPid(null)).toBe(false); expect(isRunningPid(0)).toBe(false); diff --git a/scripts/dev-worktree.ts b/scripts/dev-worktree.ts index 9a89e1ef..b0a5a5c1 100644 --- a/scripts/dev-worktree.ts +++ b/scripts/dev-worktree.ts @@ -59,6 +59,10 @@ export function buildForegroundArgs(argv: string[]) { return argv.filter((arg) => arg !== "--detach"); } +export function buildViteArgs(port: string) { + return ["--bun", "vite", "dev", "--host", "127.0.0.1", "--port", port]; +} + function readDetachedPid() { if (!existsSync(DETACHED_PID_FILE)) return null; const raw = readFileSync(DETACHED_PID_FILE, "utf8").trim(); @@ -77,43 +81,19 @@ export function isRunningPid(pid: number | null) { } } -export function parseGitWorktreeList(text: string) { - return text - .split(/\r?\n/) - .filter((line) => line.startsWith("worktree ")) - .map((line) => line.slice("worktree ".length).trim()) - .filter(Boolean); -} - -function listGitWorktrees() { - const result = spawnSync("git", ["worktree", "list", "--porcelain"], { - cwd: process.cwd(), - encoding: "utf8", - }); - if (result.status !== 0 || typeof result.stdout !== "string") return []; - return parseGitWorktreeList(result.stdout); -} - export function buildEnvFileCandidates(options: { explicit: string | null; cwd: string; worktrees?: string[]; }) { if (options.explicit) return [options.explicit]; - const primaryWorktree = options.worktrees?.[0]; - return [ - ...DEFAULT_ENV_SOURCES, - ...(primaryWorktree && resolve(primaryWorktree) !== resolve(options.cwd) - ? [`${primaryWorktree}/.env.local`] - : []), - ]; + return DEFAULT_ENV_SOURCES; } function findEnvFile(explicit: string | null) { const candidates = buildEnvFileCandidates({ explicit, cwd: process.cwd(), - worktrees: explicit ? [] : listGitWorktrees(), }); return candidates .map((candidate) => resolve(candidate)) @@ -173,7 +153,11 @@ async function isReachable(url: string) { } } -function runSync(command: string, args: string[], extraEnv: Record) { +export function runSync( + command: string, + args: string[], + extraEnv: Record, +) { return ( spawnSync(command, args, { cwd: process.cwd(), @@ -237,6 +221,7 @@ async function runConvexFunctionWhenReady(args: string[]) { function spawnManaged(command: string, args: string[]) { const child = spawn(command, args, { cwd: process.cwd(), + detached: process.platform !== "win32", env: process.env, stdio: "inherit", }); @@ -247,7 +232,16 @@ function spawnManaged(command: string, args: string[]) { function stopManagedChildren() { for (const child of managedChildren) { - if (!child.killed) child.kill("SIGTERM"); + if (child.killed) continue; + if (process.platform !== "win32" && child.pid) { + try { + process.kill(-child.pid, "SIGTERM"); + continue; + } catch { + // Fall through to the direct child signal below. + } + } + child.kill("SIGTERM"); } } @@ -337,7 +331,7 @@ async function main() { const envFile = findEnvFile(options.envFile); if (!envFile) { console.error( - "Could not find .env.local in this checkout or the primary git worktree. Pass --env-file or set CLAWHUB_ENV_FILE to a shared local env file.", + "Could not find .env.local in this checkout. Run bun run setup:worktree, pass --env-file , or set CLAWHUB_ENV_FILE.", ); process.exit(1); } @@ -350,12 +344,6 @@ async function main() { process.exit(1); } - if (!existsSync("node_modules/.bin/vite")) { - console.log("Installing dependencies for this worktree..."); - const installStatus = runSync("bun", ["install"], {}); - if (installStatus !== 0) process.exit(installStatus); - } - await ensureConvex(convexUrl); if (options.seed) { @@ -387,7 +375,7 @@ async function main() { console.log(`Starting ClawHub from ${process.cwd()}`); console.log(`Using env file: ${envFile}`); - const vite = spawnManaged("bun", ["--bun", "vite", "dev", "--port", options.port]); + const vite = spawnManaged("bun", buildViteArgs(options.port)); process.exit(await waitForExit(vite)); } diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts index 1d08e98b..a66e31d4 100644 --- a/scripts/setup-worktree.ts +++ b/scripts/setup-worktree.ts @@ -210,37 +210,15 @@ function linkFromSource(name: string, sourcePath: string, force: boolean) { return true; } -function copyOnWriteDirectory(name: string, sourceRoot: string, quiet: boolean) { - const target = resolve(process.cwd(), name); - if (existsSync(target)) return; - - const source = resolve(sourceRoot, name); - if (!existsSync(source)) return; - - const cpArgs = process.platform === "darwin" ? ["-cR", source, target] : ["-a", source, target]; - const result = spawnSync("cp", cpArgs, { stdio: quiet ? "ignore" : "inherit" }); - if (result.status !== 0) { - if (!quiet) console.log(`Copy-on-write clone failed for ${name}; running bun install instead.`); - spawnSync("bun", ["install"], { stdio: "inherit" }); - } -} - function main() { const options = parseArgs(process.argv.slice(2)); const source = findSource(options); linkFromSource(".env.local", resolve(source.path, ".env.local"), options.force); linkFromSource(".convex", resolve(source.path, ".convex"), options.force); - copyOnWriteDirectory("node_modules", source.path, options.quiet); - - if (!existsSync("node_modules/.bin/vite")) { - if (!options.quiet) console.log("Installing dependencies for this worktree..."); - const result = spawnSync("bun", ["install"], { stdio: "inherit" }); - if (result.status !== 0) process.exit(result.status ?? 1); - } if (!options.quiet) { - console.log(`Worktree setup complete using ${source.path}`); + console.log(`Worktree env setup complete using ${source.path}`); } }