Skip to content
Merged
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
66 changes: 62 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Each worktree gets a numbered slot. The slot determines everything:
- **Database**: Created via `CREATE DATABASE ... TEMPLATE` (fast filesystem copy, not dump/restore)
- **Docker services**: Run in a dedicated Docker Compose project per worktree, grouped in Docker Desktop
- **Ports**: Offset by `portStride` (default 100) per slot
- **Env files**: Copied from main worktree and patched with the slot's values
- **Env files**: Copied from main worktree, filled with safe defaults from configured `.env.example` files, and patched with the slot's values

## Quick Start

Expand Down Expand Up @@ -105,6 +105,11 @@ Create this file in your repository root and commit it. See [Configuration Refer
]
}
],
"seedEnvFiles": [
{ "source": ".env.example", "target": ".env" },
{ "source": "backend/.env.example", "target": "backend/.env" },
{ "source": "frontend/.env.example", "target": "frontend/.env" }
],
"postSetup": ["npm install"],
"autoInstall": true
}
Expand Down Expand Up @@ -132,6 +137,10 @@ wt list
# Check health
wt doctor

# Create missing .env files from examples and add missing safe defaults
wt env seed --dry-run
wt env seed

# Clean up by path or slot
wt remove .worktrees/feat-my-feature

Expand Down Expand Up @@ -159,7 +168,7 @@ Creates a new git worktree and sets up its isolated environment:
1. Allocates the next available slot (or uses `--slot N`)
2. Checks whether `origin/<branch>` exists; if it does, fetches it and creates a tracking local branch, otherwise creates a fresh local branch
3. Creates a new Postgres database from the main DB as template
4. Copies all configured `.env` files, patching each with slot-specific values
4. Copies configured `.env` files, fills missing safe defaults from examples, and patches each with slot-specific values
5. Starts configured Docker services after the slot database exists
6. Runs `postSetup` commands (unless `--no-install`)

Expand Down Expand Up @@ -221,6 +230,20 @@ Docker services to recreate: redis

`--dry-run` requires `--repair`; using it alone errors out.

### `wt env seed [path] [--dry-run] [--json]`

Creates missing local env files from configured examples and adds any variables that are present in the example but missing from the local target. Existing developer values are never overwritten.

Use this for the root worktree as well as branch worktrees:

```bash
wt env seed --dry-run # preview changes for the current checkout
wt env seed # create/fill configured env files
wt env seed .worktrees/foo # target a specific worktree
```

`wt new` and `wt setup` run the same seed pass automatically after copying env files from the main worktree and before applying slot-specific patches.

### `wt remove <targets...> [--all] [--keep-db] [--json]`

Removes a worktree and cleans up its resources:
Expand Down Expand Up @@ -349,6 +372,16 @@ This file lives in your repository root and is committed to version control.
}
],

// Safe example env files to merge into local env files (default: []).
// Missing targets are created from the example. Existing targets keep
// developer values; only missing vars are appended.
"seedEnvFiles": [
{
"source": string, // Example path relative to the target checkout
"target": string // Local env path relative to the target checkout
}
],

// Commands to run in the worktree after env setup (default: [])
"postSetup": string[],

Expand All @@ -372,6 +405,27 @@ The `port` and `url` types require a `service` field that matches a name in `ser

Legacy `type: "redis"` patches are no longer supported. Declare Redis in `dockerServices` and patch `REDIS_URL` with `type: "url"` instead.

### Env Seeding

`seedEnvFiles` is for committed safe defaults. Each entry maps a checked-in example file to the local env file that should receive those defaults:

```json
{
"seedEnvFiles": [
{ "source": ".env.example", "target": ".env" },
{ "source": "server/.env.example", "target": "server/.env" }
]
}
```

Rules:

- If `target` does not exist, it is created from `source`.
- If `target` exists, existing values are preserved.
- Variables present in `source` but missing from `target` are appended under a generated marker.
- Variables removed from `source` are not removed from `target`.
- Placeholder or blank values from examples are copied exactly; `wt` does not infer secrets.

### `.worktree-registry.json`

Auto-managed file at the repo root. **Add to `.gitignore`** — it's machine-local.
Expand Down Expand Up @@ -451,7 +505,8 @@ Identify these from the repository:
- **Redis URL format**: Search for `REDIS_URL`. If Redis should be per-worktree, declare Redis in both `services` and `dockerServices`, then patch `REDIS_URL` with `type: "url"`.
- **Services and ports**: Find all dev server commands and their default ports. Check `package.json` scripts, existing Docker Compose files, and framework configs.
- **Docker services**: Move per-worktree containers from Docker Compose files into `dockerServices`.
- **Env files**: List all `.env` files (not `.env.example`). These are the files that need patching.
- **Env files**: List all `.env` files that need slot-specific patching.
- **Seed env files**: List all committed `.env.example` files that contain safe local defaults and map each one to its target `.env`.

### Step 2: Map env vars to patch types

Expand All @@ -475,12 +530,14 @@ Using the discovered information, construct the config:
2. services = each dev server as { name, defaultPort }
3. dockerServices = each per-worktree container, with ports referencing `services`
4. envFiles = each .env file with its patches
5. postSetup = the install command for the package manager (npm install, pnpm install, etc.)
5. seedEnvFiles = each safe example mapped to its local env target
6. postSetup = the install command for the package manager (npm install, pnpm install, etc.)
```

Validate that:
- Every `port` and `url` patch has a `service` that exists in `services`
- Every `dockerServices[].ports[].service` exists in `services`
- Every `seedEnvFiles[].source` is a committed example file with safe local defaults
- If using `dockerServices`, Docker is available locally
- The `portStride` (default 100) doesn't cause port collisions with other local services
- `maxSlots * portStride` doesn't push ports into reserved ranges (e.g., above 65535)
Expand All @@ -497,6 +554,7 @@ echo ".worktree-registry.json" >> .gitignore
# Verify
wt list # Should show "No worktree allocations found."
wt doctor # Should show "All checks passed."
wt env seed --dry-run

# Smoke test (creates a real worktree + database)
wt new test/wt-smoke --no-install
Expand Down
30 changes: 28 additions & 2 deletions skills/wt/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: wt
description: Manage git worktree isolation — create, list, remove, and prune worktrees with isolated databases, Docker services, and ports
argument-hint: "[new|open|list|remove|prune|doctor|setup|init] [args...]"
description: Manage git worktree isolation — create, list, remove, prune, and seed env files for worktrees with isolated databases, Docker services, and ports
argument-hint: "[new|open|list|remove|prune|doctor|setup|env|init] [args...]"
allowed-tools: Bash, Read, Write, Edit, Grep, Glob
---

Expand All @@ -21,6 +21,7 @@ The user wants to set up `wt` in their project for the first time. Follow these

Search the repository to find:
- All `.env` files (not `.env.example`): `find . -name '.env' -not -path '*/node_modules/*' -not -path '*/.git/*'`
- All committed `.env.example` files with safe local defaults: `find . -name '.env.example' -not -path '*/node_modules/*' -not -path '*/.git/*'`
- The `DATABASE_URL` value to extract the base database name (path segment after the port, before `?`)
- Any `REDIS_URL` values and their base port/auth format
- All services and their default ports — check `package.json` scripts, Docker Compose files, framework config files
Expand Down Expand Up @@ -68,6 +69,9 @@ Build the config file at the repository root:
]
}
],
"seedEnvFiles": [
{ "source": "<relative path to .env.example>", "target": "<relative path to .env>" }
],
"postSetup": ["<install command>"],
"autoInstall": true
}
Expand All @@ -76,6 +80,7 @@ Build the config file at the repository root:
Validation rules:
- Every `port` and `url` patch must have a `service` that exists in `services`
- Every `dockerServices[].ports[].service` must exist in `services`
- Every `seedEnvFiles[].source` should be a committed example file with safe local defaults
- `portStride` * `maxSlots` + max default port must be < 65535
- `baseDatabaseName` must match the actual DB name in `DATABASE_URL`
- If using `dockerServices`, Docker must be available locally
Expand All @@ -99,6 +104,7 @@ Add `.worktree-registry.json` if not already present.
"scripts": {
"wt": "wt",
"wt:new": "wt new",
"wt:env": "wt env seed",
"wt:list": "wt list",
"wt:doctor": "wt doctor"
}
Expand Down Expand Up @@ -139,6 +145,7 @@ Make it executable: `chmod +x .husky/post-checkout`
```bash
wt list # Should show "No worktree allocations found."
wt doctor # Should show "All checks passed."
wt env seed --dry-run # Should preview safe env default updates
wt new test/wt-smoke --no-install # Create a test worktree
wt list # Should show the allocation
wt remove .worktrees/test-wt-smoke # Clean up
Expand Down Expand Up @@ -246,6 +253,24 @@ Variants:

---

### `env seed [path]` — Seed safe env defaults

Run:
```bash
wt env seed $1
```

Use this in the root worktree or any branch worktree when `.env.example` files have gained new safe local-development variables. It creates missing configured `.env` targets from examples and appends only variables that are missing from existing targets. It never overwrites developer values.

Variants:

- `wt env seed --dry-run` — preview files that would be created and vars that would be appended.
- `wt env seed --json` — output machine-readable results.

`wt new` and `wt setup` run this same seed pass automatically after copying env files from the main worktree and before applying slot-specific patches.

---

### No arguments or unrecognized command

Show a brief help:
Expand All @@ -260,4 +285,5 @@ Available commands:
/wt prune [--dry-run] — Prune Git-prunable worktrees and clean up matching managed resources
/wt doctor — Diagnose and fix environment issues
/wt setup [path] — Set up an existing worktree
/wt env seed [path] — Create/fill safe env defaults from examples
```
18 changes: 18 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { pruneCommand } from './commands/prune';
import { listCommand } from './commands/list';
import { doctorCommand } from './commands/doctor';
import { openCommand } from './commands/open';
import { envSeedCommand } from './commands/env';
import { getMainWorktreePath } from './core/git';
import { name, version } from '../package.json';
import { getUpdateNotice, refreshUpdateCache, isCacheFresh } from './core/update-check';
Expand Down Expand Up @@ -51,6 +52,23 @@ program
});
});

