fix(worktree): plan cria um worktree diferente por invocação (paralelismo /plan-issue)

Cada 'pnpm worktree plan' agora deriva um branch próprio: plans/plan-issue-<bag>
quando um bag é passado (sufixo -2/-3 em colisão) ou o próximo sequencial
plans/plan-issue-<n> quando não. O prefixo minúsculo plans/… nunca colide com o
branch <Code>-<slug> de 'next'. planBranchName (scripts/lib/worktree.mjs)
unit-testado; docs e shell/command atualizados.
This commit is contained in:
Francisco Solla 2026-08-08 16:40:05 -03:00
parent 819a015911
commit 6c8c466b40
8 changed files with 173 additions and 61 deletions

View file

@ -11,9 +11,11 @@
# Uso (terminal interativo):
# worktree next [--go] cria/reutiliza o worktree da próxima Issue claimável;
# com --go, cd para dentro dele
# worktree plan [--go] cria/reutiliza o worktree de planejamento do /plan-issue
# (branch fixo plans/plan-issue, sem vínculo com a fila);
# com --go, cd para dentro dele
# worktree plan [bag] [--go] cria um worktree de planejamento do /plan-issue
# DIFERENTE a cada chamada (sessões paralelas): com bag,
# branch plans/plan-issue-<bag> (sufixo -2/-3 se o nome
# já existir), sem bag o próximo plans/plan-issue-<n>
# sequencial; com --go, cd para dentro dele
# worktree kill [--force]
#
# Read-only no GitHub — claim continua sendo `pnpm agent:claim`.

View file

@ -20,13 +20,13 @@ A lógica determinística (fila → código+slug → branch → worktree) vive e
```bash
pnpm worktree next [--go] [--no-migrate] # cria worktree de origin/main + provisiona o ambiente
pnpm worktree plan [--go] [--no-migrate] # worktree de PLANEJAMENTO (/plan-issue), branch fixo plans/plan-issue
pnpm worktree plan [bag] [--go] [--no-migrate] # worktree de PLANEJAMENTO (/plan-issue): um DIFERENTE por invocação
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.
`plan` é o primo do `next`: worktree de **planejamento** (branch fixo `plans/plan-issue`) para rodar a skill `/plan-issue` sem ocupar o main. Não é nomeado por Issue nenhuma — proposital, para um `next` posterior da próxima Issue nunca colidir em branch nem em slot. O fluxo desta skill (fila → próxima Issue → implementação) é só `next`.
`plan` é o primo do `next`: worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main. **Cada invocação cria um DIFERENTE** (sessões paralelas): com `bag` opcional → branch `plans/plan-issue-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag` → próximo sequencial `plans/plan-issue-<n>` livre. Não é nomeado por Issue nenhuma — proposital, para um `next` posterior da próxima Issue nunca colidir em branch nem em slot (prefixo minúsculo `plans/…` vs branch `<Code>-<slug>` sempre uppercase-led). O fluxo desta skill (fila → próxima Issue → implementação) é só `next`.
No terminal interativo, para `--go` trocar de diretório de verdade, use a função `worktree()` de **`.agents/shell/worktree.sh`** (uma linha de `source` no profile): o script imprime `cd <dir>` e a função o aplica no shell que te chamou — node não consegue mudar o cwd do shell pai.

View file

@ -1,11 +1,11 @@
---
description: Cria o worktree da próxima Issue claimável (next [--go]), o worktree de planejamento do /plan-issue (plan [--go]) ou destrói o worktree atual (kill [--force])
description: Cria o worktree da próxima Issue claimável (next [--go]), um worktree novo de planejamento do /plan-issue (plan [bag] [--go]) ou destrói o worktree atual (kill [--force])
---
Rode o script determinístico abaixo (não improvise a lógica) e reporte o resultado de forma concisa.
- `next [--go]`: cria (ou reutiliza) o worktree da próxima Issue claimável. Com `--go`, o script imprime `cd <dir>` na última linha — aplique-o na sessão de shell atual (`cd <dir>`) e reporte o novo diretório.
- `plan [--go]`: cria (ou reutiliza) o worktree de planejamento (branch fixo `plans/plan-issue`, sem vínculo com a fila) para rodar `/plan-issue` sem ocupar o main; nunca conflita com `next`.
- `plan [bag] [--go]`: cria um worktree de planejamento **diferente a cada invocação** (branch `plans/plan-issue-<bag>`, ou `plans/plan-issue-<n>` sequencial sem bag; sufixo `-2`/`-3` em colisão) para rodar `/plan-issue` sem ocupar o main; nunca conflita com `next`.
- `kill [--force]`: destrói o worktree em que você está.
!`node scripts/worktree.mjs $ARGUMENTS`

View file

@ -34,7 +34,7 @@ These are product/architecture constraints — not a ban on editing existing cod
- `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).
- `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. `worktree plan [--go]` (2026-08-08) is the sibling for the `/plan-issue` skill: same provisioning on the fixed branch `plans/plan-issue` (`PLAN_WORKTREE_BRANCH`, `scripts/lib/worktree.mjs`), deliberately **not** named after any Issue so a later `next` never collides in branch or slot. `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.
- **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. `worktree plan [bag] [--go]` (2026-08-08) is the sibling for the `/plan-issue` skill: **each invocation creates a DIFFERENT planning worktree** so parallel `/plan-issue` sessions never share one — branch `plans/plan-issue-<bag>` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-<n>` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `<Code>-<slug>`, so a later `next` never collides in branch or slot. `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 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.

