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
145 changes: 145 additions & 0 deletions scripts/setup-worktree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// @effect-diagnostics nodeBuiltinImport:off
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";

import {
linkOrCopyEnvFile,
linkProjectEnvFiles,
resolveWorktreePaths,
} from "./setup-worktree.ts";

const makeTempDir = (prefix: string): string =>
NodeFs.mkdtempSync(NodePath.join(NodeOs.tmpdir(), prefix));

describe("setup-worktree", () => {
it("resolves worktree from env, falling back to cwd", () => {
expect(
resolveWorktreePaths({
T3CODE_PROJECT_ROOT: "C:\\repo",
T3CODE_WORKTREE_PATH: "C:\\repo-wt",
}),
).toEqual({
projectRoot: "C:\\repo",
worktree: "C:\\repo-wt",
});

const resolved = resolveWorktreePaths({ T3CODE_PROJECT_ROOT: "/repo" });
expect(resolved.projectRoot).toBe("/repo");
expect(resolved.worktree).toBe(process.cwd());
});

it("links or copies env files into the worktree", () => {
const root = makeTempDir("setup-worktree-root-");
const worktree = makeTempDir("setup-worktree-wt-");
try {
NodeFs.mkdirSync(NodePath.join(root, "infra", "relay"), { recursive: true });
NodeFs.writeFileSync(NodePath.join(root, ".env"), "ROOT=1\n", "utf8");
NodeFs.writeFileSync(NodePath.join(root, "infra", "relay", ".env"), "RELAY=1\n", "utf8");

const results = linkProjectEnvFiles({ projectRoot: root, worktree });
expect(results).toHaveLength(2);
for (const entry of results) {
expect(["linked", "copied"]).toContain(entry.result);
}
// Either linked or copied is fine; content must match.
expect(NodeFs.readFileSync(NodePath.join(worktree, ".env"), "utf8")).toBe("ROOT=1\n");
expect(NodeFs.readFileSync(NodePath.join(worktree, "infra", "relay", ".env"), "utf8")).toBe(
"RELAY=1\n",
);
} finally {
NodeFs.rmSync(root, { recursive: true, force: true });
NodeFs.rmSync(worktree, { recursive: true, force: true });
}
});

it("does not delete a worktree env file when the source is missing", () => {
const root = makeTempDir("setup-worktree-missing-root-");
const worktree = makeTempDir("setup-worktree-missing-wt-");
try {
const destination = NodePath.join(worktree, ".env");
NodeFs.writeFileSync(destination, "LOCAL=1\n", "utf8");

expect(
linkOrCopyEnvFile({
projectRoot: root,
worktree,
relativePath: ".env",
}),
).toBe("skipped-missing-source");
expect(NodeFs.readFileSync(destination, "utf8")).toBe("LOCAL=1\n");
} finally {
NodeFs.rmSync(root, { recursive: true, force: true });
NodeFs.rmSync(worktree, { recursive: true, force: true });
}
});

it("skips when project root and worktree are the same path", () => {
const root = makeTempDir("setup-worktree-same-");
try {
NodeFs.writeFileSync(NodePath.join(root, ".env"), "SAME=1\n", "utf8");
expect(
linkOrCopyEnvFile({
projectRoot: root,
worktree: root,
relativePath: ".env",
}),
).toBe("skipped-same-path");
} finally {
NodeFs.rmSync(root, { recursive: true, force: true });
}
});

it("skips when worktree is a symlink/junction to the project root", () => {
const root = makeTempDir("setup-worktree-alias-root-");
const parent = makeTempDir("setup-worktree-alias-parent-");
const alias = NodePath.join(parent, "wt-alias");
try {
NodeFs.writeFileSync(NodePath.join(root, ".env"), "ROOT=1\n", "utf8");
try {
NodeFs.symlinkSync(root, alias, "junction");
} catch {
// Junction/symlink may be denied; skip this platform-specific case.
return;
}
expect(
linkOrCopyEnvFile({
projectRoot: root,
worktree: alias,
relativePath: ".env",
}),
).toBe("skipped-same-path");
// Must not have replaced root/.env with a self-link/copy mess.
expect(NodeFs.readFileSync(NodePath.join(root, ".env"), "utf8")).toBe("ROOT=1\n");
} finally {
NodeFs.rmSync(alias, { force: true });
NodeFs.rmSync(root, { recursive: true, force: true });
NodeFs.rmSync(parent, { recursive: true, force: true });
}
});

it("skips non-file sources without touching the worktree env", () => {
const root = makeTempDir("setup-worktree-dir-root-");
const worktree = makeTempDir("setup-worktree-dir-wt-");
try {
NodeFs.mkdirSync(NodePath.join(root, ".env"));
const destination = NodePath.join(worktree, ".env");
NodeFs.writeFileSync(destination, "LOCAL=1\n", "utf8");

expect(
linkOrCopyEnvFile({
projectRoot: root,
worktree,
relativePath: ".env",
}),
).toBe("skipped-not-a-file");
expect(NodeFs.readFileSync(destination, "utf8")).toBe("LOCAL=1\n");
} finally {
NodeFs.rmSync(root, { recursive: true, force: true });
NodeFs.rmSync(worktree, { recursive: true, force: true });
}
});

});
204 changes: 204 additions & 0 deletions scripts/setup-worktree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
// @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists.
/**
* Worktree setup for t3.json, portable across POSIX and Windows shells.
*
* The previous inline command chained `ln -sf` and `$VAR` expansion, which
* fails under PowerShell/cmd when T3 types the setup script into a terminal
* (see ProjectSetupScriptRunner). This Node entrypoint:
* 1. installs deps (`vp i`)
* 2. links the project root's `.env` files into the worktree
* 3. warms the web dependency cache
*
* On Windows, symlink creation needs Developer Mode or elevation; when it is
* denied we fall back to copying (a copy won't track later edits to the root
* `.env`, so re-run this script after changing it).
*/
import * as NodeChildProcess from "node:child_process";
import * as NodeFs from "node:fs";
import * as NodePath from "node:path";
import * as NodeProcess from "node:process";
import * as NodeURL from "node:url";