program
.command('env')
.description('Manage local env files')
.addCommand(
new Command('seed')
.description('Create configured env files from examples and fill missing vars')
.argument('[path]', 'Repo or worktree path (default: current directory)')
.option('--dry-run', 'Preview env file changes without writing', false)
.option('--json', 'Output as JSON', false)
.action((targetPath: string | undefined, opts) => {
envSeedCommand(targetPath, {
json: opts.json,
dryRun: opts.dryRun,
});
}),
);

program
.command('open')
.description('Open a worktree by slot or branch (creates if not found)')
Expand Down
118 changes: 118 additions & 0 deletions src/commands/env.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

jest.mock('../core/git', () => ({
getMainWorktreePath: jest.fn(),
}));

jest.mock('../core/env-patcher', () => ({
seedEnvFiles: jest.fn(),
}));

jest.mock('./setup', () => ({
loadConfig: jest.fn(),
}));

import { getMainWorktreePath } from '../core/git';
import { seedEnvFiles } from '../core/env-patcher';
import { loadConfig } from './setup';
import { envSeedCommand } from './env';
import type { WtConfig } from '../types';

const mockGetMainWorktreePath = getMainWorktreePath as jest.MockedFunction<typeof getMainWorktreePath>;
const mockSeedEnvFiles = seedEnvFiles as jest.MockedFunction<typeof seedEnvFiles>;
const mockLoadConfig = loadConfig as jest.MockedFunction<typeof loadConfig>;

describe('env seed command', () => {
let tmpDir: string;
let targetDir: string;
let consoleLogSpy: jest.SpiedFunction<typeof console.log>;
let stderrSpy: jest.SpiedFunction<typeof process.stderr.write>;

const config: WtConfig = {
baseDatabaseName: 'myapp',
baseWorktreePath: '.worktrees',
portStride: 100,
maxSlots: 50,
services: [{ name: 'web', defaultPort: 3000 }],
dockerServices: [],
envFiles: [],
seedEnvFiles: [{ source: '.env.example', target: '.env' }],
postSetup: [],
autoInstall: true,
};

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-env-command-'));
targetDir = path.join(tmpDir, '.worktrees', 'feat-env');
fs.mkdirSync(targetDir, { recursive: true });
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
mockGetMainWorktreePath.mockReturnValue(tmpDir);
mockLoadConfig.mockReturnValue(config);
mockSeedEnvFiles.mockReturnValue({
dryRun: false,
changed: true,
files: [
{
source: '.env.example',
target: '.env',
created: true,
addedVars: ['DATABASE_URL'],
},
],
});
process.exitCode = 0;
});

afterEach(() => {
consoleLogSpy.mockRestore();
stderrSpy.mockRestore();
fs.rmSync(tmpDir, { recursive: true, force: true });
jest.clearAllMocks();
});

it('seeds env files for any target path, including the main worktree', () => {
envSeedCommand(tmpDir, { json: true, dryRun: false });

expect(mockLoadConfig).toHaveBeenCalledWith(tmpDir);
expect(mockSeedEnvFiles).toHaveBeenCalledWith(
config.seedEnvFiles,
tmpDir,
{ dryRun: false },
);
const payload = JSON.parse(consoleLogSpy.mock.calls[0]?.[0] ?? 'null') as {
success: boolean;
data: { files: unknown[]; changed: boolean };
};
expect(payload.success).toBe(true);
expect(payload.data.changed).toBe(true);
expect(payload.data.files).toHaveLength(1);
});

it('passes dry-run through to the seed helper for branch worktrees', () => {
mockSeedEnvFiles.mockReturnValue({
dryRun: true,
changed: true,
files: [
{
source: '.env.example',
target: '.env',
created: true,
addedVars: ['DATABASE_URL'],
},
],
});

envSeedCommand(targetDir, { json: false, dryRun: true });

expect(mockSeedEnvFiles).toHaveBeenCalledWith(
config.seedEnvFiles,
targetDir,
{ dryRun: true },
);
expect(consoleLogSpy.mock.calls[0]?.[0]).toContain('[dry-run]');
});
});
Loading
Loading