commit fd4803007104d6c37c9f13cf9c1d118507e96615
parent 25cabe6db14a345f9bda79fd08ddc5f1357d8cdf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 13 Jun 2026 23:54:14 -0400
worktree support: non-colliding ports for parallel dev/test
Add scripts/worktree.mjs (pnpm wt): a deterministic per-worktree port
allocator + command wrapper + add/list/rm lifecycle CLI. Each worktree gets
a port block offset by `index * 100` from its position in `git worktree list`,
so the main worktree keeps the original defaults and extra worktrees run in
parallel without collisions.
- editor/export package.json port flags now honor env vars
(${EDITOR_PORT}/${PORT}/${EXPORT_DEV_PORT}); previously dev:test hardcoded
3011 so a custom PORT only moved the URL Playwright waited on, not the server
- root scripts (dev:editor, dev:export, start:export, e2e) route through
`wt run` to auto-assign ports per worktree; add `pnpm wt`
- centralize the e2e base URL in editor/e2e/baseUrl.ts and export
PLAYWRIGHT_BASE_URL from the Playwright config; specs that hardcoded
localhost:3011 now derive it, so e2e passes on any port
- `wt add --share-data` links a worktree to the main transcripts/ for
read-mostly reuse (.worktree-env), with an LMDB concurrent-write caveat
- docs: WORKTREES.md, AGENTS.md pointer, CHANGELOG entry
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
17 files changed, 427 insertions(+), 21 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
@@ -3,3 +3,10 @@
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+
+# Parallel work with git worktrees
+
+To run more than one checkout at once (parallel dev servers / e2e), use git worktrees with
+per-worktree non-colliding ports. `pnpm wt add <branch>` creates one; `pnpm wt list` shows
+each worktree's port block. `pnpm dev:editor` / `pnpm e2e` auto-assign ports per worktree.
+See [WORKTREES.md](WORKTREES.md) for the port scheme and the shared-data caveat.
diff --git a/WORKTREES.md b/WORKTREES.md
@@ -0,0 +1,84 @@
+# Parallel development with git worktrees
+
+Git worktrees let you check out several branches at once, each in its own directory,
+sharing one `.git`. This repo supports running multiple worktrees **simultaneously** —
+parallel dev servers and parallel e2e suites — by giving each worktree its own
+non-colliding block of ports.
+
+The helper is `scripts/worktree.mjs`, exposed as `pnpm wt`.
+
+## Port scheme
+
+Each worktree gets an offset of `index * 100`, where `index` is the worktree's position in
+`git worktree list`. The **main** worktree is always first, so it keeps the original
+defaults — nothing changes for the primary checkout.
+
+| Env var | Base (main) | Used by |
+|---|---|---|
+| `EDITOR_PORT` | 3001 | editor real dev/start (`pnpm dev:editor`) |
+| `PORT` | 3011 | editor test server + Playwright editor baseURL |
+| `EXPORT_PORT` | 3010 | export server launched by the editor e2e suite |
+| `EXPORT_DEV_PORT` | 3000 | export real dev (`pnpm dev:export`) |
+| `EXPORT_E2E_PORT` | 3020 | export's own Playwright suite |
+| `PLAYWRIGHT_BASE_URL` | `http://localhost:3011` | node-side fetches in specs |
+
+So worktree #1 runs editor on **3101**, test server on **3111**, export on **3110**, etc.
+The hundreds digit is the worktree index. The allocator never overrides a variable already
+set in the environment, so CI and manual overrides always win.
+
+## Commands
+
+```sh
+pnpm wt ports # print this worktree's assigned ports (table + KEY=VALUE)
+pnpm wt list # list all worktrees with their port blocks
+pnpm wt add <branch> # create a sibling worktree, seeded + ports printed
+pnpm wt add <branch> --from <ref> --share-data
+pnpm wt rm <name> [--force] # remove a worktree (--force if it has local files,
+ # e.g. a --share-data worktree's .worktree-env)
+pnpm wt run -- <cmd...> # run a command with this worktree's ports injected
+```
+
+`pnpm dev:editor`, `pnpm dev:export`, `pnpm start:export`, and `pnpm e2e` already run through
+`wt run`, so they pick up the right ports automatically — no manual setup needed.
+
+### Load the ports into your shell (fish)
+
+```fish
+node scripts/worktree.mjs ports --shell fish | source
+echo $PORT # -> e.g. 3111 in worktree #1
+```
+
+For bash/zsh use `--shell bash` and `eval "$(node scripts/worktree.mjs ports --shell bash)"`.
+
+## Typical workflow
+
+```sh
+# from the main checkout
+pnpm wt add feature-x # creates ../feature-x, prints its ports, seeds settings.json
+
+# terminal A (main): pnpm dev:editor -> http://localhost:3001
+# terminal B (../feature-x): pnpm dev:editor -> http://localhost:3101
+# both run at once, no EADDRINUSE
+
+# parallel e2e — run in each worktree at the same time:
+pnpm e2e # main: editor 3011 + export 3010
+cd ../feature-x && pnpm e2e # feature-x: editor 3111 + export 3110
+```
+
+## Data directories
+
+By default each worktree is **fully isolated**: `common/lib/paths.ts` resolves
+`transcripts/`, `.next/`, `.export-index/`, etc. relative to the worktree root, and the
+editor's `dev:test`/`start:test` scripts already point `TRANSCRIPTS_DIR` at the worktree's
+own `test-transcripts/`. This is safe for parallel runs — no shared LMDB lock.
+
+### Sharing downloaded data (opt-in)
+
+Re-downloading channels into every worktree is wasteful. `pnpm wt add <branch> --share-data`
+writes a `.worktree-env` file pointing `TRANSCRIPTS_DIR` at the **main** worktree's
+`transcripts/`, and `wt run` loads it. This lets a worktree reuse the already-downloaded
+corpus for read-mostly work and builds.
+
+> ⚠️ **Caveat:** the shared LMDB index (`transcripts/index.mdb`) is not safe for concurrent
+> **writes**. Use shared mode for reading/building, not for running ingestion (downloads /
+> indexing) in two worktrees at the same time — concurrent writers can corrupt the index.
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **Git worktrees can run in parallel on non-colliding ports (dev tooling).** Two checkouts of the repo (via `git worktree`) can now run their dev servers and e2e suites at the same time without port clashes. A new helper, `scripts/worktree.mjs` (exposed as `pnpm wt`), assigns each worktree a port block offset by `index * 100` based on its position in `git worktree list` — the main worktree keeps the original defaults (editor 3001, test 3011, export 3010/3000/3020), worktree #1 gets 31xx, and so on. `pnpm dev:editor`, `pnpm dev:export`, `pnpm start:export`, and `pnpm e2e` route through `wt run`, which injects the assigned ports, so they "just work" per worktree; the editor/export `package.json` port flags and the Playwright configs now honor these env vars (previously `pnpm dev:test` hardcoded 3011, so a custom `PORT` only moved the URL Playwright waited on, not the server). E2E specs that hit the editor's test API now derive the base URL from `PLAYWRIGHT_BASE_URL` (centralized in `editor/e2e/baseUrl.ts`) instead of hardcoding `localhost:3011`. `pnpm wt add <branch>` creates a sibling worktree pre-seeded with `settings.json` and prints its ports; `--share-data` links it to the main worktree's downloaded `transcripts/` for read-mostly reuse (with an LMDB concurrent-write caveat). See `WORKTREES.md`.
- **Channel reports refresh themselves automatically, on a global debounce.** Any action that changes what a channel report (the per-channel snapshot powering the Channels list, `/actionable`, and the channel page) would say now regenerates that report on its own when it finishes — no more manually clicking **Refresh report** after transcribing, transcoding, cleaning audio, checking availability, editing channel config, or the per-video file operations (delete file, set primary transcript, delete dir, mark untranscribable, archive/do-not-clean). Every report-changing action marks its channel "dirty" and re-arms one shared debounce timer; when activity settles, the scheduler regenerates each dirty channel's snapshot in parallel (reusing the existing `refresh-report` job, deduped against any refresh already running) and revalidates the affected pages. This happens **after each completed sub-operation within a batch**, not only when the whole batch finishes — so a long download or transcription run updates its report incrementally as each video lands, rather than staying stale until the end. The debounce coalesces bursts — videos that finish within the same window collapse into a single regen pass instead of one rewrite per operation. The window is configurable in **Settings → Report refresh debounce**: **Fast** (~1s after activity settles, no cap — the default), **Balanced** (~3s, 30s max), or **Lazy** (~10s, 60s max). The download/sync pipeline's previous behavior of regenerating its report inline is removed in favor of this one uniform mechanism (so its report now lags by the debounce window — ~1s by default — rather than being written synchronously). The manual **Refresh report** / **Update all reports** buttons are unchanged.
- **Parakeet transcriptions show a per-video ETA.** While a video is being transcribed with the parakeet app, its per-task progress bar on `/jobs/active` now shows an estimate of how long that single video has left (e.g. `segment 3/12 · ETA 6:10`), alongside the existing segment count and percent. The wrapper (`scripts/parakeet-stitch.mjs`) measures each segment's real transcription wall-time and projects the remaining time as **average time per completed segment × remaining segments**, emitting it on its progress lines; the parser surfaces that ETA in the task detail. It appears from the second segment onward (the first segment has no average to project from yet). This is distinct from the batch-level "~4:30 left" estimate across all videos.
- **Each active sub-operation shows how long it has been running.** Every per-task bar on `/jobs/active` (each in-flight download or transcription) now leads with a live `m:ss` timer of its own elapsed wall-time, ticking once a second — e.g. `1:42 · 25% · segment 3/12 · ETA 6:10`. The start time comes from the job registry's per-task `startedAt`, so the timer survives page reloads and reflects the real runtime, not time-since-open.
diff --git a/editor/e2e/auto-report-refresh.spec.ts b/editor/e2e/auto-report-refresh.spec.ts
@@ -1,6 +1,7 @@
import { writeFile } from "node:fs/promises";
import { test, expect } from "@playwright/test";
import { readJson, resetData, resolvePath } from "./helpers";
+import { baseUrl } from "./baseUrl";
// Verifies the global debounced snapshot scheduler: a direct (non-managed)
// action that changes report-relevant data regenerates the channel report on
@@ -31,7 +32,7 @@ test("a direct action auto-refreshes the channel report via the debounce", async
await resetData("one-transcribe-channel-with-audio");
await seedTranscript("vidA");
await seedTranscript("vidB");
- await fetch("http://localhost:3011/api/test/invalidate-cache").catch(() => {});
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
// Visiting the channel page generates the initial snapshot from disk: both
// transcribed-with-audio videos are cleanable.
diff --git a/editor/e2e/baseUrl.ts b/editor/e2e/baseUrl.ts
@@ -0,0 +1,7 @@
+// The editor test server's base URL. Worktrees run on offset ports (see
+// scripts/worktree.mjs / WORKTREES.md), so node-side fetches in specs must read
+// the assigned port from the environment rather than hardcoding 3011. The
+// Playwright config sets PLAYWRIGHT_BASE_URL from its PORT; the fallback matches
+// the default editor test port when running without the worktree wrapper.
+export const baseUrl =
+ process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3011";
diff --git a/editor/e2e/cleanup-actionable.spec.ts b/editor/e2e/cleanup-actionable.spec.ts
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { test, expect } from "@playwright/test";
import { resetData, resolvePath } from "./helpers";
+import { baseUrl } from "./baseUrl";
const SLUG = "test-transcribe";
const SNAPSHOT_REL = `test-transcripts/channels/${SLUG}/snapshot.json`;
@@ -40,7 +41,7 @@ async function seedCleanupSnapshot(): Promise<void> {
multipleAudioFormats: 1_048_576,
},
});
- await fetch("http://localhost:3011/api/test/invalidate-cache").catch(() => {});
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
}
test("surfaces the two cleanup sections with per-channel counts", async ({
diff --git a/editor/e2e/do-not-clean.spec.ts b/editor/e2e/do-not-clean.spec.ts
@@ -1,6 +1,7 @@
import { stat, writeFile } from "node:fs/promises";
import { test, expect } from "@playwright/test";
import { pathExists, readJson, resetData, resolvePath } from "./helpers";
+import { baseUrl } from "./baseUrl";
// Mirror common/lib/format.ts formatBytes so the e2e assertion matches the UI
// without depending on the package path alias resolving in the test runner.
@@ -35,7 +36,7 @@ test("'do not clean' protects a video's audio from cleanup; toggling off restore
await seedTranscript("vidA");
await seedTranscript("vidB");
// The fixture seeds audio.m4a for each video; config audioFormat is m4a.
- await fetch("http://localhost:3011/api/test/invalidate-cache").catch(() => {});
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
// Mark vidA "do not clean" on its video page.
await page.goto(`/channels/${SLUG}/videos/vidA`);
@@ -89,7 +90,7 @@ test("snapshot records reclaimable cleanup bytes and the Cleanup stage shows the
await resetData("one-transcribe-channel-with-audio");
await seedTranscript("vidA");
await seedTranscript("vidB");
- await fetch("http://localhost:3011/api/test/invalidate-cache").catch(() => {});
+ await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
// Sum the audio that the transcribed-audio cleanup would delete (both videos
// have a whisper transcript and an audio.m4a on disk).
diff --git a/editor/e2e/helpers.ts b/editor/e2e/helpers.ts
@@ -8,6 +8,7 @@ import {
stat,
writeFile,
} from "node:fs/promises";
+import { baseUrl } from "./baseUrl";
const here = dirname(fileURLToPath(import.meta.url));
const editorRoot = resolve(here, "..");
@@ -19,8 +20,6 @@ const defaultTestSettingsFile = resolve(
"test-settings.default.json",
);
-const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3011";
-
async function fileExists(p: string): Promise<boolean> {
try {
await stat(p);
diff --git a/editor/e2e/job-stream-cancel.spec.ts b/editor/e2e/job-stream-cancel.spec.ts
@@ -7,8 +7,7 @@
import { test, expect } from "@playwright/test";
import { resetData } from "./helpers";
-
-const baseUrl = "http://localhost:3011";
+import { baseUrl } from "./baseUrl";
async function readUncaughtCount(): Promise<{
uncaught: number;
diff --git a/editor/e2e/jobs-batch-tasks-drain.spec.ts b/editor/e2e/jobs-batch-tasks-drain.spec.ts
@@ -7,8 +7,7 @@
import { mkdir, writeFile } from "node:fs/promises";
import { test, expect } from "@playwright/test";
import { pathExists, resetData, resolvePath } from "./helpers";
-
-const baseUrl = "http://localhost:3011";
+import { baseUrl } from "./baseUrl";
async function invalidateCache() {
await fetch(`${baseUrl}/api/test/invalidate-cache`).catch(() => {});
diff --git a/editor/e2e/media-file-abort.spec.ts b/editor/e2e/media-file-abort.spec.ts
@@ -7,8 +7,7 @@
import { test, expect } from "@playwright/test";
import { resetData } from "./helpers";
-
-const baseUrl = "http://localhost:3011";
+import { baseUrl } from "./baseUrl";
async function readUncaughtCount(): Promise<{
uncaught: number;
diff --git a/editor/e2e/skip-live.spec.ts b/editor/e2e/skip-live.spec.ts
@@ -9,11 +9,11 @@ import {
resolvePath,
writeSettings,
} from "./helpers";
+import { baseUrl } from "./baseUrl";
const CHANNEL = "test-live";
const ROOT = `test-transcripts/channels/${CHANNEL}`;
const here = path.dirname(fileURLToPath(import.meta.url));
-const baseUrl = process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3011";
type DownloadOutcome = {
status: string;
diff --git a/editor/package.json b/editor/package.json
@@ -4,11 +4,11 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "next dev --port 3001",
- "dev:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 next dev --port 3011",
- "start:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 next start --port 3011",
+ "dev": "next dev --port ${EDITOR_PORT:-3001}",
+ "dev:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 next dev --port ${PORT:-3011}",
+ "start:test": "TRANSCRIPTS_DIR=$(pwd)/test-transcripts EXPORT_PUBLIC_DIR=$(pwd)/test-transcripts/.export-public SETTINGS_FILE=$(pwd)/test-settings.json YTDLP_BIN=$(pwd)/e2e/fixtures/bin/fake-ytdlp.mjs WHISPER_BIN=$(pwd)/e2e/fixtures/bin/fake-whisper.mjs WHISPER_MODEL=/dev/null CHOUGH_BIN=$(pwd)/e2e/fixtures/bin/fake-chough.mjs CHOUGH_MODEL=/dev/null PARAKEET_STITCH_BIN=$(pwd)/e2e/fixtures/bin/fake-parakeet-stitch.mjs PARAKEET_CLI=/dev/null PARAKEET_MODEL=/dev/null FFMPEG_BIN=$(pwd)/e2e/fixtures/bin/fake-ffmpeg.mjs AUDIO_CHECK_INTERVAL_MS_OVERRIDE=300 AUDIO_CHECK_SIZE_GATE_OVERRIDE=4096 next start --port ${PORT:-3011}",
"build": "next build",
- "start": "next start --port 3001",
+ "start": "next start --port ${EDITOR_PORT:-3001}",
"lint": "eslint",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui"
diff --git a/editor/playwright.config.ts b/editor/playwright.config.ts
@@ -3,6 +3,10 @@ import { defineConfig, devices } from "@playwright/test";
const PORT = Number(process.env.PORT ?? 3011);
const baseURL = `http://localhost:${PORT}`;
+// Expose the assigned base URL to node-side spec code (fetches to the editor's
+// test API). Keeps offset-port worktrees working even when run directly without
+// the worktree wrapper. See e2e/baseUrl.ts.
+process.env.PLAYWRIGHT_BASE_URL = baseURL;
const webServerCommand =
process.env.E2E_MODE === "start" ? "pnpm start:test" : "pnpm dev:test";
diff --git a/export/package.json b/export/package.json
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
- "dev": "next dev",
+ "dev": "next dev --port ${EXPORT_DEV_PORT:-3000}",
"build:index": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/build-index.ts",
"build:stats": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/build-stats.ts",
"build:templates": "tsx ../common/bin/build-chart-templates.ts",
diff --git a/package.json b/package.json
@@ -7,9 +7,11 @@
"build:index": "pnpm --filter yt-dlp-transcript-common exec tsx bin/build-index.ts",
"build:export": "pnpm --filter export run build",
"build": "pnpm --filter export run build",
- "start:export": "pnpm --filter export run start",
- "dev:editor": "pnpm --filter editor run dev",
- "e2e": "pnpm --filter editor run e2e",
+ "start:export": "node scripts/worktree.mjs run -- pnpm --filter export run start",
+ "dev:editor": "node scripts/worktree.mjs run -- pnpm --filter editor run dev",
+ "dev:export": "node scripts/worktree.mjs run -- pnpm --filter export run dev",
+ "e2e": "node scripts/worktree.mjs run -- pnpm --filter editor run e2e",
+ "wt": "node scripts/worktree.mjs",
"e2e:sharded": "docker build -f Dockerfile.test -t yt-dlp-transcript-browser-e2e . && node scripts/run-sharded-e2e.mjs",
"lint": "pnpm --filter export run lint"
},
diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs
@@ -0,0 +1,302 @@
+#!/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";
+
+// Base ports (offset 0 == main worktree). Mirrors the hardcoded defaults in
+// editor/export package.json scripts and the Playwright configs.
+const PORT_BASES = {
+ EDITOR_PORT: 3001, // editor real dev/start
+ PORT: 3011, // editor test server + Playwright editor baseURL
+ EXPORT_PORT: 3010, // export server launched by editor e2e
+ EXPORT_DEV_PORT: 3000, // export real dev
+ EXPORT_E2E_PORT: 3020, // export's own Playwright suite
+};
+const OFFSET_STEP = 100;
+
+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"]);
+}
+
+function offsetForIndex(index) {
+ return index * OFFSET_STEP;
+}
+
+// Compute the port env map for a given offset. Does not consult process.env.
+function portsForOffset(offset) {
+ const env = {};
+ for (const [key, base] of Object.entries(PORT_BASES)) {
+ env[key] = String(base + offset);
+ }
+ env.PLAYWRIGHT_BASE_URL = `http://localhost:${PORT_BASES.PORT + offset}`;
+ return env;
+}
+
+// Index of the worktree containing `dir` (default: cwd) in the worktree list.
+function indexForDir(dir = process.cwd()) {
+ const trees = listWorktrees();
+ const target = realpath(dir);
+ for (let i = 0; i < trees.length; i++) {
+ const root = realpath(trees[i].path);
+ if (target === root || target.startsWith(root + path.sep)) return i;
+ }
+ return 0;
+}
+
+// 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;
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === "--from") from = args[++i];
+ else if (args[i] === "--share-data") shareData = true;
+ else positional.push(args[i]);
+ }
+ const branch = positional[0];
+ if (!branch) {
+ process.stderr.write("worktree add: usage: add <branch> [--from <ref>] [--share-data]\n");
+ return 1;
+ }
+
+ const main = mainRoot();
+ const name = branch.replace(/[^A-Za-z0-9._-]/g, "-");
+ const dir = path.join(path.dirname(main), name);
+
+ 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`);
+ }
+
+ const ports = portsForOffset(offsetForIndex(indexForDir(dir)));
+ process.stderr.write(`\nWorktree ready at ${dir}\n`);
+ process.stderr.write(
+ ` editor:${ports.EDITOR_PORT} test:${ports.PORT} export-dev:${ports.EXPORT_DEV_PORT} ` +
+ `export:${ports.EXPORT_PORT} export-e2e:${ports.EXPORT_E2E_PORT}\n`,
+ );
+ return 0;
+}
+
+function cmdRm(args) {
+ const force = args.includes("--force") || args.includes("-f");
+ const name = args.find((a) => a !== "--force" && a !== "-f");
+ if (!name) {
+ process.stderr.write("worktree rm: usage: rm <name|path> [--force]\n");
+ return 1;
+ }
+ const main = mainRoot();
+ const dir = path.isAbsolute(name) ? name : path.join(path.dirname(main), name);
+ // --share-data worktrees always carry an untracked .worktree-env, so git
+ // refuses a plain remove; --force handles that (and any other local files).
+ const rmArgs = ["worktree", "remove", ...(force ? ["--force"] : []), dir];
+ git(rmArgs, { stdio: "inherit" });
+ git(["worktree", "prune"], { stdio: "inherit" });
+ process.stderr.write(`Removed worktree ${dir}\n`);
+ return 0;
+}
+
+function usage() {
+ process.stderr.write(
+ `Usage: worktree <command>\n\n` +
+ ` ports [--shell fish|bash|posix] Print this worktree's assigned ports\n` +
+ ` run -- <cmd...> Run a command with ports injected into env\n` +
+ ` list List worktrees and their port blocks\n` +
+ ` add <branch> [--from <ref>] [--share-data] Create a pre-configured worktree\n` +
+ ` rm <name|path> [--force] Remove a worktree (--force if it has local files)\n`,
+ );
+}
+
+async function main() {
+ const [cmd, ...rest] = process.argv.slice(2);
+ switch (cmd) {
+ case "ports":
+ return cmdPorts(rest);
+ case "run":
+ return cmdRun(rest);
+ case "list":
+ return cmdList();
+ case "add":
+ return cmdAdd(rest);
+ case "rm":
+ case "remove":
+ return cmdRm(rest);
+ case undefined:
+ case "-h":
+ case "--help":
+ case "help":
+ usage();
+ return cmd === undefined ? 1 : 0;
+ default:
+ process.stderr.write(`worktree: unknown command '${cmd}'\n`);
+ usage();
+ return 1;
+ }
+}
+
+main()
+ .then((code) => process.exit(code ?? 0))
+ .catch((err) => {
+ process.stderr.write(`${err?.stack ?? err}\n`);
+ process.exit(1);
+ });