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...@@ -13,11 +13,13 @@ Use this skill for the web client. For iOS Simulator, Android Emulator, or physi
132. Choose a base directory that belongs only to the current worktree or test:132. Choose a base directory that belongs only to the current worktree or test:
14 - Use the repository's ignored `.t3` directory for reusable worktree-local state.14 - Use the repository's ignored `.t3` directory for reusable worktree-local state.
15 - Use `mktemp -d /tmp/t3code-test.XXXXXX` for disposable state and retain the printed absolute path.15 - 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.
174. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.174. Keep the terminal session alive and read the selected server port, web port, base directory, and pairing URL from its output.
1818
19Treat 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.19Treat 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
21The 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.23The 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
23## Preserve the environment while iterating25## Preserve the environment while iterating
...@@ -55,7 +57,7 @@ T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \...@@ -55,7 +57,7 @@ T3CODE_PORT=<server-port> node apps/server/src/bin.ts auth pairing create \
5557
56Use 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.58Use 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
60## Inspect or seed SQLite state62## Inspect or seed SQLite state
6163
AGENTS.md+4
...@@ -13,6 +13,10 @@...@@ -13,6 +13,10 @@
13 - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it.13 - Subagents must not independently launch dev servers or repeat integrated client verification unless their delegated task explicitly requires it.
14 - Stop dev servers, watchers, and other long-running verification processes when the focused verification is complete.14 - 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
16## Package Roles20## Package Roles
1721
18- `apps/server`: Node.js WebSocket server. Wraps Codex app-server (JSON-RPC over stdio), serves the React web app, and manages provider sessions.22- `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 @@...@@ -3,7 +3,8 @@
3- `vp run dev` — Starts contracts, server, and web in watch mode.3- `vp run dev` — Starts contracts, server, and web in watch mode.
4- `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`.4- `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`.
5- `vp run dev:web` — Starts just the Vite dev server for the web app.5- `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.
7- 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.8- 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.
8- Pass dev-runner flags directly after the root task name, for example:9- Pass dev-runner flags directly after the root task name, for example:
9 `vp run dev --home-dir /tmp/t3code-dev`10 `vp run dev --home-dir /tmp/t3code-dev`
packages/shared/package.json+4
...@@ -194,6 +194,10 @@...@@ -194,6 +194,10 @@
194 "./httpReadiness": {194 "./httpReadiness": {
195 "types": "./src/httpReadiness.ts",195 "types": "./src/httpReadiness.ts",
196 "import": "./src/httpReadiness.ts"196 "import": "./src/httpReadiness.ts"
197 },
198 "./devHome": {
199 "types": "./src/devHome.ts",
200 "import": "./src/devHome.ts"
197 }201 }
198 },202 },
199 "scripts": {203 "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>(...@@ -30,6 +30,13 @@ export const HostProcessEnvironment = Context.Reference<NodeJS.ProcessEnv>(
30 },30 },
31);31);
3232
33export const HostProcessWorkingDirectory = Context.Reference<string>(
34 "@t3tools/shared/hostProcess/HostProcessWorkingDirectory",
35 {
36 defaultValue: () => process.cwd(),
37 },
38);
39
33export const HostProcessExecutablePath = Context.Reference<string>(40export const HostProcessExecutablePath = Context.Reference<string>(
34 "@t3tools/shared/hostProcess/HostProcessExecutablePath",41 "@t3tools/shared/hostProcess/HostProcessExecutablePath",
35 {42 {
scripts/dev-runner.test.ts+117-1
...@@ -1,6 +1,14 @@...@@ -1,6 +1,14 @@
1// @effect-diagnostics nodeBuiltinImport:off - builds real worktree layouts on disk.
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * 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";
2import * as NetService from "@t3tools/shared/Net";6import * as NetService from "@t3tools/shared/Net";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";7import {
8 HostProcessEnvironment,
9 HostProcessPlatform,
10 HostProcessWorkingDirectory,
11} from "@t3tools/shared/hostProcess";
4import { assert, describe, it } from "@effect/vitest";12import { assert, describe, it } from "@effect/vitest";
5import * as ConfigProvider from "effect/ConfigProvider";13import * as ConfigProvider from "effect/ConfigProvider";
6import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
...@@ -629,5 +637,113 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -629,5 +637,113 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
629 assert.notInclude(error.message, "secret-token-value");637 assert.notInclude(error.message, "secret-token-value");
630 });638 });
631 });639 });
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 });
632 });748 });
633});749});
scripts/dev-runner.ts+20-5
...@@ -5,7 +5,8 @@ import * as NodeOS from "node:os";...@@ -5,7 +5,8 @@ import * as NodeOS from "node:os";
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6import * as NodeServices from "@effect/platform-node/NodeServices";6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as NetService from "@t3tools/shared/Net";7import * 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";
9import { resolveSpawnCommand } from "@t3tools/shared/shell";10import { resolveSpawnCommand } from "@t3tools/shared/shell";
10import * as Config from "effect/Config";11import * as Config from "effect/Config";
11import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
...@@ -245,7 +246,9 @@ export function createDevRunnerEnv({...@@ -245,7 +246,9 @@ export function createDevRunnerEnv({
245 return Effect.gen(function* () {246 return Effect.gen(function* () {
246 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;247 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
247 const webPort = BASE_WEB_PORT + webOffset;248 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;
249 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);252 const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir);
250 const isDesktopMode = mode === "dev:desktop";253 const isDesktopMode = mode === "dev:desktop";
251254
...@@ -505,12 +508,23 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -505,12 +508,23 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
505 });508 });
506509
507 const hostEnvironment = yield* HostProcessEnvironment;510 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);
508 const env = yield* createDevRunnerEnv({522 const env = yield* createDevRunnerEnv({
509 mode: input.mode,523 mode: input.mode,
510 baseEnv: hostEnvironment,524 baseEnv: hostEnvironment,
511 serverOffset,525 serverOffset,
512 webOffset,526 webOffset,
513 t3Home: input.t3Home,527 t3Home: resolvedT3Home,
514 browser: input.browser,528 browser: input.browser,
515 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,529 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,
516 logWebSocketEvents: input.logWebSocketEvents,530 logWebSocketEvents: input.logWebSocketEvents,
...@@ -592,9 +606,10 @@ const devRunnerCli = Command.make("dev-runner", {...@@ -592,9 +606,10 @@ const devRunnerCli = Command.make("dev-runner", {
592 ),606 ),
593 t3Home: Flag.string("home-dir").pipe(607 t3Home: Flag.string("home-dir").pipe(
594 Flag.withDescription(608 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.",
596 ),610 ),
597 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")),611 Flag.optional,
612 Flag.map(Option.getOrUndefined),
598 ),613 ),
599 browser: Flag.boolean("browser").pipe(614 browser: Flag.boolean("browser").pipe(
600 Flag.withDescription("Open a browser automatically (disabled by default for web dev)."),615 Flag.withDescription("Open a browser automatically (disabled by default for web dev)."),