export type EnvLinkResult =
| "linked"
| "copied"
| "skipped-same-path"
| "skipped-missing-source"
| "skipped-not-a-file";

export const ENV_LINK_RELATIVE_PATHS = [".env", NodePath.join("infra", "relay", ".env")] as const;

export function resolveWorktreePaths(env: NodeJS.ProcessEnv = NodeProcess.env): {
readonly projectRoot: string | undefined;
readonly worktree: string;
} {
return {
projectRoot: env.T3CODE_PROJECT_ROOT || undefined,
worktree: env.T3CODE_WORKTREE_PATH || NodeProcess.cwd(),
};
}

/** Resolve through symlinks/junctions when the path exists; else lexical resolve. */
export function resolvePathIdentity(path: string): string {
try {
return NodeFs.realpathSync(path);
} catch {
return NodePath.resolve(path);
}
}

/**
* Link `projectRoot/relativePath` into the worktree. Never deletes the
* destination when the source is missing (avoids wiping a local file if the
* root has no `.env` yet). Staging + rename keeps a locally edited worktree
* file if symlink/copy fails mid-flight.
*/
export function linkOrCopyEnvFile(input: {
readonly projectRoot: string;
readonly worktree: string;
readonly relativePath: string;
}): EnvLinkResult {
const source = NodePath.join(input.projectRoot, input.relativePath);
const destination = NodePath.join(input.worktree, input.relativePath);
// realpath so a worktree that is a symlink/junction to the project root is
// treated as the same path (resolve alone does not follow links).
if (resolvePathIdentity(source) === resolvePathIdentity(destination)) {
return "skipped-same-path";
}
if (!NodeFs.existsSync(source)) {
return "skipped-missing-source";
}
// Directories (or other non-files) must not be linked/copied over a .env —
// on POSIX, symlinkSync to a directory would otherwise "succeed".
if (!NodeFs.statSync(source).isFile()) {
return "skipped-not-a-file";
}

const destinationDir = NodePath.dirname(destination);
NodeFs.mkdirSync(destinationDir, { recursive: true });

// Same directory as destination so rename is same-volume (atomic on POSIX;
// on Windows we still only remove the old file after staging succeeds).
const staging = NodePath.join(
destinationDir,
`.${NodePath.basename(destination)}.${NodeProcess.pid}.${Date.now()}.tmp`,
);
const backup = `${staging}.bak`;

let result: "linked" | "copied";
try {
try {
NodeFs.symlinkSync(source, staging, "file");
result = "linked";
} catch {
NodeFs.copyFileSync(source, staging);
result = "copied";
console.log(`[setup-worktree] copied ${input.relativePath} (symlink unavailable)`);
}

let hadDestination = false;
try {
NodeFs.renameSync(destination, backup);
hadDestination = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}

try {
NodeFs.renameSync(staging, destination);
} catch (error) {
if (hadDestination) {
try {
NodeFs.renameSync(backup, destination);
} catch {
// Leave backup on disk for manual recovery.
}
}
throw error;
}

if (hadDestination) {
NodeFs.rmSync(backup, { force: true });
}
return result;
} catch (error) {
NodeFs.rmSync(staging, { force: true });
if (NodeFs.existsSync(backup) && !NodeFs.existsSync(destination)) {
try {
NodeFs.renameSync(backup, destination);
} catch {
// Leave backup on disk for manual recovery.
}
}
throw error;
}
}

