mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(worktree): ambientes isolados por worktree — porta própria, bancos próprios, migrations no provision
pnpm worktree next [--go] [--no-migrate] agora provisiona um ambiente determinístico por worktree: slot = parte numérica do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos), dev server em 3100+slot, bancos teqo_wt<slot> / teqo_wt<slot>_test no container compartilhado (-p teqo) com a cadeia de migrations aplicada; escreve .env.local + .env.test.local (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD_SECRET/Blob/VAPID do main. kill remove o worktree e dropa os bancos gerados (WITH FORCE, best-effort). Suites leem .env.test.local depois de .env.test (vitest.setup / playwright / db-doctor / gate-ci espelham o layering) e assertTestDatabase aceita teqo_test ou teqo_<worktree>_test em localhost só (fail-closed preservado). db:start/db:stop fixam -p teqo (um container de qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura em scripts/lib/worktree-env.mjs (unit-tested); fluxo validado end-to-end: provision → migrate → 598/598 int no banco do worktree → kill sem resíduo.
This commit is contained in:
parent
4736533723
commit
8125334401
16 changed files with 532 additions and 41 deletions
|
|
@ -22,6 +22,18 @@ VMs Cloud **não rodam** `docker compose` / `pnpm db:start`. Postgres nativo: `.
|
||||||
- Dev database: `postgresql://teqo:teqo@localhost:5432/teqo`
|
- Dev database: `postgresql://teqo:teqo@localhost:5432/teqo`
|
||||||
- Test database: `postgresql://teqo:teqo@localhost:5432/teqo_test`
|
- Test database: `postgresql://teqo:teqo@localhost:5432/teqo_test`
|
||||||
- Local Postgres major version is pinned to **17** in `docker-compose.yml` to match production Neon.
|
- Local Postgres major version is pinned to **17** in `docker-compose.yml` to match production Neon.
|
||||||
|
- **In a worktree provisioned by `pnpm worktree next`**, read `.env.local` / `.env.test.local` instead: dev database `teqo_wt<slot>`, test database `teqo_wt<slot>_test`, dev server on port `3100+slot` (see "Per-worktree environments" below).
|
||||||
|
|
||||||
|
## Per-worktree environments (`pnpm worktree next`)
|
||||||
|
|
||||||
|
Parallel agents must not fight over port 3000, the shared `teqo_test`, or a second Postgres container. `pnpm worktree next [--go]` provisions each worktree with a deterministic isolated env:
|
||||||
|
|
||||||
|
- `slot` = numeric part of the issue code (`C15` → 15, `B164` → 164; hash fallback without digits; bumped on collision among live worktrees).
|
||||||
|
- Dev server port `3100+slot`; databases `teqo_wt<slot>` (dev) and `teqo_wt<slot>_test` (tests), created on the **one shared container** with the full migration chain applied (one-time, ~30 s each).
|
||||||
|
- Writes `.env.local` (DATABASE_URL, PORT, NEXT_PUBLIC_SITE_URL, PLAYWRIGHT_BASE_URL, PAYLOAD_SECRET + Blob/VAPID copied from the main repo) and `.env.test.local` (test DATABASE_URL). Both carry the marker `# teqo worktree env (generated by pnpm worktree next)`; **manual env files are never overwritten** — remove them and re-run `pnpm worktree next` to regenerate.
|
||||||
|
- `--no-migrate` skips the migration runs; `pnpm worktree kill` drops the worktree's generated databases (best-effort, `DROP DATABASE … WITH (FORCE)`).
|
||||||
|
- Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning and no port override.
|
||||||
|
- Pure derivation: `scripts/lib/worktree-env.mjs` (unit-tested in `tests/unit/worktree.unit.spec.ts`).
|
||||||
|
|
||||||
## First-time setup
|
## First-time setup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ Prepara o ambiente git para a próxima Issue da fila de claim, **sem** tocá-la
|
||||||
A lógica determinística (fila → código+slug → branch → worktree) vive em **`scripts/worktree.mjs`** — fonte única, coberta por `tests/unit/worktree.unit.spec.ts`. Não a reescreva à mão aqui.
|
A lógica determinística (fila → código+slug → branch → worktree) vive em **`scripts/worktree.mjs`** — fonte única, coberta por `tests/unit/worktree.unit.spec.ts`. Não a reescreva à mão aqui.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm worktree next # cria worktree de origin/main para a próxima claimável
|
pnpm worktree next [--go] [--no-migrate] # cria worktree de origin/main + provisiona o ambiente
|
||||||
pnpm worktree kill [--force] # destrói o worktree em que o shell atual está
|
pnpm worktree kill [--force] # destrói o worktree atual + remove seus bancos gerados
|
||||||
```
|
```
|
||||||
|
|
||||||
No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.opencode/commands/worktree.md`), que só repassa `$ARGUMENTS` para o script.
|
No opencode, isso é o comando **`/worktree next`** / **`/worktree kill`** (`.opencode/commands/worktree.md`), que só repassa `$ARGUMENTS` para o script.
|
||||||
|
|
@ -38,9 +38,20 @@ No terminal interativo, para `--go` trocar de diretório de verdade, use a funç
|
||||||
- **Fila = `pnpm agent:claim --dry-run`** (ready + desbloqueadas, por `prio:P*` e mais antiga primeiro — mesma ordenação do pool e de `project-status`).
|
- **Fila = `pnpm agent:claim --dry-run`** (ready + desbloqueadas, por `prio:P*` e mais antiga primeiro — mesma ordenação do pool e de `project-status`).
|
||||||
- **Branch `<code>-<slug>`**: `code` = `id` do frontmatter; `slug` = título pt-BR via `src/lib/slug.ts` (acentos fora, não-alfanumérico → hífen); truncamento só no slug, nunca no código; valida com `git check-ref-format --allow-onelevel`.
|
- **Branch `<code>-<slug>`**: `code` = `id` do frontmatter; `slug` = título pt-BR via `src/lib/slug.ts` (acentos fora, não-alfanumérico → hífen); truncamento só no slug, nunca no código; valida com `git check-ref-format --allow-onelevel`.
|
||||||
- **Base `origin/main`** (com `git fetch` antes); dir em `~/.cursor/worktrees/teqo/<branch>`.
|
- **Base `origin/main`** (com `git fetch` antes); dir em `~/.cursor/worktrees/teqo/<branch>`.
|
||||||
- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch.
|
- **Provisiona o ambiente isolado do worktree** (determinístico, ver "Ambiente provisionado"): dev server na porta `3100+slot` e bancos próprios `teqo_wt<slot>` / `teqo_wt<slot>_test` no container compartilhado (`-p teqo`), com migrations aplicadas — agentes em paralelo não disputam porta 3000 nem o `teqo_test`. `--no-migrate` pula as migrations (bancos criados, envs escritos).
|
||||||
|
- **`kill`**: recusa destruir o worktree principal (main); recusa worktree sujo sem `--force`; remove e apaga o branch; remove os bancos gerados do worktree (best-effort).
|
||||||
- **NUNCA usa `--force` no `add`**, não inventa código de Issue, e é **read-only no GitHub**.
|
- **NUNCA usa `--force` no `add`**, não inventa código de Issue, e é **read-only no GitHub**.
|
||||||
|
|
||||||
|
## Ambiente provisionado (leia a saída do script)
|
||||||
|
|
||||||
|
Cada worktree gerado ganha um ambiente próprio — reporte ao agente/humano os valores que o script imprime ao final:
|
||||||
|
|
||||||
|
- **dev**: `http://localhost:<porta>` (`pnpm dev`), banco `teqo_wt<slot>`.
|
||||||
|
- **test**: banco `teqo_wt<slot>_test` (as suites leem `.env.test.local`, que sobrepõe o `.env.test` commitado; `pnpm test:int` / `test:e2e` / `gate:push` rodam isolados).
|
||||||
|
- Sem Docker (Cursor Cloud): o script degrada para o compartilhado `teqo`/`teqo_test` com aviso — comportamento antigo.
|
||||||
|
- Envs geradas têm o marcador `# teqo worktree env (generated by pnpm worktree next)`; **`.env.local`/`.env.test.local` manuais nunca são sobrescritos** (o script pula o provisionamento com aviso).
|
||||||
|
- Detalhes e derivarão pura: `scripts/lib/worktree-env.mjs`; skill `local-database`.
|
||||||
|
|
||||||
## NÃO faz
|
## NÃO faz
|
||||||
|
|
||||||
- Não roda `pnpm agent:claim` (mudaria labels no GitHub).
|
- Não roda `pnpm agent:claim` (mudaria labels no GitHub).
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ These are product/architecture constraints — not a ban on editing existing cod
|
||||||
- Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create <name>` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.agents/skills/payload-migrations`).
|
- Schema changes go through committed Payload migrations — `push: false` everywhere, never flip it on against a remote DB. Edit configs → `pnpm migrate:create <name>` → commit `src/migrations/*` (both `.ts` and `.json`, plus `index.ts`) → `pnpm migrate` locally. `pnpm build` runs `payload migrate`, so **every Vercel deploy applies pending migrations to prod**. Prod is baselined at migration `20260715_163458_initial` — never regenerate or replace the initial migration. Full workflow (incl. hand-written data/reconciliation migrations) lives in the `payload-migrations` skill (`.agents/skills/payload-migrations`).
|
||||||
- `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit.
|
- `Consent.text` was reconciled from `varchar` to `jsonb` via `20260715_163500_consent_text_to_jsonb` (matching the `richText` field). Any future field-type change is a migration, not a manual DB edit.
|
||||||
- To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data).
|
- To copy production content locally, use `pnpm db:pull` (`scripts/db-pull.mjs`): it only reads prod, only writes local, and excludes supporter PII (`contact`/`signature`/`subscription` row data).
|
||||||
- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED.
|
- `pnpm db:doctor` diagnoses local database connectivity for the dev AND test targets: it names who holds the port, points at exited/crash-looping containers, and flags Postgres containers sharing one data volume (two postmasters on one PGDATA corrupt the WAL — this destroyed the local cluster's checkpoint on 2026-07-25; the canonical setup is ONE compose container on 5432 hosting `teqo` + `teqo_test`). `pnpm dev` runs the same preflight via `guard-dev-db` and refuses to start with a named remedy instead of a later ECONNREFUSED. `db:start`/`db:stop` pin the compose project with `-p teqo`, so they always target that ONE container even from a worktree directory.
|
||||||
|
- **Per-worktree environments (`pnpm worktree next`, 2026-08-08):** parallel agents each get a deterministic isolated env — no fighting over port 3000, the shared `teqo_test`, or a second Postgres container. `worktree next [--go]` provisions: a dev-server port `3100+slot` (slot = numeric part of the issue code, hash fallback, bumped on collision among live worktrees), plus dedicated databases `teqo_wt<slot>` (dev) and `teqo_wt<slot>_test` (tests) created on the shared container with the full migration chain applied; writes `.env.local` + `.env.test.local` (gitignored, marker `# teqo worktree env (generated by pnpm worktree next)`; manual envs are never overwritten) and copies `PAYLOAD_SECRET`/Blob/VAPID from the main repo. `kill` drops the worktree's generated databases best-effort. Tests read `.env.test.local` after `.env.test` (vitest.setup.ts / playwright.config.ts / db-doctor / gate-ci mirror the layering), and `assertTestDatabase` accepts `teqo_test` or `teqo_<worktree>_test` on localhost only — still fail-closed on remote. Pure derivation lives in `scripts/lib/worktree-env.mjs` (unit-tested). Without Docker (Cursor Cloud) it degrades to the shared `teqo`/`teqo_test` with a warning.
|
||||||
- To (re)populate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" below.
|
- To (re)populate news content, `pnpm db:seed:posts` fetches articles live from jorgesolla.com.br into the LOCAL db (same non-local `DATABASE_URL` guard as `pnpm dev`). It writes from a CLI process outside the deployed runtime, so after seeding — or after ANY direct-DB change — you must bust the production `posts` cache via `POST /api/revalidate`. See "Posts & Tags" below.
|
||||||
- To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under the `election-tse` tag — after re-seeding a database the deployed app reads, bust it via `POST /api/revalidate?tag=election-tse`. See "Election baseline data (TSE 2022)" below.
|
- To (re)populate the TSE 2022 election baseline, `pnpm db:seed:tse` downloads TSE open data and imports the Bahia scope into the LOCAL db (same guard). Since the 2026-07-23 hardening, elections-tab reads are cached under the `election-tse` tag — after re-seeding a database the deployed app reads, bust it via `POST /api/revalidate?tag=election-tse`. See "Election baseline data (TSE 2022)" below.
|
||||||
|
|
||||||
|
|
@ -154,7 +155,7 @@ The artifact is at **v3**: v2 added E8's `campoFederalVotesByYear` / `federalTal
|
||||||
|
|
||||||
### Local verification and deploy checklist
|
### Local verification and deploy checklist
|
||||||
|
|
||||||
1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use Neon for development, tests, E2E, or local builds.
|
1. Confirm `DATABASE_URL` is `postgresql://teqo:teqo@localhost:5432/teqo` and `.env.test` points to `teqo_test`; never use Neon for development, tests, E2E, or local builds. In a provisioned worktree (`pnpm worktree next`) the values come from its `.env.local` / `.env.test.local` (`teqo_wt<slot>` / `teqo_wt<slot>_test`, dev port `3100+slot`) — read them there instead of assuming the main-repo names.
|
||||||
2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`.
|
2. Run `pnpm db:start`, `pnpm migrate:status`, and `DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test pnpm migrate:status`; every migration must show `Ran: Yes`.
|
||||||
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), `pnpm test:e2e` (or `pnpm test:all`), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js.
|
3. Run `pnpm generate:types`, `pnpm generate:importmap` when components changed, `pnpm exec tsc --noEmit`, `pnpm lint` (zero warnings — the script enforces `--max-warnings=0`), `pnpm format:check` (Prettier is the formatting authority; `pnpm format` fixes), `pnpm exec knip` (dead files/dependencies are CI-blocking; delete what your change orphaned), `pnpm check:cycles` (madge — zero circular imports), `pnpm test` (unit + int), `pnpm test:e2e` (or `pnpm test:all`), and `pnpm build` against the local database. Payload CLI scripts (`migrate`, `generate:*`, `payload`) and the build migrate step use `--conditions=react-server` so `server-only` modules load correctly outside Next.js.
|
||||||
- Never run `knip --fix` blind: when knip's module graph is incomplete (it still cannot load `payload.config.ts` — ledgered as P3), `--fix` deletes exports that migrations/tests still reference. Verify each removal (e.g. `git grep -w <symbol>`) before deleting.
|
- Never run `knip --fix` blind: when knip's module graph is incomplete (it still cannot load `payload.config.ts` — ledgered as P3), `--fix` deletes exports that migrations/tests still reference. Verify each removal (e.g. `git grep -w <symbol>`) before deleting.
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,9 @@ Teqo then becomes a configurable base product for other politicians in Brazil:
|
||||||
6. Start the dev server: `pnpm dev`
|
6. Start the dev server: `pnpm dev`
|
||||||
7. Open: `http://localhost:3000`
|
7. Open: `http://localhost:3000`
|
||||||
|
|
||||||
Useful scripts: `pnpm db:start` / `pnpm db:stop` (local Postgres), `pnpm db:pull` (refresh local content from prod), `pnpm db:seed:posts` (import news posts/tags fresh from the live jorgesolla.com.br site into the local db; idempotent by slug, refuses a non-local database), `pnpm db:seed:tse` (import TSE 2014/2018/2022 Bahia election results into the local db; idempotent per year/office/turn scope, refuses a non-local database), `pnpm build:election-aggregates` (regenerate the committed TSE aggregate artifact in `src/lib/electionAggregates/` from locally seeded data).
|
Useful scripts: `pnpm db:start` / `pnpm db:stop` (local Postgres — always the same shared container via `-p teqo`, from any directory), `pnpm db:pull` (refresh local content from prod), `pnpm db:seed:posts` (import news posts/tags fresh from the live jorgesolla.com.br site into the local db; idempotent by slug, refuses a non-local database), `pnpm db:seed:tse` (import TSE 2014/2018/2022 Bahia election results into the local db; idempotent per year/office/turn scope, refuses a non-local database), `pnpm build:election-aggregates` (regenerate the committed TSE aggregate artifact in `src/lib/electionAggregates/` from locally seeded data).
|
||||||
|
|
||||||
|
Parallel worktrees get isolated environments: `pnpm worktree next` derives a per-branch dev port (`3100+slot`) and its own databases (`teqo_wt<slot>` / `teqo_wt<slot>_test`), so agents never fight over port 3000 or the shared `teqo_test`. See `.agents/skills/local-database`.
|
||||||
|
|
||||||
### Content & cache revalidation
|
### Content & cache revalidation
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,11 @@ services:
|
||||||
- '5432:5432'
|
- '5432:5432'
|
||||||
# Default max_connections=100 is tight for vitest forks (≈CPUs × pg pool)
|
# Default max_connections=100 is tight for vitest forks (≈CPUs × pg pool)
|
||||||
# plus a leftover `next start` from local repro. Raise the ceiling so the
|
# plus a leftover `next start` from local repro. Raise the ceiling so the
|
||||||
# int gate fails on real assertions, not "too many clients already".
|
# int gate fails on real assertions, not "too many clients already". 400
|
||||||
command: ['postgres', '-c', 'max_connections=200']
|
# leaves headroom for several agents' int suites on the SAME container in
|
||||||
|
# parallel (each worktree provisions its own databases — see
|
||||||
|
# scripts/worktree.mjs — but they share this one Postgres on 5432).
|
||||||
|
command: ['postgres', '-c', 'max_connections=400']
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
# Runs only on first initialization of the volume. Creates the dedicated
|
# Runs only on first initialization of the volume. Creates the dedicated
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
**Recently resolved (2026-08-08):** **worktree next provisiona ambiente isolado por worktree** — agentes em paralelo não disputam mais porta 3000, o `teqo_test` compartilhado nem um segundo Postgres no 5432: `pnpm worktree next [--go] [--no-migrate]` deriva deterministicamente slot do código da Issue (C15→15; hash sem dígitos; bump em colisão entre worktrees vivos) → dev server `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test` criados no container compartilhado com migrations aplicadas; escreve `.env.local` + `.env.test.local` (gitignored, marcador gerado; envs manuais nunca sobrescritos) e copia PAYLOAD*SECRET/Blob/VAPID do main. `kill` dropa os bancos gerados (best-effort, WITH FORCE). Suites leem `.env.test.local` depois de `.env.test` (vitest.setup/playwright/db-doctor/gate-ci espelham o layering); `assertTestDatabase` aceita `teqo_test` ou `teqo*<worktree>\_test`em localhost só (fail-closed preservado).`db:start`/`db:stop`fixam`-p teqo`(um container, qualquer diretório); max_connections 200→400. Sem Docker (Cursor Cloud): degrada ao compartilhado com aviso. Derivação pura`scripts/lib/worktree-env.mjs` (unit-tested).
|
||||||
|
|
||||||
**Recently resolved (2026-08-07):** **C15** (Issue #390) — `/campanha/agenda` virou a entrada operacional staff com FullCalendar v7 route-local (semana/mês/lista), filtros compartilháveis por município/deputado/tag, criação pelo slot, detalhe por URL e remarcação drag/resize sob RBAC, transação e lock por atividade; `/campanha/atividades` e giros permanecem como lista/fluxos legados. Sem migration. Impl [`fullcalendar-agenda-campanha-impl.md`](plans/fullcalendar-agenda-campanha-impl.md).
|
**Recently resolved (2026-08-07):** **C15** (Issue #390) — `/campanha/agenda` virou a entrada operacional staff com FullCalendar v7 route-local (semana/mês/lista), filtros compartilháveis por município/deputado/tag, criação pelo slot, detalhe por URL e remarcação drag/resize sob RBAC, transação e lock por atividade; `/campanha/atividades` e giros permanecem como lista/fluxos legados. Sem migration. Impl [`fullcalendar-agenda-campanha-impl.md`](plans/fullcalendar-agenda-campanha-impl.md).
|
||||||
|
|
||||||
**Recently resolved (2026-08-06):** **B163** (Issue #391) — `/campanha/dobradinhas` agora edita Nome, Partido, E-mail e Telefone inline; `Contact` virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, preserva nomes legados com preflight de limite, mantém a invariável de nome/telefone e o importador da projeção também reencontra dobradinhas pelo nome atual do Contact quando o alias legado diverge; os links de relações/busca apontam para ID. Intenção [`editar-campos-lista-dobradinhas.md`](plans/editar-campos-lista-dobradinhas.md); impl [`editar-campos-lista-dobradinhas-impl.md`](plans/editar-campos-lista-dobradinhas-impl.md).
|
**Recently resolved (2026-08-06):** **B163** (Issue #391) — `/campanha/dobradinhas` agora edita Nome, Partido, E-mail e Telefone inline; `Contact` virou a fonte de identidade, dobradinha nova cria o Contact na mesma transação, e a ficha usa ID canônico com fallback/redirect de slugs antigos. A migration faz backfill idempotente, preserva nomes legados com preflight de limite, mantém a invariável de nome/telefone e o importador da projeção também reencontra dobradinhas pelo nome atual do Contact quando o alias legado diverge; os links de relações/busca apontam para ID. Intenção [`editar-campos-lista-dobradinhas.md`](plans/editar-campos-lista-dobradinhas.md); impl [`editar-campos-lista-dobradinhas-impl.md`](plans/editar-campos-lista-dobradinhas-impl.md).
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
|
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
|
||||||
"db:start": "docker compose up -d postgres",
|
"db:start": "docker compose -p teqo up -d --wait postgres",
|
||||||
"db:stop": "docker compose down",
|
"db:stop": "docker compose -p teqo down",
|
||||||
"db:doctor": "node scripts/db-doctor.mjs",
|
"db:doctor": "node scripts/db-doctor.mjs",
|
||||||
"db:pull": "node scripts/db-pull.mjs",
|
"db:pull": "node scripts/db-pull.mjs",
|
||||||
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
|
"db:seed:posts": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm --import=./scripts/seed-loader.mjs\" node scripts/seed-posts.mjs",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
|
||||||
* `override: true` guarantees the test DB wins over anything in the shell.
|
* `override: true` guarantees the test DB wins over anything in the shell.
|
||||||
*/
|
*/
|
||||||
loadEnv({ path: '.env.test', override: true })
|
loadEnv({ path: '.env.test', override: true })
|
||||||
|
// Per-worktree isolation: `.env.test.local` (gitignored, written by
|
||||||
|
// `pnpm worktree next`) wins over the committed `.env.test` when present.
|
||||||
|
loadEnv({ path: '.env.test.local', override: true })
|
||||||
|
|
||||||
// Optional local override when the default Docker port is occupied (e.g. mapped
|
// Optional local override when the default Docker port is occupied (e.g. mapped
|
||||||
// to 5433) — same escape hatch vitest.setup.ts documents.
|
// to 5433) — same escape hatch vitest.setup.ts documents.
|
||||||
|
|
|
||||||
|
|
@ -243,13 +243,24 @@ const readEnvFile = (path) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test target exactly as the suites resolve it: `.env.test` then
|
||||||
|
* `.env.test.local` (per-worktree, gitignored, wins), then the
|
||||||
|
* TEQO_TEST_DATABASE_URL escape hatch beats DATABASE_URL (same order as
|
||||||
|
* vitest.setup.ts / playwright.config.ts).
|
||||||
|
*/
|
||||||
|
const readTestTargetEnv = () => {
|
||||||
|
const layered = { ...readEnvFile('.env.test'), ...readEnvFile('.env.test.local') }
|
||||||
|
return layered.TEQO_TEST_DATABASE_URL ?? layered.DATABASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
// Next.js precedence for the dev target, without mutating process.env.
|
// Next.js precedence for the dev target, without mutating process.env.
|
||||||
const devUrl =
|
const devUrl =
|
||||||
process.env.DATABASE_URL ??
|
process.env.DATABASE_URL ??
|
||||||
readEnvFile('.env.local').DATABASE_URL ??
|
readEnvFile('.env.local').DATABASE_URL ??
|
||||||
readEnvFile('.env').DATABASE_URL
|
readEnvFile('.env').DATABASE_URL
|
||||||
const testUrl = readEnvFile('.env.test').DATABASE_URL
|
const testUrl = readTestTargetEnv()
|
||||||
|
|
||||||
let healthy = warnOnSharedDataVolumes()
|
let healthy = warnOnSharedDataVolumes()
|
||||||
if (devUrl) {
|
if (devUrl) {
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,17 @@ import { diagnoseDatabaseTarget } from './db-doctor.mjs'
|
||||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
|
||||||
const readTestEnv = () => {
|
const readTestEnv = () => {
|
||||||
try {
|
// `.env.test.local` (per-worktree, gitignored, written by `pnpm worktree next`)
|
||||||
return parseEnv(readFileSync(path.join(repoRoot, '.env.test'), 'utf8'))
|
// wins over the committed `.env.test`, mirroring vitest.setup.ts.
|
||||||
} catch {
|
const layered = {}
|
||||||
return {}
|
for (const name of ['.env.test', '.env.test.local']) {
|
||||||
|
try {
|
||||||
|
Object.assign(layered, parseEnv(readFileSync(path.join(repoRoot, name), 'utf8')))
|
||||||
|
} catch {
|
||||||
|
// file missing — fine
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return layered
|
||||||
}
|
}
|
||||||
|
|
||||||
const testEnv = readTestEnv()
|
const testEnv = readTestEnv()
|
||||||
|
|
@ -31,7 +37,10 @@ const testEnv = readTestEnv()
|
||||||
/** Env for migrate / seed / build — matches ci-pr.yml service Postgres. */
|
/** Env for migrate / seed / build — matches ci-pr.yml service Postgres. */
|
||||||
const dbEnv = {
|
const dbEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
DATABASE_URL: testEnv.DATABASE_URL ?? 'postgresql://teqo:teqo@localhost:5432/teqo_test',
|
DATABASE_URL:
|
||||||
|
testEnv.TEQO_TEST_DATABASE_URL ??
|
||||||
|
testEnv.DATABASE_URL ??
|
||||||
|
'postgresql://teqo:teqo@localhost:5432/teqo_test',
|
||||||
PAYLOAD_SECRET: testEnv.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
|
PAYLOAD_SECRET: testEnv.PAYLOAD_SECRET ?? 'test-only-secret-not-used-in-production',
|
||||||
NEXT_PUBLIC_SITE_URL: testEnv.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
|
NEXT_PUBLIC_SITE_URL: testEnv.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
67
scripts/lib/worktree-env.mjs
Normal file
67
scripts/lib/worktree-env.mjs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* Pure helpers for the per-worktree environment provisioning in
|
||||||
|
* `scripts/worktree.mjs` — deterministic port + database names derived from
|
||||||
|
* the branch, so parallel agents (each in their own worktree) never fight
|
||||||
|
* over port 3000, the shared `teqo_test` database, or a second Postgres
|
||||||
|
* container on 5432.
|
||||||
|
*
|
||||||
|
* Contract: `slot` is the single collision domain. Port and BOTH database
|
||||||
|
* names derive from the final slot, so a bumped slot frees all three at
|
||||||
|
* once. Deterministic for a given (branch, code, live taken-slots set) —
|
||||||
|
* the same "depends on live state" determinism the claim-queue branch picker
|
||||||
|
* already has.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Dev-server ports start here: slot 15 → http://localhost:3115. */
|
||||||
|
export const DEV_PORT_BASE = 3100
|
||||||
|
|
||||||
|
/** Marker line identifying a generated env file (never overwrite manual ones). */
|
||||||
|
export const GENERATED_ENV_MARKER = '# teqo worktree env (generated by pnpm worktree next)'
|
||||||
|
|
||||||
|
/** Slot cap — ports above 3100 + 999 would leave the usual ephemeral range. */
|
||||||
|
const MAX_SLOT = 999
|
||||||
|
|
||||||
|
/** First run of digits in the issue code (`C15` → 15, `B164` → 164); null when none. */
|
||||||
|
export const numericSlotOfCode = (code) => {
|
||||||
|
const match = /([0-9]+)/.exec(code ?? '')
|
||||||
|
return match ? Number(match[1]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable 32-bit FNV-1a hash → slot in [0, MAX_SLOT] for codes without digits. */
|
||||||
|
export const hashSlotOf = (value) => {
|
||||||
|
let hash = 0x811c9dc5
|
||||||
|
for (const byte of Buffer.from(String(value), 'utf8')) {
|
||||||
|
hash ^= byte
|
||||||
|
hash = Math.imul(hash, 0x01000193) >>> 0
|
||||||
|
}
|
||||||
|
return hash % (MAX_SLOT + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slot a branch prefers before collision resolution. */
|
||||||
|
const preferredSlotForBranch = (branch, code) => {
|
||||||
|
const numeric = numericSlotOfCode(code)
|
||||||
|
return numeric !== null && numeric <= MAX_SLOT ? numeric : hashSlotOf(branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const devPortForSlot = (slot) => DEV_PORT_BASE + slot
|
||||||
|
export const devDatabaseForSlot = (slot) => `teqo_wt${slot}`
|
||||||
|
export const testDatabaseForSlot = (slot) => `teqo_wt${slot}_test`
|
||||||
|
|
||||||
|
/** DB name charset guard — derived names are fed into `CREATE DATABASE` SQL. */
|
||||||
|
export const isGeneratedDatabaseName = (name) => /^teqo_wt[0-9]+(_test)?$/.test(name ?? '')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Final environment for a branch. `takenSlots` = slots already claimed by
|
||||||
|
* OTHER live worktrees; bumps +1 until free (bounded — live slot sets are
|
||||||
|
* tiny). Every value a consumer needs derives from the one final slot.
|
||||||
|
*/
|
||||||
|
export const worktreeEnvironment = ({ branch, code, takenSlots = new Set() }) => {
|
||||||
|
let slot = preferredSlotForBranch(branch, code)
|
||||||
|
while (takenSlots.has(slot)) slot += 1
|
||||||
|
return {
|
||||||
|
slot,
|
||||||
|
devPort: devPortForSlot(slot),
|
||||||
|
devDatabase: devDatabaseForSlot(slot),
|
||||||
|
testDatabase: testDatabaseForSlot(slot),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,30 +2,55 @@
|
||||||
* `pnpm worktree` — worktree management determinístico em torno da fila de
|
* `pnpm worktree` — worktree management determinístico em torno da fila de
|
||||||
* claim do projeto (mesma fila de `agent:claim` / agent pool).
|
* claim do projeto (mesma fila de `agent:claim` / agent pool).
|
||||||
*
|
*
|
||||||
* pnpm worktree next [--go] cria worktree a partir de origin/main para a
|
* pnpm worktree next [--go] [--no-migrate]
|
||||||
* próxima Issue claimável; branch `<code>-<slug>`.
|
* cria worktree a partir de origin/main para a
|
||||||
* Com `--go`, imprime `cd <dir>` no fim — node
|
* próxima Issue claimável; branch `<code>-<slug>`.
|
||||||
* não muda o cwd do shell pai; o shell chamador
|
* Com `--go`, imprime `cd <dir>` no fim — node
|
||||||
* aplica (opencode/CDP usa o opencode command;
|
* não muda o cwd do shell pai; o shell chamador
|
||||||
* terminal interativo: função `worktree()` em
|
* aplica (opencode/CDP usa o opencode command;
|
||||||
* `.agents/shell/worktree.sh`, sourced no profile).
|
* terminal interativo: função `worktree()` em
|
||||||
|
* `.agents/shell/worktree.sh`, sourced no profile).
|
||||||
|
* Também PROVISIONA o ambiente isolado do worktree:
|
||||||
|
* porta do dev server + bancos próprios derivados
|
||||||
|
* deterministicamente do branch (ver
|
||||||
|
* scripts/lib/worktree-env.mjs), para agentes em
|
||||||
|
* paralelo não disputarem porta 3000 nem o
|
||||||
|
* `teqo_test` compartilhado. `--no-migrate` pula a
|
||||||
|
* aplicação das migrations nos bancos novos.
|
||||||
* pnpm worktree kill [--force] destrói o worktree em que o shell atual está
|
* pnpm worktree kill [--force] destrói o worktree em que o shell atual está
|
||||||
* (recusa worktree sujo sem `--force`)
|
* (recusa worktree sujo sem `--force`) e remove os
|
||||||
|
* bancos gerados do worktree (best-effort)
|
||||||
*
|
*
|
||||||
* Read-only no GitHub: `next` NUNCA claima (claim = `pnpm agent:claim`).
|
* Read-only no GitHub: `next` NUNCA claima (claim = `pnpm agent:claim`).
|
||||||
* Dir raiz: `~/.cursor/worktrees/teqo/` (mesma casa dos worktrees do Cursor).
|
* Dir raiz: `~/.cursor/worktrees/teqo/` (mesma casa dos worktrees do Cursor).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process'
|
import { execFileSync } from 'node:child_process'
|
||||||
|
import { randomBytes } from 'node:crypto'
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { homedir } from 'node:os'
|
import { homedir } from 'node:os'
|
||||||
import { join, resolve } from 'node:path'
|
import { join, resolve } from 'node:path'
|
||||||
|
|
||||||
|
import { parse as parseEnv } from 'dotenv'
|
||||||
|
import pg from 'pg'
|
||||||
|
|
||||||
import { dieAgent, nextClaimableIssue, parseArgs } from './lib/agent-github.mjs'
|
import { dieAgent, nextClaimableIssue, parseArgs } from './lib/agent-github.mjs'
|
||||||
|
import {
|
||||||
|
DEV_PORT_BASE,
|
||||||
|
GENERATED_ENV_MARKER,
|
||||||
|
isGeneratedDatabaseName,
|
||||||
|
worktreeEnvironment,
|
||||||
|
} from './lib/worktree-env.mjs'
|
||||||
import { branchNameForIssue } from './lib/worktree.mjs'
|
import { branchNameForIssue } from './lib/worktree.mjs'
|
||||||
|
|
||||||
const die = dieAgent('worktree')
|
const die = dieAgent('worktree')
|
||||||
const WORKTREES_ROOT = join(homedir(), '.cursor', 'worktrees', 'teqo')
|
const WORKTREES_ROOT = join(homedir(), '.cursor', 'worktrees', 'teqo')
|
||||||
|
|
||||||
|
/** Fixed compose project so `db:start`/provisioning always target ONE container. */
|
||||||
|
const SHARED_COMPOSE_PROJECT = 'teqo'
|
||||||
|
/** Maintenance database on the shared local container — only ever localhost. */
|
||||||
|
const MAINTENANCE_DATABASE_URL = 'postgresql://teqo:teqo@localhost:5432/postgres'
|
||||||
|
|
||||||
/** `git` wrapper; `okIfFails` swallows the failure and returns null instead. */
|
/** `git` wrapper; `okIfFails` swallows the failure and returns null instead. */
|
||||||
const git = (args, { okIfFails = false } = {}) => {
|
const git = (args, { okIfFails = false } = {}) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -56,7 +81,198 @@ const parseWorktreeList = (porcelain) => {
|
||||||
return entries
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmdNext = (go) => {
|
/**
|
||||||
|
* Slots already claimed by OTHER live worktrees, read from their generated
|
||||||
|
* `.env.local` (only files carrying the marker count — manual envs claim
|
||||||
|
* nothing and are never overwritten).
|
||||||
|
*/
|
||||||
|
const readLiveSlots = (entries, excludedDir) => {
|
||||||
|
const slots = new Set()
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (resolve(entry.path) === resolve(excludedDir)) continue
|
||||||
|
try {
|
||||||
|
const text = readFileSync(join(entry.path, '.env.local'), 'utf8')
|
||||||
|
if (!text.includes(GENERATED_ENV_MARKER)) continue
|
||||||
|
const match = /^PORT=([0-9]+)/m.exec(text)
|
||||||
|
const slot = match ? Number(match[1]) - DEV_PORT_BASE : null
|
||||||
|
if (slot !== null && Number.isInteger(slot) && slot >= 0) slots.add(slot)
|
||||||
|
} catch {
|
||||||
|
// worktree dir without a generated env file — nothing claimed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge the main repo's `.env` then `.env.local` (local wins) — secret source. */
|
||||||
|
const readMainEnv = (mainRoot) => {
|
||||||
|
const merged = {}
|
||||||
|
for (const name of ['.env', '.env.local']) {
|
||||||
|
try {
|
||||||
|
Object.assign(merged, parseEnv(readFileSync(join(mainRoot, name), 'utf8')))
|
||||||
|
} catch {
|
||||||
|
// file missing — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
const envFileIsGenerated = (dir, name) => {
|
||||||
|
try {
|
||||||
|
return readFileSync(join(dir, name), 'utf8').includes(GENERATED_ENV_MARKER)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start (or reuse) the ONE shared Postgres container from any worktree dir. */
|
||||||
|
const dockerComposeUp = (dir) => {
|
||||||
|
execFileSync(
|
||||||
|
'docker',
|
||||||
|
['compose', '-p', SHARED_COMPOSE_PROJECT, 'up', '-d', '--wait', 'postgres'],
|
||||||
|
{ cwd: dir, stdio: 'inherit' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `CREATE DATABASE` for each missing generated name on the shared container. */
|
||||||
|
const ensureDatabases = async (names) => {
|
||||||
|
const client = new pg.Client({
|
||||||
|
connectionString: MAINTENANCE_DATABASE_URL,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
})
|
||||||
|
await client.connect()
|
||||||
|
try {
|
||||||
|
for (const name of names) {
|
||||||
|
if (!isGeneratedDatabaseName(name)) die(`Nome de banco gerado inválido: ${name}`)
|
||||||
|
const { rows } = await client.query('SELECT 1 AS ok FROM pg_database WHERE datname = $1', [
|
||||||
|
name,
|
||||||
|
])
|
||||||
|
if (rows.length > 0) {
|
||||||
|
console.log(`[worktree] banco ${name} já existe.`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await client.query(`CREATE DATABASE ${name}`)
|
||||||
|
console.log(`[worktree] banco criado: ${name}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureWorktreeDeps = (dir) => {
|
||||||
|
if (existsSync(join(dir, 'node_modules', '.bin', 'payload'))) return
|
||||||
|
console.log('[worktree] ▶ node_modules ausente — pnpm install --frozen-lockfile …')
|
||||||
|
execFileSync('pnpm', ['install', '--frozen-lockfile'], { cwd: dir, stdio: 'inherit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const runMigrate = (dir, databaseUrl, payloadSecret) => {
|
||||||
|
console.log(`[worktree] ▶ migrations em ${databaseUrl} (uma vez por worktree)…`)
|
||||||
|
execFileSync('pnpm', ['migrate'], {
|
||||||
|
cwd: dir,
|
||||||
|
env: { ...process.env, DATABASE_URL: databaseUrl, PAYLOAD_SECRET: payloadSecret },
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cursor Cloud / no-Docker fallback: shared teqo + teqo_test, no port override. */
|
||||||
|
const writeFallbackEnv = (dir, branch) => {
|
||||||
|
const devEnv = [
|
||||||
|
GENERATED_ENV_MARKER,
|
||||||
|
'# fallback sem Docker (Cursor Cloud?): banco compartilhado, sem porta própria.',
|
||||||
|
`# branch ${branch}`,
|
||||||
|
'DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
const testEnv = [
|
||||||
|
GENERATED_ENV_MARKER,
|
||||||
|
'# fallback sem Docker (Cursor Cloud?): banco de teste compartilhado.',
|
||||||
|
`# branch ${branch}`,
|
||||||
|
'DATABASE_URL=postgresql://teqo:teqo@localhost:5432/teqo_test',
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
writeFileSync(join(dir, '.env.local'), devEnv)
|
||||||
|
writeFileSync(join(dir, '.env.test.local'), testEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic per-worktree environment: shared container + own databases +
|
||||||
|
* own dev-server port + env files + migrations. Skips everything when a
|
||||||
|
* manual `.env.local`/`.env.test.local` (no marker) exists — never clobbers.
|
||||||
|
*/
|
||||||
|
const provision = async ({ dir, branch, issue, env, skipMigrate, mainRoot }) => {
|
||||||
|
const manual =
|
||||||
|
(existsSync(join(dir, '.env.local')) && !envFileIsGenerated(dir, '.env.local')) ||
|
||||||
|
(existsSync(join(dir, '.env.test.local')) && !envFileIsGenerated(dir, '.env.test.local'))
|
||||||
|
if (manual) {
|
||||||
|
console.warn(
|
||||||
|
'[worktree] .env.local/.env.test.local manuais presentes — sem provisionar nem sobrescrever.',
|
||||||
|
)
|
||||||
|
console.warn(
|
||||||
|
'[worktree] Remova os arquivos manuais e rode `pnpm worktree next` de novo para o ambiente isolado.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
dockerComposeUp(dir)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[worktree] Docker indisponível — usando o banco compartilhado (teqo/teqo_test).')
|
||||||
|
console.warn(`[worktree] (${error.message.split('\n')[0]})`)
|
||||||
|
writeFallbackEnv(dir, branch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const devDatabase = env.devDatabase
|
||||||
|
const testDatabase = env.testDatabase
|
||||||
|
const devUrl = `postgresql://teqo:teqo@localhost:5432/${devDatabase}`
|
||||||
|
const testUrl = `postgresql://teqo:teqo@localhost:5432/${testDatabase}`
|
||||||
|
await ensureDatabases([devDatabase, testDatabase])
|
||||||
|
|
||||||
|
const mainEnv = readMainEnv(mainRoot)
|
||||||
|
const payloadSecret = mainEnv.PAYLOAD_SECRET ?? randomBytes(24).toString('hex')
|
||||||
|
const copy = (key) => (mainEnv[key] ? [`${key}=${mainEnv[key]}`] : [])
|
||||||
|
|
||||||
|
const devEnv = [
|
||||||
|
GENERATED_ENV_MARKER,
|
||||||
|
`# branch ${branch} · issue #${issue.number} · slot ${env.slot} — gerado por pnpm worktree next`,
|
||||||
|
`DATABASE_URL=${devUrl}`,
|
||||||
|
`PORT=${env.devPort}`,
|
||||||
|
`NEXT_PUBLIC_SITE_URL=http://localhost:${env.devPort}`,
|
||||||
|
`PLAYWRIGHT_BASE_URL=http://localhost:${env.devPort}`,
|
||||||
|
`PAYLOAD_SECRET=${payloadSecret}`,
|
||||||
|
...copy('BLOB_READ_WRITE_TOKEN'),
|
||||||
|
...copy('NEXT_PUBLIC_VAPID_PUBLIC_KEY'),
|
||||||
|
...copy('VAPID_PUBLIC_KEY'),
|
||||||
|
...copy('VAPID_PRIVATE_KEY'),
|
||||||
|
...copy('VAPID_SUBJECT'),
|
||||||
|
...copy('RESEND_API_KEY'),
|
||||||
|
...copy('CAMPAIGN_EMAIL_FROM'),
|
||||||
|
...copy('CAMPAIGN_EMAIL_FROM_NAME'),
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
writeFileSync(join(dir, '.env.local'), devEnv)
|
||||||
|
console.log(`[worktree] .env.local escrito (porta ${env.devPort}).`)
|
||||||
|
|
||||||
|
const testEnv = [
|
||||||
|
GENERATED_ENV_MARKER,
|
||||||
|
`# branch ${branch} · issue #${issue.number} · slot ${env.slot} — gerado por pnpm worktree next`,
|
||||||
|
'# Lido DEPOIS de .env.test (vitest.setup.ts / playwright.config.ts) — suites isoladas.',
|
||||||
|
`DATABASE_URL=${testUrl}`,
|
||||||
|
`NEXT_PUBLIC_SITE_URL=http://localhost:${env.devPort}`,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
writeFileSync(join(dir, '.env.test.local'), testEnv)
|
||||||
|
console.log('[worktree] .env.test.local escrito.')
|
||||||
|
|
||||||
|
if (skipMigrate) {
|
||||||
|
console.log('[worktree] --no-migrate: migrations ficam para depois (pnpm migrate).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureWorktreeDeps(dir)
|
||||||
|
runMigrate(dir, devUrl, payloadSecret)
|
||||||
|
runMigrate(dir, testUrl, payloadSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmdNext = async (go, skipMigrate) => {
|
||||||
const pick = nextClaimableIssue()
|
const pick = nextClaimableIssue()
|
||||||
if (!pick) {
|
if (!pick) {
|
||||||
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm agent:status` para ver a fila.')
|
die('Fila vazia — nada `ready` desbloqueado. Rode `pnpm agent:status` para ver a fila.')
|
||||||
|
|
@ -89,10 +305,64 @@ const cmdNext = (go) => {
|
||||||
console.log('Issue NÃO claimada — claim continua sendo `pnpm agent:claim`.')
|
console.log('Issue NÃO claimada — claim continua sendo `pnpm agent:claim`.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mainRoot = entries[0]?.path
|
||||||
|
const env = worktreeEnvironment({
|
||||||
|
branch,
|
||||||
|
code: pick.meta.id,
|
||||||
|
takenSlots: readLiveSlots(entries, dir),
|
||||||
|
})
|
||||||
|
await provision({ dir, branch, issue, env, skipMigrate, mainRoot })
|
||||||
|
|
||||||
|
console.log(`\nAmbiente isolado do worktree (slot ${env.slot}):`)
|
||||||
|
console.log(` dev server: http://localhost:${env.devPort} (pnpm dev)`)
|
||||||
|
console.log(` banco dev: postgresql://teqo:teqo@localhost:5432/${env.devDatabase}`)
|
||||||
|
console.log(` banco test: postgresql://teqo:teqo@localhost:5432/${env.testDatabase}`)
|
||||||
|
|
||||||
if (go) console.log(`cd ${dir}`)
|
if (go) console.log(`cd ${dir}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmdKill = (force) => {
|
/** Generated database names referenced by a worktree's own env files. */
|
||||||
|
const worktreeDatabaseNamesOf = (dir) => {
|
||||||
|
const names = []
|
||||||
|
for (const file of ['.env.local', '.env.test.local']) {
|
||||||
|
try {
|
||||||
|
const text = readFileSync(join(dir, file), 'utf8')
|
||||||
|
if (!text.includes(GENERATED_ENV_MARKER)) continue
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const match = /^DATABASE_URL=postgresql:\/\/[^/]+\/([a-z0-9_]+)$/.exec(line.trim())
|
||||||
|
if (match && isGeneratedDatabaseName(match[1])) names.push(match[1])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no env file — nothing to clean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(names)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort DROP DATABASE (WITH FORCE) of the destroyed worktree's DBs. */
|
||||||
|
const dropWorktreeDatabases = async (names) => {
|
||||||
|
if (names.length === 0) return
|
||||||
|
try {
|
||||||
|
const client = new pg.Client({
|
||||||
|
connectionString: MAINTENANCE_DATABASE_URL,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
})
|
||||||
|
await client.connect()
|
||||||
|
try {
|
||||||
|
for (const name of names) {
|
||||||
|
await client.query(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`)
|
||||||
|
console.log(`[worktree] banco removido: ${name}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end().catch(() => {})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[worktree] não consegui remover os bancos ${names.join(', ')}: ${error.message}`)
|
||||||
|
console.warn('[worktree] remova manualmente: docker exec teqo-postgres-1 dropdb -U teqo <nome>')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmdKill = async (force) => {
|
||||||
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
|
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
|
||||||
const mainRoot = entries[0]?.path
|
const mainRoot = entries[0]?.path
|
||||||
if (!mainRoot) die('Não consegui ler os worktrees deste repo.')
|
if (!mainRoot) die('Não consegui ler os worktrees deste repo.')
|
||||||
|
|
@ -118,31 +388,39 @@ const cmdKill = (force) => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read the generated DB names BEFORE the directory is removed.
|
||||||
|
const databaseNames = worktreeDatabaseNamesOf(top)
|
||||||
|
|
||||||
git(['-C', mainRoot, 'worktree', 'remove', '--force', top])
|
git(['-C', mainRoot, 'worktree', 'remove', '--force', top])
|
||||||
if (branch) git(['-C', mainRoot, 'branch', '-D', branch])
|
if (branch) git(['-C', mainRoot, 'branch', '-D', branch])
|
||||||
|
|
||||||
console.log(`Worktree destruído: ${top}`)
|
console.log(`Worktree destruído: ${top}`)
|
||||||
console.log(` branch removido: ${branch ?? '(detached — nada a remover)'}`)
|
console.log(` branch removido: ${branch ?? '(detached — nada a remover)'}`)
|
||||||
console.log(`Volte ao main: cd ${mainRoot}`)
|
console.log(`Volte ao main: cd ${mainRoot}`)
|
||||||
|
|
||||||
|
await dropWorktreeDatabases(databaseNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { flags, positional } = parseArgs(process.argv.slice(2), new Set())
|
const { flags, positional } = parseArgs(process.argv.slice(2), new Set())
|
||||||
const subcommand = positional[0]
|
const subcommand = positional[0]
|
||||||
|
|
||||||
if (!subcommand) {
|
if (!subcommand) {
|
||||||
console.log('Uso: pnpm worktree next [--go] | kill [--force]')
|
console.log('Uso: pnpm worktree next [--go] [--no-migrate] | kill [--force]')
|
||||||
console.log(' next [--go] cria worktree da próxima Issue claimável (branch <code>-<slug>);')
|
console.log(' next [--go] [--no-migrate]')
|
||||||
console.log(' com --go imprime `cd <dir>` no fim (quem aplica o cd: opencode')
|
console.log(' cria worktree da próxima Issue claimável (branch <code>-<slug>) e provisiona o')
|
||||||
console.log(' command, ou a função `worktree()` de .agents/shell/worktree.sh)')
|
console.log(' ambiente isolado: porta de dev + bancos próprios (determinístico do branch);')
|
||||||
console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force)')
|
console.log(' com --go imprime `cd <dir>` no fim (quem aplica o cd: opencode command, ou a')
|
||||||
|
console.log(' função `worktree()` de .agents/shell/worktree.sh); --no-migrate pula migrations')
|
||||||
|
console.log(' kill [--force] destrói o worktree em que você está (recusa sujo sem --force) e')
|
||||||
|
console.log(' remove os bancos gerados do worktree (best-effort)')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (subcommand === 'next') cmdNext(Boolean(flags.go))
|
if (subcommand === 'next') await cmdNext(Boolean(flags.go), Boolean(flags['no-migrate']))
|
||||||
else if (subcommand === 'kill') {
|
else if (subcommand === 'kill') {
|
||||||
if (flags.go) die('`--go` só faz sentido com `next`.')
|
if (flags.go) die('`--go` só faz sentido com `next`.')
|
||||||
cmdKill(Boolean(flags.force))
|
await cmdKill(Boolean(flags.force))
|
||||||
} else die(`subcomando desconhecido: ${subcommand} (esperado: next | kill)`)
|
} else die(`subcomando desconhecido: ${subcommand} (esperado: next | kill)`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.stderr) die(error.stderr.toString().trim())
|
if (error?.stderr) die(error.stderr.toString().trim())
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,20 @@
|
||||||
*
|
*
|
||||||
* The tests delete and recreate records (see seedUser.ts), so they must NEVER
|
* The tests delete and recreate records (see seedUser.ts), so they must NEVER
|
||||||
* run against the production database. Rather than trying to detect prod (an
|
* run against the production database. Rather than trying to detect prod (an
|
||||||
* allowlist we could forget to update), we require the exact local test
|
* allowlist we could forget to update), we require a LOCAL database whose name
|
||||||
* database. A remote database is rejected even if its name looks test-like.
|
* is `teqo_test` or `teqo_<worktree>_test` (per-worktree isolation generated
|
||||||
|
* by `pnpm worktree next`). A remote database is rejected even if its name
|
||||||
|
* looks test-like.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** `teqo_test`, `teqo_wt15_test`, … — the _test suffix is the hard contract. */
|
||||||
|
const TEST_DATABASE_NAME = /^teqo(_[a-z0-9]+)?_test$/
|
||||||
|
|
||||||
export function assertTestDatabase(databaseUrl: string | undefined): void {
|
export function assertTestDatabase(databaseUrl: string | undefined): void {
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'DATABASE_URL is not set. Tests require a dedicated test database configured in .env.test ' +
|
'DATABASE_URL is not set. Tests require a dedicated local test database configured in ' +
|
||||||
'(e.g. postgresql://teqo:teqo@localhost:5432/teqo_test).',
|
'.env.test (e.g. postgresql://teqo:teqo@localhost:5432/teqo_test).',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,13 +32,14 @@ export function assertTestDatabase(databaseUrl: string | undefined): void {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
parsedUrl.protocol !== 'postgresql:' ||
|
parsedUrl.protocol !== 'postgresql:' ||
|
||||||
databaseName !== 'teqo_test' ||
|
!TEST_DATABASE_NAME.test(databaseName) ||
|
||||||
!localHosts.has(parsedUrl.hostname)
|
!localHosts.has(parsedUrl.hostname)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Refusing to run tests using protocol "${parsedUrl.protocol || '(unknown)'}" against database ` +
|
`Refusing to run tests using protocol "${parsedUrl.protocol || '(unknown)'}" against database ` +
|
||||||
`"${databaseName || '(unknown)'}" on host ` +
|
`"${databaseName || '(unknown)'}" on host ` +
|
||||||
`"${parsedUrl.hostname || '(unknown)'}". Tests require the exact database "teqo_test" ` +
|
`"${parsedUrl.hostname || '(unknown)'}". Tests require a local database named ` +
|
||||||
|
'`teqo_test` or `teqo_<worktree>_test` (per-worktree isolation from `pnpm worktree next`) ' +
|
||||||
'over postgresql: on localhost, 127.0.0.1, or ::1. ' +
|
'over postgresql: on localhost, 127.0.0.1, or ::1. ' +
|
||||||
'Configure DATABASE_URL in .env.test (e.g. postgresql://teqo:teqo@localhost:5432/teqo_test).',
|
'Configure DATABASE_URL in .env.test (e.g. postgresql://teqo:teqo@localhost:5432/teqo_test).',
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,22 @@ import { assertTestDatabase } from '../helpers/assertTestDatabase'
|
||||||
describe('assertTestDatabase', () => {
|
describe('assertTestDatabase', () => {
|
||||||
it.each([
|
it.each([
|
||||||
'postgresql://teqo:teqo@localhost:5432/teqo_test',
|
'postgresql://teqo:teqo@localhost:5432/teqo_test',
|
||||||
|
'postgresql://teqo:teqo@localhost:5432/teqo_wt15_test',
|
||||||
'postgresql://teqo:teqo@127.0.0.1:5432/teqo_test',
|
'postgresql://teqo:teqo@127.0.0.1:5432/teqo_test',
|
||||||
|
'postgresql://teqo:teqo@127.0.0.1:5432/teqo_wt164_test',
|
||||||
'postgresql://teqo:teqo@[::1]:5432/teqo_test',
|
'postgresql://teqo:teqo@[::1]:5432/teqo_test',
|
||||||
])('accepts the exact local test database at %s', (databaseUrl) => {
|
])('accepts the local test database at %s', (databaseUrl) => {
|
||||||
expect(() => assertTestDatabase(databaseUrl)).not.toThrow()
|
expect(() => assertTestDatabase(databaseUrl)).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
'postgresql://teqo:teqo@localhost:5432/other_test',
|
'postgresql://teqo:teqo@localhost:5432/other_test',
|
||||||
'postgresql://teqo:teqo@localhost:5432/teqo',
|
'postgresql://teqo:teqo@localhost:5432/teqo',
|
||||||
|
'postgresql://teqo:teqo@localhost:5432/teqo_wt15',
|
||||||
|
'postgresql://teqo:teqo@localhost:5432/teqo_test_extra',
|
||||||
|
'postgresql://teqo:teqo@localhost:5432/teqo_Test_test',
|
||||||
'postgresql://teqo:teqo@db.example.com:5432/teqo_test',
|
'postgresql://teqo:teqo@db.example.com:5432/teqo_test',
|
||||||
'postgresql://teqo:teqo@neon.example.com:5432/other_test',
|
'postgresql://teqo:teqo@neon.example.com:5432/teqo_wt15_test',
|
||||||
'postgres://teqo:teqo@localhost:5432/teqo_test',
|
'postgres://teqo:teqo@localhost:5432/teqo_test',
|
||||||
'https://localhost/teqo_test',
|
'https://localhost/teqo_test',
|
||||||
'postgresql+srv://teqo:teqo@localhost:5432/teqo_test',
|
'postgresql+srv://teqo:teqo@localhost:5432/teqo_test',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,16 @@
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
GENERATED_ENV_MARKER,
|
||||||
|
devDatabaseForSlot,
|
||||||
|
devPortForSlot,
|
||||||
|
hashSlotOf,
|
||||||
|
isGeneratedDatabaseName,
|
||||||
|
numericSlotOfCode,
|
||||||
|
testDatabaseForSlot,
|
||||||
|
worktreeEnvironment,
|
||||||
|
} from '../../scripts/lib/worktree-env.mjs'
|
||||||
import { branchNameForIssue, issueCodeAndSubject } from '../../scripts/lib/worktree.mjs'
|
import { branchNameForIssue, issueCodeAndSubject } from '../../scripts/lib/worktree.mjs'
|
||||||
|
|
||||||
type TestIssue = {
|
type TestIssue = {
|
||||||
|
|
@ -67,3 +77,70 @@ describe('branchNameForIssue', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('worktreeEnvironment (per-worktree ports and databases)', () => {
|
||||||
|
it('derives slot, port and database names deterministically from the branch', () => {
|
||||||
|
const env = worktreeEnvironment({ branch: 'C15-foo', code: 'C15' })
|
||||||
|
expect(env).toEqual(worktreeEnvironment({ branch: 'C15-foo', code: 'C15' }))
|
||||||
|
expect(env).toEqual({
|
||||||
|
slot: 15,
|
||||||
|
devPort: 3115,
|
||||||
|
devDatabase: 'teqo_wt15',
|
||||||
|
testDatabase: 'teqo_wt15_test',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the numeric part of the issue code as the preferred slot', () => {
|
||||||
|
expect(numericSlotOfCode('C15')).toBe(15)
|
||||||
|
expect(numericSlotOfCode('B164')).toBe(164)
|
||||||
|
expect(numericSlotOfCode('P3')).toBe(3)
|
||||||
|
expect(numericSlotOfCode('E2')).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a stable hash slot for codes without digits', () => {
|
||||||
|
expect(numericSlotOfCode('X')).toBeNull()
|
||||||
|
const env = worktreeEnvironment({ branch: 'X-foo', code: 'X' })
|
||||||
|
expect(env.slot).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(env.slot).toBeLessThanOrEqual(999)
|
||||||
|
expect(env).toEqual(worktreeEnvironment({ branch: 'X-foo', code: 'X' }))
|
||||||
|
expect(hashSlotOf('X-foo')).toBe(hashSlotOf('X-foo'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bumps to the next free slot when the preferred one is taken', () => {
|
||||||
|
const env = worktreeEnvironment({
|
||||||
|
branch: 'A15-bar',
|
||||||
|
code: 'A15',
|
||||||
|
takenSlots: new Set([15, 16]),
|
||||||
|
})
|
||||||
|
expect(env.slot).toBe(17)
|
||||||
|
expect(env.devPort).toBe(3117)
|
||||||
|
expect(env.devDatabase).toBe('teqo_wt17')
|
||||||
|
expect(env.testDatabase).toBe('teqo_wt17_test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps port and database names consistent after a bump', () => {
|
||||||
|
const env = worktreeEnvironment({ branch: 'C15-foo', code: 'C15', takenSlots: new Set([15]) })
|
||||||
|
expect(devPortForSlot(env.slot)).toBe(env.devPort)
|
||||||
|
expect(devDatabaseForSlot(env.slot)).toBe(env.devDatabase)
|
||||||
|
expect(testDatabaseForSlot(env.slot)).toBe(env.testDatabase)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps huge codes into the hash range instead of absurd ports', () => {
|
||||||
|
const env = worktreeEnvironment({ branch: 'C999999-foo', code: 'C999999' })
|
||||||
|
expect(env.slot).toBeLessThanOrEqual(999)
|
||||||
|
expect(env.devPort).toBeLessThanOrEqual(4099)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts only generated names for CREATE/DROP DATABASE', () => {
|
||||||
|
expect(isGeneratedDatabaseName('teqo_wt15')).toBe(true)
|
||||||
|
expect(isGeneratedDatabaseName('teqo_wt15_test')).toBe(true)
|
||||||
|
expect(isGeneratedDatabaseName('teqo')).toBe(false)
|
||||||
|
expect(isGeneratedDatabaseName('teqo_test')).toBe(false)
|
||||||
|
expect(isGeneratedDatabaseName('teqo_wt15_extra')).toBe(false)
|
||||||
|
expect(isGeneratedDatabaseName('teqo_wt15_test; DROP TABLE x')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pins the marker used to detect generated env files', () => {
|
||||||
|
expect(GENERATED_ENV_MARKER).toContain('generated by pnpm worktree next')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import { config as loadEnv } from 'dotenv'
|
||||||
import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
|
import { assertTestDatabase } from './tests/helpers/assertTestDatabase'
|
||||||
|
|
||||||
loadEnv({ path: '.env.test', override: true })
|
loadEnv({ path: '.env.test', override: true })
|
||||||
|
// Per-worktree isolation: `.env.test.local` (gitignored, written by
|
||||||
|
// `pnpm worktree next`) wins over the committed `.env.test` when present.
|
||||||
|
loadEnv({ path: '.env.test.local', override: true })
|
||||||
|
|
||||||
// Optional local override when the default Docker port is occupied (e.g. mapped to 5433).
|
// Optional local override when the default Docker port is occupied (e.g. mapped to 5433).
|
||||||
if (process.env.TEQO_TEST_DATABASE_URL) {
|
if (process.env.TEQO_TEST_DATABASE_URL) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue