Browse Source

feat(skills): add git worktree management skill

Adds a worktree skill for creating and managing isolated git worktrees
as sibling directories with auto-computed ports, .env generation,
dependency install, and cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
darrenhinde 4 months ago
parent
commit
f9601764b6

+ 302 - 0
.opencode/skills/worktree/SKILL.md

@@ -0,0 +1,302 @@
+---
+name: worktree
+description: Git worktree management skill — creates isolated sibling worktrees with auto-computed ports, .env generation, dependency install, and cleanup. Designed for Turborepo/pnpm monorepos but works with any stack.
+version: 1.0.0
+author: opencode
+type: skill
+category: development
+tags:
+  - git
+  - worktree
+  - ports
+  - monorepo
+  - turborepo
+  - environment
+---
+
+# Worktree Skill
+
+> **Purpose**: Create and manage fully isolated git worktrees as sibling directories. Each worktree gets its own port range, `.env` files, and (optionally) a Zellij terminal session — so you can run `main`, a feature branch, and a hotfix simultaneously without anything conflicting.
+
+---
+
+## What I Do
+
+- **Create** a new worktree as a sibling directory with a clean name
+- **Compute ports** automatically using an index scheme (no manual port tracking)
+- **Generate `.env` files** for each app from a shared `.env.template`
+- **Copy secrets** (`.env.local`) from the main worktree
+- **Install dependencies** (pnpm / yarn / npm, auto-detected)
+- **Launch Zellij** session named after the worktree (if Zellij is installed)
+- **Remove** a worktree cleanly — stops Docker, kills Zellij session, prunes branch
+
+---
+
+## Quick Start
+
+```bash
+# Create a worktree for a new feature branch
+bash .opencode/skills/worktree/router.sh create feature/auth
+
+# Create with a custom short name (used for the directory suffix)
+bash .opencode/skills/worktree/router.sh create feature/auth feature-auth
+
+# List all worktrees and their paths
+bash .opencode/skills/worktree/router.sh list
+
+# Show port assignments for all worktrees
+bash .opencode/skills/worktree/router.sh ports
+
+# Remove a worktree after the branch is merged
+bash .opencode/skills/worktree/router.sh remove feature-auth
+
+# Remove but keep the branch
+bash .opencode/skills/worktree/router.sh remove feature-auth --keep-branch
+```
+
+---
+
+## Directory Structure
+
+Worktrees are created as **siblings** to the main repo, not inside it:
+
+```
+~/Documents/github/
+  my-app/                       ← main worktree (main branch)
+  my-app-feature-auth/          ← created by: create feature/auth
+  my-app-bugfix-payments/       ← created by: create bugfix/payments
+  my-app-experiment-ai/         ← created by: create experiment/ai
+```
+
+Each directory is a full working tree sharing the same `.git` — completely isolated, with its own `.env` files and installed packages.
+
+---
+
+## Port Index Scheme
+
+Each worktree is assigned a numeric **index** derived from how many worktrees exist at the moment `create` runs. The formula is `BASE + (INDEX × 10)`.
+
+How the index is calculated at creation time:
+- Only main exists (1 line from `git worktree list`): `1 - 1 = index 0` → main
+- main + 1 feature (2 lines): `2 - 1 = index 1` → first feature
+- main + 2 features (3 lines): `3 - 1 = index 2` → second feature
+
+| Index | Worktree       | Web  | Admin | API  | DB   | Redis |
+|-------|----------------|------|-------|------|------|-------|
+| 0     | main           | 3000 | 3001  | 3002 | 5432 | 6379  |
+| 1     | first feature  | 3010 | 3011  | 3012 | 5442 | 6389  |
+| 2     | second feature | 3020 | 3021  | 3022 | 5452 | 6399  |
+| 3     | third feature  | 3030 | 3031  | 3032 | 5462 | 6409  |
+
+> **Note:** If you remove a worktree, its index slot is freed and will be reused by the next `create`. Run `ports` before creating a new worktree to see current assignments.
+
+Run `bash .opencode/skills/worktree/router.sh ports` to see the live table.
+
+---
+
+## `.env.template` Setup
+
+Add a `.env.template` to your repo root (commit it). The script replaces `__PLACEHOLDER__` tokens at worktree creation time.
+
+```bash
+# .env.template
+WORKTREE_INDEX=__INDEX__
+WORKTREE_NAME=__NAME__
+
+WEB_PORT=__WEB_PORT__
+ADMIN_PORT=__ADMIN_PORT__
+API_PORT=__API_PORT__
+DB_PORT=__DB_PORT__
+REDIS_PORT=__REDIS_PORT__
+
+DATABASE_URL=postgresql://postgres:postgres@localhost:__DB_PORT__/myapp
+REDIS_URL=redis://localhost:__REDIS_PORT__
+NEXT_PUBLIC_API_URL=http://localhost:__API_PORT__
+```
+
+Generated `.env` files are placed at:
+- `{worktree}/.env` — root
+- `{worktree}/apps/web/.env` — if directory exists
+- `{worktree}/apps/admin/.env` — if directory exists
+- `{worktree}/apps/api/.env` — if directory exists
+
+> **Limitation:** Per-app `.env` generation is hardcoded to `apps/web`, `apps/admin`, and `apps/api`. Directories that don't exist are silently skipped. If your monorepo uses different app names, edit `wt-new.sh` lines ~148-151 to match your structure.
+
+Add to `.gitignore`:
+```
+.env
+.env.local
+apps/**/.env
+apps/**/.env.local
+```
+
+---
+
+## Per-App Port Configuration
+
+### Next.js (`apps/web`, `apps/admin`)
+
+Next.js does not read `PORT` from `.env` automatically — pass it via the script:
+
+```json
+{
+  "scripts": {
+    "dev": "dotenv -e .env -- next dev -p $WEB_PORT"
+  }
+}
+```
+
+Install `dotenv-cli` once at the workspace root:
+```bash
+pnpm add -Dw dotenv-cli
+```
+
+### Vite (`apps/admin` or any Vite app)
+
+`vite.config.ts`:
+```ts
+import { defineConfig, loadEnv } from 'vite';
+
+export default defineConfig(({ mode }) => {
+  const env = loadEnv(mode, process.cwd(), '');
+  return {
+    server: {
+      port: Number(env.WEB_PORT || 5173),
+      strictPort: true,
+    },
+  };
+});
+```
+
+### NestJS (`apps/api`)
+
+`src/main.ts`:
+```ts
+async function bootstrap() {
+  const app = await NestFactory.create(AppModule);
+  await app.listen(process.env.API_PORT || 3002);
+}
+bootstrap();
+```
+
+NestJS picks up `.env` via `@nestjs/config` automatically.
+
+### Turborepo `turbo.json`
+
+Declare port env vars so Turbo doesn't use stale cache across worktrees:
+
+```json
+{
+  "$schema": "https://turbo.build/schema.json",
+  "tasks": {
+    "dev": {
+      "cache": false,
+      "env": ["WEB_PORT", "ADMIN_PORT", "API_PORT", "DB_PORT", "REDIS_PORT", "WORKTREE_INDEX"]
+    },
+    "build": {
+      "dependsOn": ["^build"],
+      "env": ["WEB_PORT", "ADMIN_PORT", "API_PORT"]
+    }
+  }
+}
+```
+
+---
+
+## Docker Compose
+
+Use the same env values for fully isolated DB/Redis volumes per worktree:
+
+```yaml
+# docker-compose.yml
+services:
+  postgres:
+    image: postgres:16
+    ports:
+      - "${DB_PORT:-5432}:5432"
+    environment:
+      POSTGRES_DB: myapp_${WORKTREE_NAME:-main}
+    volumes:
+      - pgdata_${WORKTREE_INDEX:-0}:/var/lib/postgresql/data
+
+  redis:
+    image: redis:7
+    ports:
+      - "${REDIS_PORT:-6379}:6379"
+
+volumes:
+  pgdata_0:
+  pgdata_1:
+  pgdata_2:
+  pgdata_3:
+```
+
+Run from worktree root:
+```bash
+docker compose --env-file .env up -d
+```
+
+---
+
+## Everyday Workflow
+
+```bash
+# 1. Create a worktree for a new feature
+bash .opencode/skills/worktree/router.sh create feature/checkout feature-checkout
+
+# 2. In the new directory, start everything
+cd ~/Documents/github/my-app-feature-checkout
+pnpm dev                                      # all apps on unique ports
+docker compose --env-file .env up -d          # DB + Redis isolated
+
+# 3. Check what's running
+bash .opencode/skills/worktree/router.sh list
+bash .opencode/skills/worktree/router.sh ports
+
+# 4. Clean up after merge
+bash .opencode/skills/worktree/router.sh remove feature-checkout
+```
+
+---
+
+## Command Reference
+
+| Command | Description |
+|---------|-------------|
+| `create <branch> [name]` | Create worktree from branch (creates branch if new) |
+| `remove <name> [--keep-branch]` | Remove worktree, stop Docker, kill Zellij session |
+| `list` | Show all worktrees with paths and branches |
+| `ports` | Show port index table for all worktrees |
+| `help` | Show usage |
+
+---
+
+## File Structure
+
+```
+.opencode/skills/worktree/
+├── SKILL.md                    # This file
+├── router.sh                   # CLI entry point
+└── scripts/
+    ├── wt-new.sh               # Create worktree logic
+    └── wt-close.sh             # Remove worktree logic
+```
+
+---
+
+## Troubleshooting
+
+### "Branch already exists"
+The script detects existing local and remote branches and checks them out instead of creating new ones.
+
+### "Directory already exists"
+The target sibling directory is already present. Remove it manually or use a different name.
+
+### ".env.template not found"
+`.env` generation is skipped. Create a `.env.template` in your repo root to enable it.
+
+### Ports already in use
+Run `ports` to see current assignments. If a worktree was removed without pruning, run `git worktree prune` in the main repo to reclaim the index slot.
+
+### Zellij not launching
+Zellij is optional. If not installed, the script prints the `cd` path and exits normally.

