diff --git a/.agents/shell/worktree.sh b/.agents/shell/worktree.sh index 16172767..9578ac4f 100644 --- a/.agents/shell/worktree.sh +++ b/.agents/shell/worktree.sh @@ -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- (sufixo -2/-3 se o nome +# já existir), sem bag o próximo plans/plan-issue- +# sequencial; com --go, cd para dentro dele # worktree kill [--force] # # Read-only no GitHub — claim continua sendo `pnpm agent:claim`. diff --git a/.agents/skills/worktree-next-issue/SKILL.md b/.agents/skills/worktree-next-issue/SKILL.md index 06209215..9df5d96a 100644 --- a/.agents/skills/worktree-next-issue/SKILL.md +++ b/.agents/skills/worktree-next-issue/SKILL.md @@ -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-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag` → próximo sequencial `plans/plan-issue-` 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 `-` 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 ` e a função o aplica no shell que te chamou — node não consegue mudar o cwd do shell pai. diff --git a/.opencode/commands/worktree.md b/.opencode/commands/worktree.md index 64c2df91..65e2c97f 100644 --- a/.opencode/commands/worktree.md +++ b/.opencode/commands/worktree.md @@ -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 ` na última linha — aplique-o na sessão de shell atual (`cd `) 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-`, ou `plans/plan-issue-` 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` diff --git a/AGENTS.md b/AGENTS.md index 4cf6640c..a4bdb59f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` (dev) and `teqo_wt_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__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` (dev) and `teqo_wt_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-` when a `bag` slug is given (suffixed `-2`/`-3` if that name is taken) or the next free sequential `plans/plan-issue-` when not (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`); the lowercase `plans/…` prefix deliberately never matches `next`'s uppercase-led `-`, 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__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. diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 7a140e42..1985efa8 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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`/`teqo_wt_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-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-` 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`/`teqo_wt_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). diff --git a/scripts/lib/worktree.mjs b/scripts/lib/worktree.mjs index 5a205bd6..75bdc7d1 100644 --- a/scripts/lib/worktree.mjs +++ b/scripts/lib/worktree.mjs @@ -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 - * `-` 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 — `-` 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 ` — ` (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-`; if that name is already + * taken, `plans/plan-issue--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 `-` 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 + } +} diff --git a/scripts/worktree.mjs b/scripts/worktree.mjs index e1089c42..44deb70b 100644 --- a/scripts/worktree.mjs +++ b/scripts/worktree.mjs @@ -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-` (e `-2`, `-3`, … se o + * nome já estiver vivo); sem `bag`, o próximo + * sequencial `plans/plan-issue-` 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-`, suffixed `-2`, `-3`, … on + * collision) or from the next free sequential `plans/plan-issue-`, 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 -) e provisiona o') console.log(' ambiente isolado: porta de dev + bancos próprios (determinístico do branch);') console.log(' com --go imprime `cd ` 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 - de `next`') + console.log(' paralelas): com bag, branch plans/plan-issue- (sufixo -2/-3 se o nome já') + console.log(' existir); sem bag, o próximo plans/plan-issue- sequencial livre; o prefixo') + console.log(' minúsculo plans/… nunca colide com o branch - 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)) diff --git a/tests/unit/worktree.unit.spec.ts b/tests/unit/worktree.unit.spec.ts index f5bbbc20..bd035120 100644 --- a/tests/unit/worktree.unit.spec.ts +++ b/tests/unit/worktree.unit.spec.ts @@ -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-', () => { + 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- 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 -)', () => { + 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) })