diff --git a/.opencode/skills/worktree/SKILL.md b/.opencode/skills/worktree/SKILL.md new file mode 100644 index 00000000..ace3f693 --- /dev/null +++ b/.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 [name]` | Create worktree from branch (creates branch if new) | +| `remove [--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. diff --git a/.opencode/skills/worktree/router.sh b/.opencode/skills/worktree/router.sh new file mode 100755 index 00000000..4045255b --- /dev/null +++ b/.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 [name] Create a new worktree as a sibling directory + remove [--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 diff --git a/.opencode/skills/worktree/scripts/wt-close.sh b/.opencode/skills/worktree/scripts/wt-close.sh new file mode 100755 index 00000000..a3b77ce7 --- /dev/null +++ b/.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 [--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 [--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" diff --git a/.opencode/skills/worktree/scripts/wt-new.sh b/.opencode/skills/worktree/scripts/wt-new.sh new file mode 100755 index 00000000..2eceb9c8 --- /dev/null +++ b/.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 [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 [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