export function linkProjectEnvFiles(input: {
readonly projectRoot: string;
readonly worktree: string;
readonly relativePaths?: readonly string[];
}): ReadonlyArray<{ readonly relativePath: string; readonly result: EnvLinkResult }> {
const relativePaths = input.relativePaths ?? ENV_LINK_RELATIVE_PATHS;
return relativePaths.map((relativePath) => ({
relativePath,
result: linkOrCopyEnvFile({
projectRoot: input.projectRoot,
worktree: input.worktree,
relativePath,
}),
}));
}

function run(command: string, args: readonly string[], cwd: string): void {
// shell: true so Windows resolves launcher shims (vp.cmd) the same way a
// typed terminal command would. Always pin cwd to the worktree — do not
// rely on the process already sitting there.
const result = NodeChildProcess.spawnSync(command, [...args], {
stdio: "inherit",
shell: true,
cwd,
env: NodeProcess.env,
});
if (result.error) {
console.error(`[setup-worktree] failed to spawn ${command}:`, result.error.message);
NodeProcess.exit(1);
}
if (result.status !== 0) {
NodeProcess.exit(result.status ?? 1);
}
}

export function runSetupWorktree(env: NodeJS.ProcessEnv = NodeProcess.env): void {
const { projectRoot, worktree } = resolveWorktreePaths(env);
if (!NodeFs.existsSync(worktree)) {
console.error(`[setup-worktree] worktree path does not exist: ${worktree}`);
NodeProcess.exit(1);
}

NodeProcess.chdir(worktree);

run("vp", ["i"], worktree);

if (projectRoot) {
for (const { relativePath, result } of linkProjectEnvFiles({ projectRoot, worktree })) {
if (result === "linked" || result === "copied") {
console.log(`[setup-worktree] ${result} ${relativePath}`);
}
}
} else {
console.warn("[setup-worktree] T3CODE_PROJECT_ROOT unset; skipping .env link");
}

run("node", [NodePath.join("apps", "web", "scripts", "warm-dep-cache.ts")], worktree);
}

const isExecutedDirectly =
typeof NodeProcess.argv[1] === "string" &&
NodeURL.pathToFileURL(NodePath.resolve(NodeProcess.argv[1])).href === import.meta.url;

if (isExecutedDirectly) {
runSetupWorktree();
}
2 changes: 1 addition & 1 deletion t3.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"scripts": [
{
"name": "Setup Worktree",
"command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env && node apps/web/scripts/warm-dep-cache.ts",
"command": "node scripts/setup-worktree.ts",
"icon": "configure",
"runOnWorktreeCreate": true
}
Expand Down
Loading