Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/operations/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ Put that value in the main checkout's gitignored `.env`:
T3CODE_DEV_AUTH_TOKEN=<the value generated above>
```

The `t3.json` Setup Worktree action links that file to each worktree's `.env`. The dev runner reads repository env files at startup. `.env.local` and inherited process
The `t3.json` Setup Worktree action links that file to each worktree's `.env`. Where symlinks are
not allowed, such as Windows without Developer Mode, it copies the file instead. A copy does not pick
up later changes; delete it and rerun setup to refresh it.
The dev runner reads repository env files at startup. `.env.local` and inherited process
Comment thread
only21mil marked this conversation as resolved.
Resolved
environment values override `.env`, so no per-worktree export is needed after setup.

For a manual worktree or launcher without that link, export the same fixed value instead:
Expand Down
86 changes: 86 additions & 0 deletions scripts/setup-worktree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @effect-diagnostics nodeBuiltinImport:off - Runs the real setup script against a fixture checkout.
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import { describe, expect, it } from "vite-plus/test";

// Windows without Developer Mode or admin rights rejects every symlink this way.
const DENY_SYMLINKS = `import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
fs.symlinkSync = () => {
throw Object.assign(new Error("EPERM: operation not permitted, symlink"), { code: "EPERM" });
};
syncBuiltinESMExports();
`;

describe("setup-worktree", () => {
it("copies env files when symlinks are denied and keeps the copies on later runs", () => {
const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-setup-worktree-"));
try {
const main = NodePath.join(root, "main");
const worktree = NodePath.join(root, "worktree");
const bin = NodePath.join(root, "bin");
for (const dir of [
NodePath.join(main, "infra", "relay"),
NodePath.join(worktree, "infra", "relay"),
NodePath.join(worktree, "node_modules"),
NodePath.join(worktree, "scripts"),
bin,
]) {
NodeFS.mkdirSync(dir, { recursive: true });
}
NodeFS.writeFileSync(NodePath.join(main, ".env"), "ROOT=1\n");
NodeFS.writeFileSync(NodePath.join(main, "infra", "relay", ".env"), "RELAY=1\n");
const script = NodePath.join(worktree, "scripts", "setup-worktree.ts");
NodeFS.copyFileSync(NodePath.join(import.meta.dirname, "setup-worktree.ts"), script);
NodeFS.writeFileSync(NodePath.join(worktree, "scripts", "package.json"), '{"type":"module"}');
// A no-op install keeps the run on the env-file step.
NodeFS.writeFileSync(NodePath.join(bin, "vp"), "#!/bin/sh\n", { mode: 0o755 });
NodeFS.writeFileSync(NodePath.join(bin, "vp.cmd"), "@exit /b 0\r\n");
NodeFS.writeFileSync(NodePath.join(worktree, "node_modules", ".modules.yaml"), "{}");

const setup = () => {
const run = NodeChildProcess.spawnSync(
process.execPath,
["--import", `data:text/javascript,${encodeURIComponent(DENY_SYMLINKS)}`, script],
{
cwd: worktree,
encoding: "utf8",
env: {
...process.env,
PATH: `${bin}${NodePath.delimiter}${process.env.PATH ?? ""}`,
T3CODE_PROJECT_ROOT: main,
},
},
);
expect(run.status, run.stderr).toBe(0);
};
const envFiles = () =>
[".env", NodePath.join("infra", "relay", ".env")].map((file) => {
const target = NodePath.join(worktree, file);
return {
symlink: NodeFS.lstatSync(target).isSymbolicLink(),
text: NodeFS.readFileSync(target, "utf8"),
};
});

setup();
expect(NodeFS.existsSync(NodePath.join(worktree, ".t3", "setup-done"))).toBe(true);
expect(envFiles()).toEqual([
{ symlink: false, text: "ROOT=1\n" },
{ symlink: false, text: "RELAY=1\n" },
]);

NodeFS.writeFileSync(NodePath.join(main, ".env"), "ROOT=2\n");
NodeFS.writeFileSync(NodePath.join(worktree, "infra", "relay", ".env"), "LOCAL=1\n");
setup();
expect(envFiles()).toEqual([
{ symlink: false, text: "ROOT=1\n" },
{ symlink: false, text: "LOCAL=1\n" },
]);
} finally {
NodeFS.rmSync(root, { recursive: true, force: true });
}
});
});
13 changes: 11 additions & 2 deletions scripts/setup-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ if (
);
}

// Worktrees link main-checkout env files; replace only symlinks so real files always survive.
// Worktrees link main-checkout env files, or copy them where Windows denies symlinks.
// Only symlinks are replaced, so real files always survive.
for (const file of ENV_FILES) {
const source = NodePath.join(projectRoot, file);
const sourceStat = NodeFS.lstatSync(source, { throwIfNoEntry: false });
Expand All @@ -112,5 +113,13 @@ for (const file of ENV_FILES) {
const existing = NodeFS.lstatSync(target, { throwIfNoEntry: false });
if (existing && !existing.isSymbolicLink()) continue;
if (existing) NodeFS.rmSync(target);
NodeFS.symlinkSync(source, target);
try {
NodeFS.symlinkSync(source, target);
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "EPERM")) throw error;
NodeFS.copyFileSync(source, target, NodeFS.constants.COPYFILE_EXCL);
process.stderr.write(
`Copied ${file}: symlinks are not allowed here. Delete it and rerun setup to pick up later changes to ${source}.\n`,
);
}
}
Loading