+ 135 - 0
.opencode/skills/worktree/router.sh

@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+#############################################################################
+# Worktree Skill Router
+# Manages git worktrees as sibling directories with isolated ports and envs
+#############################################################################
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+show_help() {
+  cat << 'HELP'
+Git Worktree Skill
+━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+Usage: router.sh [COMMAND] [OPTIONS]
+
+COMMANDS:
+  create <branch> [name]     Create a new worktree as a sibling directory
+  remove <name> [--keep-branch]   Remove a worktree and clean up
+  list                       List all worktrees for this repo
+  ports                      Show the port index table
+  help                       Show this help message
+
+EXAMPLES:
+  bash .opencode/skills/worktree/router.sh create feature/auth
+  bash .opencode/skills/worktree/router.sh create feature/auth feature-auth
+  bash .opencode/skills/worktree/router.sh list
+  bash .opencode/skills/worktree/router.sh remove feature-auth
+  bash .opencode/skills/worktree/router.sh remove feature-auth --keep-branch
+  bash .opencode/skills/worktree/router.sh ports
+
+WORKTREE LOCATION:
+  Worktrees are created as siblings to the main repo directory:
+    ~/…/github/my-app/              ← main repo (you are here)
+    ~/…/github/my-app-feature-auth/ ← created worktree
+
+PORT SCHEME (BASE + INDEX * 10):
+  Index 0 (main):  web=3000  admin=3001  api=3002  db=5432  redis=6379
+  Index 1:         web=3010  admin=3011  api=3012  db=5442  redis=6389
+  Index 2:         web=3020  admin=3021  api=3022  db=5452  redis=6399
+
+For detailed documentation, see: .opencode/skills/worktree/SKILL.md
+HELP
+}
+
+# Find project root
+find_git_root() {
+  local dir
+  dir="$(pwd)"
+  while [ "$dir" != "/" ]; do
+    if [ -d "$dir/.git" ]; then
+      echo "$dir"
+      return 0
+    fi
+    dir="$(dirname "$dir")"
+  done
+  echo "ERROR: Not inside a git repository" >&2
+  return 1
+}
+
+cmd_list() {
+  local main_repo
+  main_repo="$(find_git_root)"
+  echo "Worktrees for $(basename "$main_repo"):"
+  echo ""
+  git -C "$main_repo" worktree list
+}
+
+cmd_ports() {
+  local main_repo
+  main_repo="$(find_git_root)"
+  local count
+  count=$(git -C "$main_repo" worktree list | wc -l | xargs)
+
+  echo "Port index table (BASE + INDEX * 10):"
+  echo ""
+  printf "%-6s %-10s %-6s %-7s %-5s %-6s %-7s\n" "Index" "Name" "Web" "Admin" "API" "DB" "Redis"
+  echo "─────────────────────────────────────────────────────"
+
+  local i=0
+  while IFS= read -r line; do
+    local path branch
+    path="$(echo "$line" | awk '{print $1}')"
+    branch="$(echo "$line" | awk '{print $NF}' | tr -d '[]')"
+    local name
+    name="$(basename "$path")"
+    printf "%-6s %-10s %-6s %-7s %-5s %-6s %-7s\n" \
+      "$i" "${name:0:10}" \
+      "$((3000 + i * 10))" "$((3001 + i * 10))" "$((3002 + i * 10))" \
+      "$((5432 + i * 10))" "$((6379 + i * 10))"
+    i=$((i + 1))
+  done < <(git -C "$main_repo" worktree list)
+
+  local next_idx=$((count - 1))
+  echo ""
+  echo "Next worktree will use index $next_idx:"
+  printf "  web=%-6s admin=%-6s api=%-6s db=%-6s redis=%s\n" \
+    "$((3000 + next_idx * 10))" "$((3001 + next_idx * 10))" \
+    "$((3002 + next_idx * 10))" "$((5432 + next_idx * 10))" \
+    "$((6379 + next_idx * 10))"
+}
+
+# No arguments — show help
+if [ $# -eq 0 ]; then
+  show_help
+  exit 0
+fi
+
+COMMAND="$1"
+shift
+
+case "$COMMAND" in
+  create)
+    bash "$SCRIPT_DIR/scripts/wt-new.sh" "$@"
+    ;;
+  remove)
+    bash "$SCRIPT_DIR/scripts/wt-close.sh" "$@"
+    ;;
+  list)
+    cmd_list
+    ;;
+  ports)
+    cmd_ports
+    ;;
+  help|-h|--help)
+    show_help
+    ;;
+  *)
+    echo "ERROR: Unknown command: $COMMAND"
+    echo ""
+    show_help
+    exit 1
+    ;;
+esac

+ 94 - 0
.opencode/skills/worktree/scripts/wt-close.sh

@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+#############################################################################
+# wt-close.sh — Remove a git worktree and optionally clean up its branch
+#
+# Usage: wt-close.sh <name> [--keep-branch]
+#   name           Short name used when creating (e.g. feature-auth)
+#   --keep-branch  Skip branch deletion (useful if branch is unmerged)
+#############################################################################
+
+set -euo pipefail
+
+# ── Locate main repo root ─────────────────────────────────────────────────
+find_git_root() {
+  local dir
+  dir="$(pwd)"
+  while [ "$dir" != "/" ]; do
+    if [ -d "$dir/.git" ]; then
+      echo "$dir"
+      return 0
+    fi
+    dir="$(dirname "$dir")"
+  done
+  echo "ERROR: not inside a git repository" >&2
+  return 1
+}
+
+MAIN_REPO="$(find_git_root)"
+GITHUB_ROOT="$(dirname "$MAIN_REPO")"
+REPO_NAME="$(basename "$MAIN_REPO")"
+
+# ── Args ──────────────────────────────────────────────────────────────────
+NAME="${1:-}"
+KEEP_BRANCH=false
+
+if [[ -z "$NAME" ]]; then
+  echo "Usage: wt-close.sh <name> [--keep-branch]"
+  echo "  e.g. wt-close.sh feature-auth"
+  exit 1
+fi
+
+shift
+for arg in "$@"; do
+  if [[ "$arg" == "--keep-branch" ]]; then
+    KEEP_BRANCH=true
+  fi
+done
+
+WORKTREE_DIR="$GITHUB_ROOT/${REPO_NAME}-${NAME}"
+
+if [[ ! -d "$WORKTREE_DIR" ]]; then
+  echo "ERROR: worktree directory not found: $WORKTREE_DIR" >&2
+  exit 1
+fi
+
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo " Removing worktree: ${REPO_NAME}-${NAME}"
+echo " Path: $WORKTREE_DIR"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+
+# ── Stop Docker Compose if running ───────────────────────────────────────
+if [[ -f "$WORKTREE_DIR/docker-compose.yml" ]]; then
+  echo "→ Stopping Docker Compose..."
+  if [[ -f "$WORKTREE_DIR/.env" ]]; then
+    docker compose --env-file "$WORKTREE_DIR/.env" -f "$WORKTREE_DIR/docker-compose.yml" down -v 2>/dev/null || true
+  else
+    docker compose -f "$WORKTREE_DIR/docker-compose.yml" down -v 2>/dev/null || true
+  fi
+fi
+
+# ── Kill Zellij session if running ───────────────────────────────────────
+if command -v zellij &>/dev/null; then
+  if zellij list-sessions 2>/dev/null | grep -q "^$NAME"; then
+    echo "→ Deleting Zellij session: $NAME"
+    zellij delete-session "$NAME" 2>/dev/null || true
+  fi
+fi
+
+# ── Find the branch name for this worktree ───────────────────────────────
+BRANCH="$(git -C "$WORKTREE_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
+
+# ── Remove the worktree ───────────────────────────────────────────────────
+echo "→ Removing worktree..."
+git -C "$MAIN_REPO" worktree remove "$WORKTREE_DIR" --force
+git -C "$MAIN_REPO" worktree prune
+
+# ── Optionally delete the branch ─────────────────────────────────────────
+if [[ "$KEEP_BRANCH" == false ]] && [[ -n "$BRANCH" ]] && [[ "$BRANCH" != "main" ]] && [[ "$BRANCH" != "master" ]]; then
+  echo "→ Deleting branch: $BRANCH"
+  git -C "$MAIN_REPO" branch -d "$BRANCH" 2>/dev/null \
+    || echo "  (branch not deleted — has unmerged commits. Use --keep-branch or delete manually)"
+fi
+
+echo ""
+echo "✓ Worktree '${REPO_NAME}-${NAME}' removed"

+ 175 - 0
.opencode/skills/worktree/scripts/wt-new.sh

@@ -0,0 +1,175 @@
+#!/usr/bin/env bash
+#############################################################################
+# wt-new.sh — Create a new git worktree as a sibling directory
+#
+# Usage: wt-new.sh <branch> [name]
+#   branch  Git branch name, e.g. feature/auth or bugfix/payments
+#   name    Short directory suffix, defaults to branch with / replaced by -
+#           e.g. feature/auth → feature-auth
+#
+# Output structure (siblings to the main repo):
+#   ~/…/github/
+#     my-app/                   ← main worktree (this repo)
+#     my-app-feature-auth/      ← new worktree
+#
+# Port scheme: BASE + (INDEX * STEP) where INDEX = number of existing worktrees
+#   Index 0 (main):  web=3000  admin=3001  api=3002  db=5432  redis=6379
+#   Index 1:         web=3010  admin=3011  api=3012  db=5442  redis=6389
+#   Index 2:         web=3020  admin=3021  api=3022  db=5452  redis=6399
+#############################################################################
+
+set -euo pipefail
+
+# ── Locate main repo root ─────────────────────────────────────────────────
+find_git_root() {
+  local dir
+  dir="$(pwd)"
+  while [ "$dir" != "/" ]; do
+    if [ -d "$dir/.git" ]; then
+      echo "$dir"
+      return 0
+    fi
+    dir="$(dirname "$dir")"
+  done
+  echo "ERROR: not inside a git repository" >&2
+  return 1
+}
+
+MAIN_REPO="$(find_git_root)"
+GITHUB_ROOT="$(dirname "$MAIN_REPO")"
+REPO_NAME="$(basename "$MAIN_REPO")"
+
+# ── Args ──────────────────────────────────────────────────────────────────
+BRANCH="${1:-}"
+NAME="${2:-}"
+
+if [[ -z "$BRANCH" ]]; then
+  echo "Usage: wt-new.sh <branch> [name]"
+  echo "  e.g. wt-new.sh feature/auth feature-auth"
+  exit 1
+fi
+
+# Default name: replace / with -
+if [[ -z "$NAME" ]]; then
+  NAME="${BRANCH//\//-}"
+fi
+
+WORKTREE_DIR="$GITHUB_ROOT/${REPO_NAME}-${NAME}"
+
+# ── Compute index ─────────────────────────────────────────────────────────
+# Count existing worktrees (main counts as index 0, so subtract 1)
+INDEX=$(git -C "$MAIN_REPO" worktree list | wc -l | tr -d ' ')
+INDEX=$((INDEX - 1))
+
+BASE_WEB=3000
+BASE_ADMIN=3001
+BASE_API=3002
+BASE_DB=5432
+BASE_REDIS=6379
+STEP=10
+
+WEB_PORT=$((BASE_WEB   + INDEX * STEP))
+ADMIN_PORT=$((BASE_ADMIN + INDEX * STEP))
+API_PORT=$((BASE_API   + INDEX * STEP))
+DB_PORT=$((BASE_DB     + INDEX * STEP))
+REDIS_PORT=$((BASE_REDIS + INDEX * STEP))
+
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+echo " Creating worktree: ${REPO_NAME}-${NAME}"
+echo " Branch:  $BRANCH"
+echo " Path:    $WORKTREE_DIR"
+echo " Index:   $INDEX"
+echo " Ports:   web=$WEB_PORT  admin=$ADMIN_PORT  api=$API_PORT  db=$DB_PORT  redis=$REDIS_PORT"
+echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+
+if [[ -d "$WORKTREE_DIR" ]]; then
+  echo "ERROR: directory already exists: $WORKTREE_DIR" >&2
+  exit 1
+fi
+
+# ── Create worktree ───────────────────────────────────────────────────────
+cd "$MAIN_REPO"
+
+if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
+  echo "→ Branch exists locally — adding worktree"
+  git worktree add "$WORKTREE_DIR" "$BRANCH"
+elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH"; then
+  echo "→ Branch exists on remote — checking out"
+  git worktree add -b "$BRANCH" "$WORKTREE_DIR" "origin/$BRANCH"
+else
+  echo "→ New branch — creating from origin/main"
+  git worktree add -b "$BRANCH" "$WORKTREE_DIR" origin/main
+fi
+
+cd "$WORKTREE_DIR"
+
+# ── Install dependencies ──────────────────────────────────────────────────
+if [[ -f "pnpm-lock.yaml" ]]; then
+  echo "→ Installing dependencies (pnpm)..."
+  pnpm install
+elif [[ -f "yarn.lock" ]]; then
+  echo "→ Installing dependencies (yarn)..."
+  yarn install
+elif [[ -f "package-lock.json" ]]; then
+  echo "→ Installing dependencies (npm)..."
+  npm install
+else
+  echo "  (no lock file found — skipping install)"
+fi
+
+# ── Generate .env files ───────────────────────────────────────────────────
+TEMPLATE="$MAIN_REPO/.env.template"
+
+if [[ -f "$TEMPLATE" ]]; then
+  echo "→ Generating .env files from .env.template..."
+
+  generate_env() {
+    local dest="$1"
+    local dir
+    dir="$(dirname "$dest")"
+    mkdir -p "$dir"
+    sed \
+      -e "s/__INDEX__/$INDEX/g" \
+      -e "s/__NAME__/$NAME/g" \
+      -e "s/__WEB_PORT__/$WEB_PORT/g" \
+      -e "s/__ADMIN_PORT__/$ADMIN_PORT/g" \
+      -e "s/__API_PORT__/$API_PORT/g" \
+      -e "s/__DB_PORT__/$DB_PORT/g" \
+      -e "s/__REDIS_PORT__/$REDIS_PORT/g" \
+      "$TEMPLATE" > "$dest"
+    echo "  ✓ $dest"
+  }
+
+  # Root .env
+  generate_env "$WORKTREE_DIR/.env"
+
+  # Per-app .env files (only if the app directories exist)
+  for app_dir in apps/web apps/admin apps/api; do
+    if [[ -d "$WORKTREE_DIR/$app_dir" ]]; then
+      generate_env "$WORKTREE_DIR/$app_dir/.env"
+    fi
+  done
+else
+  echo "  (no .env.template found — skipping .env generation)"
+  echo "  Tip: add a .env.template to your repo root for automatic port-isolated .env files"
+fi
+
+# ── Copy .env.local secrets from main worktree ───────────────────────────
+for extra in ".env.local" "apps/web/.env.local" "apps/admin/.env.local" "apps/api/.env.local"; do
+  if [[ -f "$MAIN_REPO/$extra" ]]; then
+    dest_dir="$(dirname "$WORKTREE_DIR/$extra")"
+    mkdir -p "$dest_dir"
+    cp "$MAIN_REPO/$extra" "$WORKTREE_DIR/$extra"
+    echo "  ✓ copied $extra"
+  fi
+done
+
+# ── Launch in Zellij (optional) ───────────────────────────────────────────
+if command -v zellij &>/dev/null; then
+  echo "→ Launching Zellij session: $NAME"
+  zellij attach -c "$NAME" --layout worktree 2>/dev/null || zellij attach -c "$NAME"
+else
+  echo ""
+  echo "✓ Worktree ready!"
+  echo "  cd $WORKTREE_DIR"
+fi