authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-26 15:26:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-26 15:26:59-07:00
loga17cbc3b4022b824cda9b199e4c53a8c9a608923
tree0b829c4933bebeb9f0f6d80bd034a4fda9b9a533
parent5719e8ac4020dda0e375ef61d044b61f55a0df8a
signature Signed by PGP key B5690EEEBB952194

feat(dev): keep worktree dev state isolated on T3 Code dev servers (#4555)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

9 files changed, 367 insertions(+), 9 deletions(-)

.agents/skills/test-t3-app/SKILL.md+4-2
......@@ -13,11 +13,13 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi
13132. Choose a base directory that belongs only to the current worktree or test:
1414 - Use the repository's ignored `.t3` directory for reusable worktree-local state.
1515 - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path.
163. Start the full web stack with `vp run dev --home-dir <base-dir>`.
163. Start the full web stack with `vp run dev`. In a linked worktree it defaults to that worktree's gitignored `.t3`; pass `--home-dir <base-dir>` only when the test needs a different isolated directory.
17174. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.
1818
1919Treat a base directory as disposable only when it was created or deliberately selected for the current test. Never delete or directly seed the shared `~/.t3` directory. Prefer starting with a new temporary base directory over clearing state of uncertain ownership.
2020
21The worktree-local default deliberately outranks an ambient `T3CODE_HOME`; do not pass the shared home through to a worktree dev server.
22
2123The dev runner disables browser auto-open by default. Do not pass `--browser` during automated testing: an automatically opened page can consume the one-time bootstrap token before the controlled browser uses it.
2224
2325## Preserve the environment while iterating
......@@ -55,7 +57,7 @@ T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \
5557
5658Use the `Pair URL` from this command once. Derive `<server-port>` and `<web-url>` from the current dev-runner output, including any automatically selected port offset. Setting `T3CODE_PORT` keeps the administrative CLI from probing for an unrelated free port.
5759
58Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets.
60Always pass `--dev-url` for a dev-runner environment so the generated pairing URL uses the current web origin. An explicit base directory stores runtime state in `<base-dir>/userdata`; the `<base-dir>/dev` fallback is only used by an implicit dev home. A worktree-local `.t3` counts as explicit, so its state lives in `<worktree>/.t3/userdata`. Use `auth pairing list` to inspect active token metadata; it intentionally cannot reveal token secrets.
5961
6062## Inspect or seed SQLite state
6163
AGENTS.md+4
......@@ -13,6 +13,10 @@
1313 - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it.
1414 - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete.
1515
16## Dev Servers
17
18- In a linked git worktree, dev state defaults to that worktree's gitignored `.t3`. This deliberately outranks an ambient `T3CODE_HOME`, which could otherwise select the installed app's live `~/.t3/userdata` database. An explicit `--home-dir` still wins.
19
1620## Package Roles
1721
1822- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.
docs/reference/scripts.md+2-1
......@@ -3,7 +3,8 @@
33- `vp run dev` — Starts contracts, server, and web in watch mode.
44- `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`.
55- `vp run dev:web` — Starts just the Vite dev server for the web app.
6- Dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir <path>` stores state under `<path>/userdata`; the base directory remains available for caches, worktrees, and other shared data.
6- Dev commands run from a linked **git worktree** default to that worktree's gitignored `.t3`, even when `T3CODE_HOME` is set, storing state in `<worktree>/.t3/userdata`. Pass `--home-dir <path>` to choose another isolated directory explicitly. Submodules are not worktrees and keep the normal precedence.
7- From the **main checkout**, dev commands implicitly use `~/.t3/dev`, keeping development state separate from `~/.t3/userdata`. An explicit `--home-dir <path>` stores state under `<path>/userdata`; the base directory remains available for caches, worktrees, and other shared data.
78- Web dev commands do not auto-open a browser. Open the one-time pairing URL printed by the server so the first browser navigation is authenticated. Set `T3CODE_NO_BROWSER=0` only when interactive auto-open is intentional.
89- Pass dev-runner flags directly after the root task name, for example:
910 `vp run dev --home-dir /tmp/t3code-dev`
packages/shared/package.json+4
......@@ -194,6 +194,10 @@
194194 "./httpReadiness": {
195195 "types": "./src/httpReadiness.ts",
196196 "import": "./src/httpReadiness.ts"
197 },
198 "./devHome": {
199 "types": "./src/devHome.ts",
200 "import": "./src/devHome.ts"
197201 }
198202 },
199203 "scripts": {
packages/shared/src/devHome.test.ts created+106
......@@ -0,0 +1,106 @@
1// @effect-diagnostics nodeBuiltinImport:off - builds real worktree layouts on disk.
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as NodeFS from "node:fs";
5import * as NodeOS from "node:os";
6import * as NodePath from "node:path";
7import * as Effect from "effect/Effect";
8
9import { resolveGitWorktreePath, resolveWorktreeT3Home } from "./devHome.ts";
10
11const makeRepo = (
12 kind:
13 | "worktree"
14 | "checkout"
15 | "bare"
16 | "submodule"
17 | "unreadable-git-file"
18 | "bare-repo-worktree"
19 | "custom-common-dir-worktree",
20) =>
21 Effect.acquireRelease(
22 Effect.sync(() => {
23 const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-devhome-"));
24 if (kind === "worktree") {
25 NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /elsewhere/.git/worktrees/x\n");
26 } else if (kind === "bare-repo-worktree") {
27 // `git worktree add` from a bare repo: the common dir is `<name>.git`.
28 NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /srv/myrepo.git/worktrees/x\n");
29 } else if (kind === "custom-common-dir-worktree") {
30 // $GIT_COMMON_DIR need not be named `.git` at all.
31 NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: /srv/store/worktrees/x\n");
32 } else if (kind === "submodule") {
33 NodeFS.writeFileSync(NodePath.join(root, ".git"), "gitdir: ../.git/modules/sub\n");
34 } else if (kind === "unreadable-git-file") {
35 NodeFS.writeFileSync(NodePath.join(root, ".git"), "not a gitdir pointer\n");
36 } else if (kind === "checkout") {
37 NodeFS.mkdirSync(NodePath.join(root, ".git"));
38 }
39 const nested = NodePath.join(root, "apps", "web", "src");
40 NodeFS.mkdirSync(nested, { recursive: true });
41 return { root, nested };
42 }),
43 ({ root }) => Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true })),
44 );
45
46describe("resolveGitWorktreePath", () => {
47 it.effect("finds a worktree root from a nested directory", () =>
48 Effect.gen(function* () {
49 const { root, nested } = yield* makeRepo("worktree");
50 assert.equal(yield* resolveGitWorktreePath(nested), NodePath.resolve(root));
51 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
52 );
53
54 it.effect("reports a main checkout as not a linked worktree", () =>
55 Effect.gen(function* () {
56 const { nested } = yield* makeRepo("checkout");
57 assert.equal(yield* resolveGitWorktreePath(nested), undefined);
58 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
59 );
60
61 it.effect("reports a directory outside a repository", () =>
62 Effect.gen(function* () {
63 const { nested } = yield* makeRepo("bare");
64 assert.equal(yield* resolveGitWorktreePath(nested), undefined);
65 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
66 );
67
68 it.effect("reports a submodule as not a linked worktree", () =>
69 Effect.gen(function* () {
70 const { nested } = yield* makeRepo("submodule");
71 assert.equal(yield* resolveGitWorktreePath(nested), undefined);
72 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
73 );
74
75 it.effect("reports a .git file without a usable gitdir pointer", () =>
76 Effect.gen(function* () {
77 const { nested } = yield* makeRepo("unreadable-git-file");
78 assert.equal(yield* resolveGitWorktreePath(nested), undefined);
79 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
80 );
81
82 it.effect("finds a worktree of a bare repository", () =>
83 Effect.gen(function* () {
84 const { root, nested } = yield* makeRepo("bare-repo-worktree");
85 assert.equal(yield* resolveGitWorktreePath(nested), NodePath.resolve(root));
86 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
87 );
88
89 it.effect("finds a worktree whose common dir is not named .git", () =>
90 Effect.gen(function* () {
91 const { root, nested } = yield* makeRepo("custom-common-dir-worktree");
92 assert.equal(yield* resolveGitWorktreePath(nested), NodePath.resolve(root));
93 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
94 );
95});
96
97describe("resolveWorktreeT3Home", () => {
98 it.effect("answers with .t3 before the dev runner creates it", () =>
99 Effect.gen(function* () {
100 const { root, nested } = yield* makeRepo("worktree");
101 const home = yield* resolveWorktreeT3Home(nested);
102 assert.equal(home, NodePath.join(NodePath.resolve(root), ".t3"));
103 assert.isFalse(NodeFS.existsSync(home ?? ""));
104 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
105 );
106});
packages/shared/src/devHome.ts created+103
......@@ -0,0 +1,103 @@
1/**
2 * Where development state lives, and how to keep it away from the shared
3 * `~/.t3` that a user's installed T3 Code runs against.
4 *
5 * A linked git worktree gets its own (gitignored) `.t3`: feature work in a
6 * throwaway branch must not share a database with the real app, and an ambient
7 * `T3CODE_HOME` counts as an explicit base dir — flipping the state directory
8 * from `<base>/dev` to `<base>/userdata`, the live production database.
9 */
10
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Option from "effect/Option";
14import * as Path from "effect/Path";
15
16/**
17 * A `.git` file points at the real git directory. A linked worktree's lives at
18 * `<common-dir>/worktrees/<name>`; a submodule's at
19 * `<super-git-dir>/modules/<name>`. Both are files, so the pointer — not the
20 * file-vs-directory distinction alone — is what identifies a worktree.
21 *
22 * The common dir is not necessarily named `.git`: a worktree of a bare repo
23 * points at `<repo>.git/worktrees/<name>`, and `$GIT_COMMON_DIR` can be
24 * anything. So match on the `worktrees/<name>` tail, which git always uses,
25 * rather than on the name of the directory containing it.
26 */
27const pointsAtLinkedWorktree = (gitFileContents: string, path: Path.Path): boolean => {
28 const gitdir = gitFileContents
29 .split(/\r?\n/)
30 .map((line) => line.trim())
31 .find((line) => line.startsWith("gitdir:"))
32 ?.slice("gitdir:".length)
33 .trim();
34 if (gitdir === undefined || gitdir.length === 0) {
35 return false;
36 }
37 // Compare as path segments so a directory merely named `…worktrees…` cannot
38 // match as a substring. Trailing separators normalize away first.
39 const segments = path
40 .normalize(gitdir.replaceAll("\\", "/"))
41 .split(/[/\\]/)
42 .filter((segment) => segment.length > 0);
43 // `<common-dir>/worktrees/<name>`: `worktrees` is the penultimate segment,
44 // and something must precede it. This excludes `<git-dir>/modules/<name>`.
45 return segments.length >= 3 && segments.at(-2) === "worktrees";
46};
47
48/**
49 * The path of the linked git worktree containing `cwd`, or undefined when
50 * `cwd` is not inside one. Git marks a linked worktree by making `.git` a file
51 * whose `gitdir:` points into the repository's `.git/worktrees`.
52 *
53 * Walks up to the repository root, so running from a subdirectory resolves the
54 * same worktree as running from the top.
55 */
56export const resolveGitWorktreePath = (
57 cwd: string,
58): Effect.Effect<string | undefined, never, FileSystem.FileSystem | Path.Path> =>
59 Effect.gen(function* () {
60 const fileSystem = yield* FileSystem.FileSystem;
61 const path = yield* Path.Path;
62
63 let directory = path.resolve(cwd);
64 for (;;) {
65 const gitPath = path.join(directory, ".git");
66 const info = yield* fileSystem.stat(gitPath).pipe(Effect.option);
67 if (Option.isSome(info)) {
68 // A directory means the main checkout. Stop either way: nesting one
69 // repository inside another does not make the outer one this root.
70 if (info.value.type !== "File") {
71 return undefined;
72 }
73 // A submodule also has a `.git` file, but it is not a worktree of this
74 // repository and gets no worktree-local home.
75 const contents = yield* fileSystem
76 .readFileString(gitPath)
77 .pipe(Effect.orElseSucceed(() => ""));
78 return pointsAtLinkedWorktree(contents, path) ? directory : undefined;
79 }
80 const parent = path.dirname(directory);
81 if (parent === directory) {
82 return undefined;
83 }
84 directory = parent;
85 }
86 });
87
88/**
89 * The worktree-local data directory for `cwd`, or undefined outside a linked
90 * worktree. Deliberately does not require the directory to exist yet: falling
91 * back because it is missing would send callers at the shared home.
92 */
93export const resolveWorktreeT3Home = (
94 cwd: string,
95): Effect.Effect<string | undefined, never, FileSystem.FileSystem | Path.Path> =>
96 Effect.gen(function* () {
97 const worktreePath = yield* resolveGitWorktreePath(cwd);
98 if (worktreePath === undefined) {
99 return undefined;
100 }
101 const path = yield* Path.Path;
102 return path.join(worktreePath, ".t3");
103 });
packages/shared/src/hostProcess.ts+7
......@@ -30,6 +30,13 @@ export const HostProcessEnvironment = Context.Reference<NodeJS.ProcessEnv>(
3030 },
3131);
3232
33export const HostProcessWorkingDirectory = Context.Reference<string>(
34 "@t3tools/shared/hostProcess/HostProcessWorkingDirectory",
35 {
36 defaultValue: () => process.cwd(),
37 },
38);
39
3340export const HostProcessExecutablePath = Context.Reference<string>(
3441 "@t3tools/shared/hostProcess/HostProcessExecutablePath",
3542 {
scripts/dev-runner.test.ts+117-1
......@@ -1,6 +1,14 @@
1// @effect-diagnostics nodeBuiltinImport:off - builds real worktree layouts on disk.
12import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
26import * as NetService from "@t3tools/shared/Net";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
7import {
8 HostProcessEnvironment,
9 HostProcessPlatform,
10 HostProcessWorkingDirectory,
11} from "@t3tools/shared/hostProcess";
412import { assert, describe, it } from "@effect/vitest";
513import * as ConfigProvider from "effect/ConfigProvider";
614import * as Effect from "effect/Effect";
......@@ -629,5 +637,113 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
629637 assert.notInclude(error.message, "secret-token-value");
630638 });
631639 });
640
641 describe("t3 home precedence", () => {
642 const makeWorktree = Effect.acquireRelease(
643 Effect.sync(() => {
644 const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-devrunner-"));
645 NodeFS.writeFileSync(
646 NodePath.join(root, ".git"),
647 "gitdir: /elsewhere/.git/worktrees/x\n",
648 );
649 return root;
650 }),
651 (root) => Effect.sync(() => NodeFS.rmSync(root, { recursive: true, force: true })),
652 );
653
654 const spawnedHome = (input: {
655 readonly t3Home: string | undefined;
656 readonly cwd: string;
657 readonly ambientHome: string | undefined;
658 }) =>
659 Effect.gen(function* () {
660 let captured: Record<string, string | undefined> | undefined;
661 const spawnerLayer = Layer.succeed(
662 ChildProcessSpawner.ChildProcessSpawner,
663 ChildProcessSpawner.make((command) => {
664 captured = (
665 command as {
666 readonly options?: { readonly env?: Record<string, string | undefined> };
667 }
668 ).options?.env;
669 return Effect.succeed(mockProcess(0));
670 }),
671 );
672
673 yield* runDevRunnerWithInput({ ...devServerInput, t3Home: input.t3Home }).pipe(
674 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
675 Effect.provideService(HostProcessPlatform, "linux"),
676 Effect.provideService(HostProcessWorkingDirectory, input.cwd),
677 Effect.provideService(
678 HostProcessEnvironment,
679 input.ambientHome === undefined ? {} : { T3CODE_HOME: input.ambientHome },
680 ),
681 );
682
683 return captured?.T3CODE_HOME;
684 });
685
686 it.effect("prefers an explicit --home-dir over the worktree default", () =>
687 Effect.gen(function* () {
688 const path = yield* Path.Path;
689 const root = yield* makeWorktree;
690 const home = yield* spawnedHome({
691 t3Home: "/tmp/explicit-home",
692 cwd: root,
693 ambientHome: "/home/user/.t3",
694 });
695 assert.equal(home, path.resolve("/tmp/explicit-home"));
696 }).pipe(Effect.scoped),
697 );
698
699 it.effect("treats a blank --home-dir as unset rather than as a selection", () =>
700 Effect.gen(function* () {
701 const path = yield* Path.Path;
702 const root = yield* makeWorktree;
703 const home = yield* spawnedHome({
704 t3Home: " ",
705 cwd: root,
706 ambientHome: "/home/user/.t3",
707 });
708 assert.equal(home, path.join(path.resolve(root), ".t3"));
709 }).pipe(Effect.scoped),
710 );
711
712 it.effect("prefers the worktree .t3 over an ambient T3CODE_HOME", () =>
713 Effect.gen(function* () {
714 const path = yield* Path.Path;
715 const root = yield* makeWorktree;
716 const home = yield* spawnedHome({
717 t3Home: undefined,
718 cwd: root,
719 ambientHome: "/home/user/.t3",
720 });
721 assert.equal(home, path.join(path.resolve(root), ".t3"));
722 }).pipe(Effect.scoped),
723 );
724
725 it.effect("falls back to an ambient T3CODE_HOME outside a worktree", () =>
726 Effect.gen(function* () {
727 const path = yield* Path.Path;
728 const home = yield* spawnedHome({
729 t3Home: undefined,
730 cwd: NodeOS.tmpdir(),
731 ambientHome: "/home/user/.t3",
732 });
733 assert.equal(home, path.resolve("/home/user/.t3"));
734 }),
735 );
736
737 it.effect("leaves the home implicit with no worktree and no ambient value", () =>
738 Effect.gen(function* () {
739 const home = yield* spawnedHome({
740 t3Home: undefined,
741 cwd: NodeOS.tmpdir(),
742 ambientHome: undefined,
743 });
744 assert.equal(home, undefined);
745 }),
746 );
747 });
632748 });
633749});
scripts/dev-runner.ts+20-5
......@@ -5,7 +5,8 @@ import * as NodeOS from "node:os";
55import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import * as NetService from "@t3tools/shared/Net";
8import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
8import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
9import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
910import { resolveSpawnCommand } from "@t3tools/shared/shell";
1011import * as Config from "effect/Config";
1112import * as Effect from "effect/Effect";
......@@ -245,7 +246,9 @@ export function createDevRunnerEnv({
245246 return Effect.gen(function* () {
246247 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
247248 const webPort = BASE_WEB_PORT + webOffset;
248 const configuredBaseDir = t3Home?.trim() || baseEnv.T3CODE_HOME?.trim() || undefined;
249 // Precedence is resolved by the caller. An unset t3Home here genuinely
250 // means "use the default" rather than inheriting an ambient value.
251 const configuredBaseDir = t3Home?.trim() || undefined;
249252 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);
250253 const isDesktopMode = mode === "dev:desktop";
251254
......@@ -505,12 +508,23 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
505508 });
506509
507510 const hostEnvironment = yield* HostProcessEnvironment;
511 // A worktree defaults to its own gitignored `.t3`. This deliberately
512 // outranks ambient T3CODE_HOME, which otherwise selects the installed
513 // app's live userdata database. An explicit --home-dir still wins.
514 const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcessWorkingDirectory);
515 // Trim before choosing: `--home-dir ""` is not a selection, and treating it
516 // as one would skip the worktree default and land on the shared home —
517 // exactly the outcome this precedence exists to prevent.
518 const resolvedT3Home =
519 (input.t3Home?.trim() || undefined) ??
520 worktreeHome ??
521 (hostEnvironment.T3CODE_HOME?.trim() || undefined);
508522 const env = yield* createDevRunnerEnv({
509523 mode: input.mode,
510524 baseEnv: hostEnvironment,
511525 serverOffset,
512526 webOffset,
513 t3Home: input.t3Home,
527 t3Home: resolvedT3Home,
514528 browser: input.browser,
515529 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,
516530 logWebSocketEvents: input.logWebSocketEvents,
......@@ -592,9 +606,10 @@ const devRunnerCli = Command.make("dev-runner", {
592606 ),
593607 t3Home: Flag.string("home-dir").pipe(
594608 Flag.withDescription(
595 "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).",
609 "Explicit T3 Code data directory; runtime state is stored under userdata. Inside a git worktree this defaults to that worktree's own .t3.",
596610 ),
597 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")),
611 Flag.optional,
612 Flag.map(Option.getOrUndefined),
598613 ),
599614 browser: Flag.boolean("browser").pipe(
600615 Flag.withDescription("Open a browser automatically (disabled by default for web dev)."),