View file

@ -4,7 +4,7 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
---
**Recently resolved (2026-08-08):** **`pnpm worktree plan [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: branch fixo `plans/plan-issue` (`PLAN_WORKTREE_BRANCH`, `scripts/lib/worktree.mjs`), deliberadamente **não** nomeado por Issue nenhuma — um `next` posterior da próxima Issue nunca colide em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Constant unit-testada.
**Recently resolved (2026-08-08):** **`pnpm worktree plan [bag] [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: **cada invocação cria UM DIFERENTE**, para sessões de planejamento paralelas — com `bag`, branch `plans/plan-issue-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-<n>` livre (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`). Prefixo minúsculo `plans/…` nunca colide com um `next` posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Naming unit-testada.
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).

View file

@ -9,11 +9,15 @@
import { slugify } from '../../src/lib/slug.ts'
/**
* Generic branch of the /plan-issue planning worktree (`pnpm worktree plan`).
* Deliberately NOT tied to any Issue: it must never collide with the
* `<code>-<slug>` branch `worktree next` derives for the next claimable Issue.
* Prefix of every `/plan-issue` planning-worktree branch (`pnpm worktree
* plan`). Lowercase-led `plans/…`, so it can never collide with a `next`
* branch — `<Code>-<slug>` is always uppercase-led — neither in branch name
* nor, structurally, in the port/slot space derived from the branch.
*/
export const PLAN_WORKTREE_BRANCH = 'plans/plan-issue'
export const PLAN_BRANCH_PREFIX = 'plans/plan-issue'
/** Total branch-name budget for plan branches — mirrors `branchNameForIssue`. */
const PLAN_BRANCH_MAX_LENGTH = 60
/** Strip the leading `<code> — ` (or any dash variant) off a title. */
const stripCodePrefix = (title, code) => {
@ -49,3 +53,39 @@ export const branchNameForIssue = (issue, maxLength = 60) => {
const keep = Math.max(1, maxLength - code.length - 1)
return `${code}-${slug.slice(0, keep)}`
}
/**
* Branch for a `/plan-issue` planning worktree. Every invocation must land on
* a DIFFERENT branch so parallel planning sessions coexist:
* - `bag` given → `plans/plan-issue-<bag-slug>`; if that name is already
* taken, `plans/plan-issue-<bag-slug>-2`, `-3`, …
* - no `bag` → the next free sequential `plans/plan-issue-1`, `-2`, `-3`, …
* `taken` = branch short-names already alive (local refs + origin); when a
* name is free it is reused only as a name-free slot — the branch is created
* fresh from `origin/main` each time. The lowercase `plans/…` prefix cannot
* collide with `next`'s uppercase-led `<Code>-<slug>` branches.
*/
export const planBranchName = ({ bag = '', taken = new Set() }) => {
const hasBag = typeof bag === 'string' && bag.trim().length > 0
if (!hasBag) {
for (let n = 1; ; n += 1) {
const candidate = `${PLAN_BRANCH_PREFIX}-${n}`
if (!taken.has(candidate)) return candidate
}
}
const slug = slugify(bag) || 'plano'
const base = `${PLAN_BRANCH_PREFIX}-${slug.slice(
0,
PLAN_BRANCH_MAX_LENGTH - PLAN_BRANCH_PREFIX.length - 1,
)}`
if (!taken.has(base)) return base
for (let n = 2; ; n += 1) {
const suffix = `-${n}`
const keep = Math.max(1, PLAN_BRANCH_MAX_LENGTH - PLAN_BRANCH_PREFIX.length - 1 - suffix.length)
const candidate = `${PLAN_BRANCH_PREFIX}-${slug.slice(0, keep)}${suffix}`
if (!taken.has(candidate)) return candidate
}
}

View file

@ -17,14 +17,17 @@
* paralelo não disputarem porta 3000 nem o
* `teqo_test` compartilhado. `--no-migrate` pula a
* aplicação das migrations nos bancos novos.
* pnpm worktree plan [--go] [--no-migrate]
* cria/reutiliza o worktree de PLANEJAMENTO
* (branch fixo `plans/plan-issue`) para rodar a
* skill /plan-issue sem ocupar o main; NÃO é
* nomeado nem derivado de Issue nenhuma, então um
* `next` posterior da próxima Issue nunca colide
* (nem no branch, nem no slot). Mesmo provisionamento
* isolado do `next`.
* pnpm worktree plan [bag] [--go] [--no-migrate]
* cria um worktree de PLANEJAMENTO novo para rodar
* a skill /plan-issue sem ocupar o main — cada
* invocação cria UM DIFERENTE, para sessões de
* planejamento paralelas: com `bag`, branch
* `plans/plan-issue-<bag>` (e `-2`, `-3`, … se o
* nome já estiver vivo); sem `bag`, o próximo
* sequencial `plans/plan-issue-<n>` livre. Nenhum
* deles (branch nem slot) colide com um `next`
* posterior (prefixo minúsculo `plans/…`).
* Mesmo provisionamento isolado do `next`.
* pnpm worktree kill [--force] destrói o worktree em que o shell atual está
* (recusa worktree sujo sem `--force`) e remove os
* bancos gerados do worktree (best-effort)
@ -49,7 +52,7 @@ import {
isGeneratedDatabaseName,
worktreeEnvironment,
} from './lib/worktree-env.mjs'
import { branchNameForIssue, PLAN_WORKTREE_BRANCH } from './lib/worktree.mjs'
import { branchNameForIssue, planBranchName } from './lib/worktree.mjs'
const die = dieAgent('worktree')
const WORKTREES_ROOT = join(homedir(), '.cursor', 'worktrees', 'teqo')
@ -335,37 +338,60 @@ const cmdNext = async (go, skipMigrate) => {
}
/**
* `plan` — the /plan-issue planning worktree. Deliberately NOT tied to the
* claim queue and NOT named after any Issue: `PLAN_WORKTREE_BRANCH` is a fixed
* generic branch, so a later `pnpm worktree next` for the next claimable Issue
* never collides with it (branch name nor slot — the plan env registers its
* hashed slot via the marker and `next` bumps around it). Same isolated env
* provisioning as `next`.
* `plan` — a `/plan-issue` planning worktree. Deliberately NOT tied to the
* claim queue and NOT named after any Issue: the branch is derived either from
* an optional `bag` slug (`plans/plan-issue-<bag>`, suffixed `-2`, `-3`, … on
* collision) or from the next free sequential `plans/plan-issue-<n>`, so a
* later `pnpm worktree next` for the next claimable Issue never collides with
* it (branch name nor slot — each plan env registers its hashed slot via the
* marker and `next` bumps around it). Every invocation creates a DIFFERENT
* worktree — parallel `/plan-issue` sessions never share one. Same isolated
* env provisioning as `next`.
*/
const cmdPlan = async (go, skipMigrate) => {
const branch = PLAN_WORKTREE_BRANCH
const cmdPlan = async (go, skipMigrate, bag) => {
git(['fetch', 'origin'])
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
const taken = new Set()
for (const scope of [
(
git(['for-each-ref', '--format=%(refname:short)', 'refs/heads'], {
okIfFails: true,
}) ?? ''
).split('\n'),
(
git(['for-each-ref', '--format=%(refname:short)', 'refs/remotes/origin'], {
okIfFails: true,
}) ?? ''
)
.split('\n')
.map((name) => name.replace(/^origin\//, '')),
]) {
for (const name of scope) if (name) taken.add(name)
}
const branch = planBranchName({ bag, taken })
if (git(['check-ref-format', '--allow-onelevel', branch], { okIfFails: true }) === null) {
die(`Branch plan inválido para refname: ${branch}`)
}
const dir = join(WORKTREES_ROOT, branch)
git(['fetch', 'origin'])
const entries = parseWorktreeList(git(['worktree', 'list', '--porcelain']))
if (entries.some((entry) => resolve(entry.path) === resolve(dir))) {
console.log(`Worktree de planejamento já existe em ${dir} — reutilizando, sem duplicar.`)
} else {
const branchExists =
git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { okIfFails: true }) !== null
const flag = branchExists ? '-B' : '-b'
git(['worktree', 'add', flag, branch, dir, 'origin/main'])
console.log('Worktree de planejamento criado:')
console.log(` branch: ${branch}`)
console.log(` path: ${dir}`)
console.log(' origem: origin/main')
die(
`Já existe um worktree de planejamento em ${dir} (branch ${branch} não detectado nos refs). Rode \`pnpm worktree kill\` de dentro dele.`,
)
}
const label = bag && bag.trim() ? `lote "${bag}"` : 'sequencial'
git(['worktree', 'add', '-b', branch, dir, 'origin/main'])
console.log('Worktree de planejamento criado:')
console.log(` sessão: ${label}`)
console.log(` branch: ${branch}`)
console.log(` path: ${dir}`)
console.log(' origem: origin/main')
const mainRoot = entries[0]?.path
const env = worktreeEnvironment({
branch,
@ -467,19 +493,20 @@ const subcommand = positional[0]
if (!subcommand) {
console.log(
'Uso: pnpm worktree next [--go] [--no-migrate] | plan [--go] [--no-migrate] | kill [--force]',
'Uso: pnpm worktree next [--go] [--no-migrate] | plan [bag] [--go] [--no-migrate] | kill [--force]',
)
console.log(' next [--go] [--no-migrate]')
console.log(' cria worktree da próxima Issue claimável (branch <code>-<slug>) e provisiona o')
console.log(' ambiente isolado: porta de dev + bancos próprios (determinístico do branch);')
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(' plan [--go] [--no-migrate]')
console.log(`\n plan [bag] [--go] [--no-migrate]`)
console.log(
' cria/reutiliza o worktree de planejamento (branch plans/plan-issue) para rodar a',
' cria um worktree de planejamento DIFERENTE a cada invocação (sessões /plan-issue',
)
console.log(' skill /plan-issue sem ocupar o main nem travar na fila; provisiona o mesmo')
console.log(' ambiente isolado de `next`; nunca colide com o branch <code>-<slug> de `next`')
console.log(' paralelas): com bag, branch plans/plan-issue-<bag> (sufixo -2/-3 se o nome já')
console.log(' existir); sem bag, o próximo plans/plan-issue-<n> sequencial livre; o prefixo')
console.log(' minúsculo plans/… nunca colide com o branch <code>-<slug> de `next`')
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)
@ -487,7 +514,8 @@ if (!subcommand) {
try {
if (subcommand === 'next') await cmdNext(Boolean(flags.go), Boolean(flags['no-migrate']))
else if (subcommand === 'plan') await cmdPlan(Boolean(flags.go), Boolean(flags['no-migrate']))
else if (subcommand === 'plan')
await cmdPlan(Boolean(flags.go), Boolean(flags['no-migrate']), positional[1])
else if (subcommand === 'kill') {
if (flags.go) die('`--go` só faz sentido com `next`.')
await cmdKill(Boolean(flags.force))

View file

@ -15,7 +15,8 @@ import {
import {
branchNameForIssue,
issueCodeAndSubject,
PLAN_WORKTREE_BRANCH,
PLAN_BRANCH_PREFIX,
planBranchName,
} from '../../scripts/lib/worktree.mjs'
type TestIssue = {
@ -82,6 +83,50 @@ describe('branchNameForIssue', () => {
})
})
describe('planBranchName (per-invocation planning worktrees)', () => {
it('no bag → first free sequential plans/plan-issue-<n>', () => {
expect(planBranchName({})).toBe('plans/plan-issue-1')
expect(planBranchName({ bag: '' })).toBe('plans/plan-issue-1')
expect(planBranchName({ bag: ' ' })).toBe('plans/plan-issue-1')
})
it('no bag → skips taken sequential names (parallel sessions)', () => {
const taken = new Set(['plans/plan-issue-1', 'plans/plan-issue-2'])
expect(planBranchName({ taken })).toBe('plans/plan-issue-3')
})
it('named bag → plans/plan-issue-<slug> when free', () => {
expect(planBranchName({ bag: 'Agenda eleitoral' })).toBe('plans/plan-issue-agenda-eleitoral')
})
it('named bag whose name is taken → suffixed -2, -3, …', () => {
const taken = new Set(['plans/plan-issue-agenda'])
expect(planBranchName({ bag: 'agenda', taken })).toBe('plans/plan-issue-agenda-2')
taken.add('plans/plan-issue-agenda-2')
expect(planBranchName({ bag: 'agenda', taken })).toBe('plans/plan-issue-agenda-3')
})
it('each invocation returns a different branch for the same bag (live name)', () => {
const first = planBranchName({ bag: 'municipios' })
const second = planBranchName({ bag: 'municipios', taken: new Set([first]) })
expect(second).toBe(`${first}-2`)
})
it('never collides with a `next` branch (uppercase-led <Code>-<slug>)', () => {
const taken = new Set(['C15-fullcalendar-em-campanha-agenda'])
for (const bag of ['agenda', 'municipios', 'C15']) {
const branch = planBranchName({ bag, taken })
expect(branch).toMatch(/^plans\/plan-issue/)
expect(branch).not.toMatch(/^[A-Z][A-Za-z0-9]*-/)
}
})
it('a numeric bag shares the sequential namespace (uniform)', () => {
expect(planBranchName({ bag: '3' })).toBe('plans/plan-issue-3')
})
})
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' })
@ -130,17 +175,14 @@ describe('worktreeEnvironment (per-worktree ports and databases)', () => {
})
it('plan worktree derives a stable hashed slot and never collides with `next`', () => {
expect(PLAN_WORKTREE_BRANCH).toBe('plans/plan-issue')
expect(PLAN_WORKTREE_BRANCH).not.toMatch(/^[A-Z][A-Za-z0-9]*-/)
const env = worktreeEnvironment({ branch: PLAN_WORKTREE_BRANCH, code: null })
expect(env.slot).toBe(hashSlotOf(PLAN_WORKTREE_BRANCH))
const branch = planBranchName({ bag: 'agenda' })
expect(branch).toBe(`${PLAN_BRANCH_PREFIX}-agenda`)
expect(PLAN_BRANCH_PREFIX).not.toMatch(/^[A-Z][A-Za-z0-9]*-/)
const env = worktreeEnvironment({ branch, code: null })
expect(env.slot).toBe(hashSlotOf(branch))
expect(numericSlotOfCode('')).toBeNull()
expect(env).toEqual(worktreeEnvironment({ branch: PLAN_WORKTREE_BRANCH, code: null }))
const bumped = worktreeEnvironment({
branch: PLAN_WORKTREE_BRANCH,
code: null,
takenSlots: new Set([env.slot]),
})
expect(env).toEqual(worktreeEnvironment({ branch, code: null }))
const bumped = worktreeEnvironment({ branch, code: null, takenSlots: new Set([env.slot]) })
expect(bumped.slot).toBe(env.slot + 1)
})