Skip to content
Open
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
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,22 @@ Before starting, `wtc` copies infrastructure files from main into the worktree:

### Env Injection

After copying `.env`, `wtc` appends an idempotent block with allocated port overrides:
After copying `.env`, `wtc` appends an idempotent block with the worktree's `COMPOSE_PROJECT_NAME` and allocated port overrides:

```bash
# existing .env content stays untouched...

# --- wtc port overrides ---
COMPOSE_PROJECT_NAME=myapp-wt-1-feature-auth
POSTGRES_PORT=25435
REDIS_PORT=26381
BACKEND_PORT=28001
FRONTEND_PORT=25174
# --- end wtc ---
```

Because `COMPOSE_PROJECT_NAME` lives in `.env`, external tooling (Makefiles, raw `docker compose ...` invocations, editor tasks, etc.) running inside a worktree automatically targets the correct project.

## Commands

### `wtc start [indices...]`
Expand Down Expand Up @@ -219,7 +222,24 @@ Extra files/directories to copy from main into each worktree on start. Use for g

### `envOverrides`

Additional env vars injected into `.env`. Supports `${VAR}` interpolation with allocated port values. Use when env vars depend on allocated ports (e.g. `VITE_API_URL`).
Additional env vars to inject into each worktree's `.env`. Values may reference the per-worktree allocated ports and project name as `${...}` placeholders — `wtc` substitutes them at inject time. You don't supply the values of these placeholders; they're computed by `wtc`.

Available placeholders:

- `${COMPOSE_PROJECT_NAME}` — the per-worktree Compose project (e.g. `myapp-wt-1-feature-auth`). Always injected automatically as a top-level line in the managed block; this placeholder is for referencing that value inside your own overrides.
- `${<SERVICE>_PORT}` — each allocated host port (e.g. `${BACKEND_PORT}`, `${POSTGRES_PORT}`).

Example:

```json
{
"envOverrides": {
"VITE_API_URL": "http://localhost:${BACKEND_PORT}",
"SENTRY_ENVIRONMENT": "dev-${COMPOSE_PROJECT_NAME}",
"LOG_PREFIX": "[${COMPOSE_PROJECT_NAME}] "
}
}
```

## MCP Server

Expand Down
1 change: 1 addition & 0 deletions src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function startCommand(indices: number[]): void {
injectPortOverrides(
`${wt.path}/.env`,
allocations,
project,
ctx.config.envOverrides,
);
log.success("Injected port overrides into .env");
Expand Down
1 change: 1 addition & 0 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function startWorktrees(indices: number[]): string {
injectPortOverrides(
`${wt.path}/.env`,
allocations,
project,
ctx.config.envOverrides,
);

Expand Down
17 changes: 11 additions & 6 deletions src/sync/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,25 @@ export function stripOverrideBlock(content: string): string {

export function buildOverrideBlock(
allocations: PortAllocation[],
projectName: string,
envOverrides?: Record<string, string>,
): string {
const lines: string[] = [BLOCK_START];
const lines: string[] = [BLOCK_START, `COMPOSE_PROJECT_NAME=${projectName}`];

const interpolations = new Map<string, string>([
["COMPOSE_PROJECT_NAME", projectName],
]);

const portValues = new Map<string, number>();
for (const a of allocations) {
lines.push(`${a.envVar}=${a.port}`);
portValues.set(a.envVar, a.port);
interpolations.set(a.envVar, String(a.port));
}

if (envOverrides) {
for (const [key, template] of Object.entries(envOverrides)) {
let value = template;
for (const [envVar, port] of portValues) {
value = value.replace(`\${${envVar}}`, String(port));
for (const [varName, varValue] of interpolations) {
value = value.replaceAll(`\${${varName}}`, varValue);
}
lines.push(`${key}=${value}`);
}
Expand All @@ -47,6 +51,7 @@ export function buildOverrideBlock(
export function injectPortOverrides(
envPath: string,
allocations: PortAllocation[],
projectName: string,
envOverrides?: Record<string, string>,
): void {
let content = "";
Expand All @@ -56,7 +61,7 @@ export function injectPortOverrides(

content = stripOverrideBlock(content);

const block = buildOverrideBlock(allocations, envOverrides);
const block = buildOverrideBlock(allocations, projectName, envOverrides);
const result = content.trimEnd() + "\n\n" + block + "\n";

fs.writeFileSync(envPath, result, "utf-8");
Expand Down
32 changes: 28 additions & 4 deletions tests/sync/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,30 +19,54 @@ const allocations: PortAllocation[] = [
},
];

const project = "myapp-wt-1-feature-auth";

describe("buildOverrideBlock", () => {
it("creates a delimited block with port assignments", () => {
const block = buildOverrideBlock(allocations);
it("creates a delimited block with the project name and port assignments", () => {
const block = buildOverrideBlock(allocations, project);
expect(block).toContain("# --- wtc port overrides ---");
expect(block).toContain(`COMPOSE_PROJECT_NAME=${project}`);
expect(block).toContain("BACKEND_PORT=28001");
expect(block).toContain("FRONTEND_PORT=25174");
expect(block).toContain("# --- end wtc ---");
});

it("always writes COMPOSE_PROJECT_NAME (required argument)", () => {
const block = buildOverrideBlock(allocations, project);
expect(block).toContain(`COMPOSE_PROJECT_NAME=${project}`);
});

it("interpolates envOverrides with port values", () => {
const block = buildOverrideBlock(allocations, {
const block = buildOverrideBlock(allocations, project, {
VITE_API_URL: "http://localhost:${BACKEND_PORT}",
});
expect(block).toContain("VITE_API_URL=http://localhost:28001");
});

it("handles multiple envOverrides", () => {
const block = buildOverrideBlock(allocations, {
const block = buildOverrideBlock(allocations, project, {
VITE_API_URL: "http://localhost:${BACKEND_PORT}",
VITE_APP_URL: "http://localhost:${FRONTEND_PORT}",
});
expect(block).toContain("VITE_API_URL=http://localhost:28001");
expect(block).toContain("VITE_APP_URL=http://localhost:25174");
});

it("interpolates ${COMPOSE_PROJECT_NAME} inside envOverrides", () => {
const block = buildOverrideBlock(allocations, project, {
STACK_LABEL: "stack:${COMPOSE_PROJECT_NAME}",
MIXED: "${COMPOSE_PROJECT_NAME}-${BACKEND_PORT}",
});
expect(block).toContain(`STACK_LABEL=stack:${project}`);
expect(block).toContain(`MIXED=${project}-28001`);
});

it("replaces every occurrence of an interpolation token", () => {
const block = buildOverrideBlock(allocations, project, {
DUP: "${BACKEND_PORT}/${BACKEND_PORT}",
});
expect(block).toContain("DUP=28001/28001");
});
});

describe("stripOverrideBlock", () => {
Expand Down