#!/usr/bin/env node // Git worktree helper: deterministic non-colliding ports per worktree, a wrapper // that injects them into dev/e2e commands, and add/list/rm lifecycle commands. // // Ports are offset by `worktreeIndex * 100`, where the index is the worktree's // position in `git worktree list` (the main worktree is first -> index 0 -> // default ports unchanged). So the main checkout behaves exactly as before, and // each extra worktree gets a clean 31xx / 32xx / ... block. See WORKTREES.md. import { execFileSync, spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { OFFSET_STEP, portsForOffset } from "../common/lib/ports.mjs"; // The port table — names, bases and the offset step — is common/lib/ports.mjs, // the one copy. This file only decides WHICH offset a checkout gets. function git(args, opts = {}) { // With stdio:"inherit" execFileSync returns null (output not captured). const out = execFileSync("git", args, { encoding: "utf8", ...opts }); return out == null ? "" : out.trim(); } function realpath(p) { try { return fs.realpathSync(p); } catch { return path.resolve(p); } } // Parse `git worktree list --porcelain` into [{ path, branch, head }], in order. function listWorktrees() { const out = git(["worktree", "list", "--porcelain"]); const trees = []; let cur = null; for (const line of out.split("\n")) { if (line.startsWith("worktree ")) { cur = { path: line.slice("worktree ".length), branch: null, head: null }; trees.push(cur); } else if (cur && line.startsWith("branch ")) { cur.branch = line.slice("branch ".length).replace("refs/heads/", ""); } else if (cur && line.startsWith("HEAD ")) { cur.head = line.slice("HEAD ".length).slice(0, 12); } else if (cur && line === "detached") { cur.branch = "(detached)"; } } return trees; } // The main worktree is always the first entry of `git worktree list`. function mainRoot(trees = listWorktrees()) { return trees.length ? trees[0].path : git(["rev-parse", "--show-toplevel"]); } // WHERE A WORKTREE LIVES, asked the same way by `add` and by `rm`. // // A branch name may contain "/" and a sibling directory name may not, so `add` // has always flattened `tags/site` to `tags-site`. `rm` did not, so // `pnpm wt rm tags/site` pointed git at a path nothing had ever created — and // that string is exactly what somebody who just ran `add` will type. One // function, both callers, so the two spellings cannot drift apart again. // // An absolute path passes through untouched: `rm` accepts one (it is what // `list` prints), and sanitizing it would eat its separators. export function worktreeDirFor(main, name) { if (path.isAbsolute(name)) return name; return path.join(path.dirname(main), name.replace(/[^A-Za-z0-9._-]/g, "-")); } function offsetForIndex(index) { return index * OFFSET_STEP; } // Index of the worktree containing `dir` (default: cwd) in the worktree list. function indexForDir(dir = process.cwd()) { const trees = listWorktrees(); return indexForPath( trees.map((t) => realpath(t.path)), realpath(dir), ); } // THE MOST SPECIFIC root containing `target`, by its index in `roots` (0 when // none does). Not the first: a worktree NESTED in the main checkout -- every // `.claude/worktrees/` is -- is also "inside" the main root, which // comes first in the list, so a first-match gave every agent worktree the // main checkout's ports (offset 0) and its e2e servers collided on them. export function indexForPath(roots, target) { let best = 0; let bestLen = -1; for (let i = 0; i < roots.length; i++) { const root = roots[i]; if ((target === root || target.startsWith(root + path.sep)) && root.length > bestLen) { best = i; bestLen = root.length; } } return best; } // Read a simple KEY=VALUE file (e.g. .worktree-env) into an object. function readEnvFile(file) { const env = {}; if (!fs.existsSync(file)) return env; for (const raw of fs.readFileSync(file, "utf8").split("\n")) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; const eq = line.indexOf("="); if (eq === -1) continue; env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); } return env; } // ---- subcommands --------------------------------------------------------- function cmdPorts(args) { const shellIdx = args.indexOf("--shell"); const shell = shellIdx !== -1 ? args[shellIdx + 1] : null; const ports = portsForOffset(offsetForIndex(indexForDir())); if (shell === "fish") { for (const [k, v] of Object.entries(ports)) { process.stdout.write(`set -gx ${k} ${v}\n`); } return 0; } if (shell === "bash" || shell === "posix") { for (const [k, v] of Object.entries(ports)) { process.stdout.write(`export ${k}=${v}\n`); } return 0; } // Default: human-readable table on stderr, plain KEY=VALUE on stdout. const idx = indexForDir(); process.stderr.write( `worktree #${idx} (offset ${offsetForIndex(idx)}) ${realpath(process.cwd())}\n`, ); for (const [k, v] of Object.entries(ports)) { process.stderr.write(` ${k.padEnd(20)} ${v}\n`); process.stdout.write(`${k}=${v}\n`); } return 0; } function cmdList() { const trees = listWorktrees(); for (let i = 0; i < trees.length; i++) { const p = portsForOffset(offsetForIndex(i)); const branch = trees[i].branch ?? trees[i].head ?? "?"; process.stdout.write( `#${i} ${branch.padEnd(24)} editor:${p.EDITOR_PORT} test:${p.PORT} ` + `export:${p.EXPORT_PORT} ${trees[i].path}\n`, ); } return 0; } function cmdRun(args) { // Accept either `run -- cmd args` or `run cmd args`. const sep = args.indexOf("--"); const cmdArgs = sep !== -1 ? args.slice(sep + 1) : args; if (cmdArgs.length === 0) { process.stderr.write("worktree run: no command given\n"); return 1; } const ports = portsForOffset(offsetForIndex(indexForDir())); const fileEnv = readEnvFile(path.join(process.cwd(), ".worktree-env")); const env = { ...process.env }; // Precedence: real environment (CI/manual) > .worktree-env > computed ports. for (const [k, v] of Object.entries(ports)) if (env[k] == null) env[k] = v; for (const [k, v] of Object.entries(fileEnv)) if (env[k] == null) env[k] = v; const child = spawn(cmdArgs[0], cmdArgs.slice(1), { stdio: "inherit", env }); return new Promise((resolve) => { child.on("error", (err) => { process.stderr.write(`worktree run: ${err.message}\n`); resolve(1); }); child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); resolve(code ?? 1); }); }); } function branchExists(branch) { try { git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { stdio: ["ignore", "ignore", "ignore"], }); return true; } catch { return false; } } function cmdAdd(args) { const positional = []; let from = null; let shareData = false; let install = true; for (let i = 0; i < args.length; i++) { if (args[i] === "--from") from = args[++i]; else if (args[i] === "--share-data") shareData = true; else if (args[i] === "--no-install") install = false; else positional.push(args[i]); } const branch = positional[0]; if (!branch) { process.stderr.write( "worktree add: usage: add [--from ] [--share-data] [--no-install]\n", ); return 1; } const main = mainRoot(); const dir = worktreeDirFor(main, branch); if (branchExists(branch)) { git(["worktree", "add", dir, branch], { stdio: "inherit" }); } else { git(["worktree", "add", "-b", branch, dir, from ?? "HEAD"], { stdio: "inherit" }); } // Seed settings.json from the main worktree if the new one lacks it. const mainSettings = path.join(main, "settings.json"); const newSettings = path.join(dir, "settings.json"); if (fs.existsSync(mainSettings) && !fs.existsSync(newSettings)) { fs.copyFileSync(mainSettings, newSettings); process.stderr.write(`Seeded settings.json from ${main}\n`); } if (shareData) { const sharedTranscripts = path.join(main, "transcripts"); fs.writeFileSync( path.join(dir, ".worktree-env"), `# Shared data link created by 'wt add --share-data'.\n` + `# WARNING: concurrent writes to the shared LMDB index can corrupt it;\n` + `# use for read-mostly/build reuse, not simultaneous ingestion.\n` + `TRANSCRIPTS_DIR=${sharedTranscripts}\n`, ); process.stderr.write(`Wrote .worktree-env -> TRANSCRIPTS_DIR=${sharedTranscripts}\n`); } // Install deps so the worktree is immediately usable. pnpm 11 gates native // build scripts (esbuild/lmdb/msgpackr-extract/sharp/unrs-resolver) behind an // `allowBuilds` approval and, on a fresh worktree, injects a placeholder // `allowBuilds:` block into the tracked pnpm-workspace.yaml and then fails its // deps-verify-before-run check on every later `pnpm