From 0f508db4717fe07da5dee0da2225ece1562ab06d Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Wed, 23 Sep 2026 00:09:28 -0300 Subject: [PATCH 1/8] chore(worktrees): fail-fast Docker gate, branch-synced env files, generated artifacts, main-checkout guard hook Port of Zealot-Monorepo#2411 adapted to this repo's Postgres/MinIO slots and bun run scripts. create.sh: Docker/Postgres pre-flight (--skip-db opt-out), main-checkout resolution via git-common-dir, slot ownership by marker or DB-name reference under a per-slot mkdir lock, WORKTREE_SLOT written first, env files synced against the branch's .env.*.example with slot rewrites in apps/api/.env.local too, orphan DBs dropped only when no worktree claims the slot, workspace resolution check, generate:routes + db:generate + generate:sdk, db:push:dev, green ready vs yellow WITH GAPS summary. destroy.sh: shared docker resolution, DROP DATABASE WITH (FORCE), Redis flush on whichever container serves 6379. lib.sh: shared helpers; list.sh uses them. .claude/hooks/bashMainCheckoutGuard.ts (+ tests, .claude/settings.json): PreToolUse on Bash and SessionStart; denies mutating commands from the main checkout on branch main once the session has worked in a worktree. Fixes the eight open review findings on #2411: slot ownership and lock, array-built psql, explicit cd/-C honoured only for real checkouts, global options before the subcommand, no bare-whitespace separator, try/finally HEAD restore, worktree add/remove in the guarded set with docs matching the regex. Docs: worktrees skill, AI/ENTRYPOINT.md, docs/claude/SCRIPTS.md, docs/claude/DOCKER.md. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/bashMainCheckoutGuard.ts | 155 +++++ .../hooks/tests/bashMainCheckoutGuard.test.ts | 244 ++++++++ .claude/settings.json | 29 + .claude/skills/worktrees/SKILL.md | 76 ++- AI/ENTRYPOINT.md | 2 +- docs/claude/DOCKER.md | 3 +- docs/claude/SCRIPTS.md | 28 +- scripts/worktree/create.sh | 569 ++++++++---------- scripts/worktree/destroy.sh | 148 ++--- scripts/worktree/lib.sh | 152 +++++ scripts/worktree/list.sh | 19 +- 11 files changed, 976 insertions(+), 449 deletions(-) create mode 100644 .claude/hooks/bashMainCheckoutGuard.ts create mode 100644 .claude/hooks/tests/bashMainCheckoutGuard.test.ts create mode 100644 .claude/settings.json create mode 100755 scripts/worktree/lib.sh diff --git a/.claude/hooks/bashMainCheckoutGuard.ts b/.claude/hooks/bashMainCheckoutGuard.ts new file mode 100644 index 000000000..8b248753a --- /dev/null +++ b/.claude/hooks/bashMainCheckoutGuard.ts @@ -0,0 +1,155 @@ +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; + +type HookInput = { + hook_event_name?: string; + session_id?: string; + tool_name?: string; + cwd?: string; + tool_input?: { command?: string }; +}; + +const SEPARATOR = String.raw`(?:^|[;&|(]\s*)`; +const GLOBAL_OPTIONS = String.raw`(?:\s+-{1,2}[\w-]+(?:[= ]\S+)?)*`; +const RUNNER = String.raw`(?:(?:bunx|bun\s+run${GLOBAL_OPTIONS}|bun\s+x|npx|sudo|time|env(?:\s+\w+=\S*)*)\s+)*`; +const PATH_TOKEN = String.raw`(?:"([^"]+)"|'([^']+)'|([^\s;&|)]+))`; + +export const MUTATING = new RegExp( + `${SEPARATOR}${RUNNER}(?:` + + String.raw`bun${GLOBAL_OPTIONS}\s+(?:add|remove|update)\b` + + `|` + + String.raw`git${GLOBAL_OPTIONS}\s+(?:commit|push|merge|rebase|cherry-pick|worktree\s+(?:add|remove|move|prune))\b` + + `|` + + String.raw`prisma\s+migrate\s+dev\b` + + `|` + + String.raw`db:migrate\b` + + `)`, +); + +const LEADING_CD = new RegExp(String.raw`^\s*\(?\s*(?:cd|pushd)\s+${PATH_TOKEN}`); +const GIT_C = new RegExp(String.raw`\bgit(?:\s+-{1,2}[\w-]+(?:[= ]\S+)?)*\s+-C\s+${PATH_TOKEN}`, 'g'); + +const pathFromMatch = (match: RegExpMatchArray) => match[1] ?? match[2] ?? match[3]; + +const explicitTargets = (command: string): string[] => { + const targets: string[] = []; + const leading = command.match(LEADING_CD); + if (leading) targets.push(pathFromMatch(leading)); + for (const match of command.matchAll(GIT_C)) targets.push(pathFromMatch(match)); + return targets; +}; + +type Checkout = { root: string; isMain: boolean }; + +const linkedGitdir = (dotGitFile: string, root: string): string | undefined => { + const gitdir = readFileSync(dotGitFile, 'utf8') + .match(/^gitdir:\s*(.+)$/m)?.[1] + ?.trim(); + return gitdir ? resolve(root, gitdir) : undefined; +}; + +const findCheckout = (from: string): Checkout | undefined => { + let dir = resolve(from); + while (true) { + const dotGit = join(dir, '.git'); + if (existsSync(dotGit)) { + if (statSync(dotGit).isDirectory()) return { root: dir, isMain: true }; + const gitdir = linkedGitdir(dotGit, dir); + return gitdir && existsSync(gitdir) ? { root: dir, isMain: false } : undefined; + } + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +}; + +const namesCheckoutDeliberately = (raw: string, cwd: string): boolean => { + if (/[$`]/.test(raw)) return false; + const expanded = raw === '~' || raw.startsWith('~/') ? join(homedir(), raw.slice(1)) : raw; + const path = isAbsolute(expanded) ? expanded : resolve(cwd, expanded); + if (!existsSync(path) || !statSync(path).isDirectory()) return false; + const checkout = findCheckout(path); + if (!checkout) return false; + return checkout.isMain ? isAbsolute(expanded) : true; +}; + +const unresolvedTargets = (command: string, cwd: string): string[] => + explicitTargets(command).filter((target) => !namesCheckoutDeliberately(target, cwd)); + +const currentBranch = (root: string): string | undefined => { + const head = readFileSync(join(root, '.git/HEAD'), 'utf8').trim(); + return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : undefined; +}; + +export type SessionState = { + lastWorktree: (sessionId: string) => string | undefined; + rememberWorktree: (sessionId: string, worktreeRoot: string) => void; +}; + +export const fileSessionState = (dir = join(tmpdir(), 'template-worktree-guard')): SessionState => { + const file = (sessionId: string) => join(dir, sessionId.replace(/[^\w.-]/g, '_')); + return { + lastWorktree: (sessionId) => + existsSync(file(sessionId)) ? readFileSync(file(sessionId), 'utf8').trim() : undefined, + rememberWorktree: (sessionId, worktreeRoot) => { + mkdirSync(dir, { recursive: true }); + writeFileSync(file(sessionId), `${worktreeRoot}\n`); + }, + }; +}; + +export const guard = (input: HookInput, state: SessionState): string | undefined => { + if (!input.cwd || !input.session_id) return undefined; + const checkout = findCheckout(input.cwd); + if (!checkout) return undefined; + if (!checkout.isMain) { + state.rememberWorktree(input.session_id, checkout.root); + return undefined; + } + + const command = input.tool_input?.command; + if (input.tool_name !== 'Bash' || !command) return undefined; + if (!MUTATING.test(command)) return undefined; + + const targets = explicitTargets(command); + const unresolved = unresolvedTargets(command, input.cwd); + if (targets.length > 0 && unresolved.length === 0) return undefined; + + const worktree = state.lastWorktree(input.session_id); + if (!worktree || findCheckout(worktree)?.isMain !== false) return undefined; + if (currentBranch(checkout.root) !== 'main') return undefined; + return [ + `This session has been working in the worktree ${worktree}, but the Bash cwd is now the MAIN checkout (${checkout.root}, branch main) — the session cwd resets to main between calls.`, + ...unresolved.map( + (target) => + `The command names ${target}, which does not exist, is not a git checkout, or is only a relative path into main — so it would run in main anyway.`, + ), + 'Dependency changes, commits, pushes, merges, worktree registry changes, and new migrations would land on main. Name the directory:', + ` cd ${worktree} && ${command}`, + `If main really is the target, say so the same way: cd ${checkout.root} && ${command}`, + ].join('\n'); +}; + +const main = () => { + const input = JSON.parse(readFileSync(0, 'utf8')) as HookInput; + const reason = guard(input, fileSessionState(process.env.WORKTREE_GUARD_STATE_DIR)); + if (!reason) return; + process.stdout.write( + `${JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: reason, + }, + })}\n`, + ); +}; + +if (import.meta.main) { + try { + main(); + } catch (error) { + if (process.env.WORKTREE_GUARD_DEBUG) console.error(error); + } +} diff --git a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts new file mode 100644 index 000000000..a728d2306 --- /dev/null +++ b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts @@ -0,0 +1,244 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileSessionState, guard, MUTATING, type SessionState } from '../bashMainCheckoutGuard'; + +const HOOKS_DIR = resolve(import.meta.dir, '..'); + +let sandbox: string; +let mainRoot: string; +let worktreeRoot: string; +let plainDir: string; +let state: SessionState; + +const SESSION = 'session-a'; + +const bash = (command: string, cwd: string, sessionId = SESSION) => ({ + hook_event_name: 'PreToolUse', + session_id: sessionId, + tool_name: 'Bash', + cwd, + tool_input: { command }, +}); + +const visitWorktree = (sessionId = SESSION) => guard(bash('ls', worktreeRoot, sessionId), state); + +const onMain = (head: string, run: () => void) => { + writeFileSync(join(mainRoot, '.git/HEAD'), `ref: refs/heads/${head}\n`); + try { + run(); + } finally { + writeFileSync(join(mainRoot, '.git/HEAD'), 'ref: refs/heads/main\n'); + } +}; + +beforeAll(() => { + sandbox = mkdtempSync(join(tmpdir(), 'main-checkout-guard-')); + + mainRoot = join(sandbox, 'repo'); + mkdirSync(join(mainRoot, '.git/worktrees/FEAT-1234'), { recursive: true }); + writeFileSync(join(mainRoot, '.git/HEAD'), 'ref: refs/heads/main\n'); + mkdirSync(join(mainRoot, 'apps/api'), { recursive: true }); + + worktreeRoot = join(mainRoot, '.worktrees/FEAT-1234'); + mkdirSync(join(worktreeRoot, 'apps/api'), { recursive: true }); + writeFileSync(join(worktreeRoot, '.git'), `gitdir: ${mainRoot}/.git/worktrees/FEAT-1234\n`); + + plainDir = join(sandbox, 'not-a-checkout'); + mkdirSync(plainDir, { recursive: true }); +}); + +beforeEach(() => { + state = fileSessionState(mkdtempSync(join(sandbox, 'state-'))); +}); + +afterAll(() => { + rmSync(sandbox, { recursive: true, force: true }); +}); + +describe('MUTATING', () => { + test('the guarded set, with global options before the subcommand and through command runners', () => { + for (const command of [ + 'bun add left-pad', + 'bun --cwd . add left-pad', + 'bun remove left-pad', + 'bun update', + 'git commit -m x', + 'git -C . commit -m x', + 'git --no-pager push', + 'git -c core.editor=true rebase origin/main', + 'git --git-dir=.git cherry-pick abc', + 'git merge feature', + 'git worktree add ../x', + 'git worktree remove x', + 'git worktree prune', + 'bunx prisma migrate dev', + 'bun run db:migrate', + 'bun run --cwd packages/db db:migrate', + 'bun run prisma migrate dev --name add_thing', + 'git add -A && git push origin HEAD', + 'bun install; git commit -m x', + 'true || git push', + '(git push)', + ]) { + expect(MUTATING.test(command), command).toBe(true); + } + }); + + test('read-only, routine, and mention-only commands are outside it', () => { + for (const command of [ + 'git status', + 'git pull --ff-only origin main', + 'git -C . log --oneline', + 'git worktree list', + 'bun install', + 'bun run worktree:create main FEAT-9', + 'bun run worktree:destroy FEAT-1234', + 'bun run test', + 'bun run db:push:dev', + 'gh pr comment 1 --body "remember to git push"', + 'echo "then run: bun add left-pad"', + 'cat < { + test('a session that has only ever been in the main checkout is never guarded', () => { + expect(guard(bash('bun add left-pad', mainRoot), state)).toBeUndefined(); + expect(guard(bash('git commit -m "docs"', mainRoot), state)).toBeUndefined(); + expect(guard(bash('git push origin main', join(mainRoot, 'apps/api')), state)).toBeUndefined(); + }); + + test('after the session has been in a worktree, a bare mutation from main is denied and names that worktree', () => { + visitWorktree(); + const reason = guard(bash('bun add @inixiative/json-rules@2.22.0', mainRoot), state); + expect(reason).toContain('MAIN checkout'); + expect(reason).toContain(`cd ${worktreeRoot} && bun add @inixiative/json-rules@2.22.0`); + expect(reason).toContain(`cd ${mainRoot} && bun add @inixiative/json-rules@2.22.0`); + }); + + test('commits, pushes, merges, worktree registry changes, and new migrations are the guarded set', () => { + visitWorktree(); + expect(guard(bash('git commit -m "wip"', mainRoot), state)).toBeDefined(); + expect(guard(bash('git add -A && git push origin HEAD', mainRoot), state)).toBeDefined(); + expect(guard(bash('git rebase origin/main', mainRoot), state)).toBeDefined(); + expect(guard(bash('git worktree add ../x feature', mainRoot), state)).toBeDefined(); + expect(guard(bash('bun run db:migrate --name add_thing', mainRoot), state)).toBeDefined(); + expect(guard(bash('bunx prisma migrate dev', join(mainRoot, 'apps/api')), state)).toBeDefined(); + }); + + test('global options before the subcommand do not bypass the guard', () => { + visitWorktree(); + expect(guard(bash('git -C . commit -m x', mainRoot), state)).toBeDefined(); + expect(guard(bash('git --no-pager push', mainRoot), state)).toBeDefined(); + expect(guard(bash('bun --cwd . add left-pad', mainRoot), state)).toBeDefined(); + }); + + test('an explicit cd into an existing checkout is a deliberate choice, even when it targets main', () => { + visitWorktree(); + expect(guard(bash(`cd ${mainRoot} && bun add left-pad`, mainRoot), state)).toBeUndefined(); + expect(guard(bash(`cd ${worktreeRoot} && git commit -m x`, mainRoot), state)).toBeUndefined(); + expect(guard(bash(`cd "${worktreeRoot}" && git commit -m x`, mainRoot), state)).toBeUndefined(); + expect(guard(bash(`cd '${worktreeRoot}/apps/api'; git push`, mainRoot), state)).toBeUndefined(); + expect(guard(bash('cd .worktrees/FEAT-1234 && git push', mainRoot), state)).toBeUndefined(); + }); + + test('a cd whose target does not exist or is not a checkout does not bypass the guard', () => { + visitWorktree(); + const missing = guard(bash('cd /does/not/exist; git push', mainRoot), state); + expect(missing).toContain('/does/not/exist'); + expect(missing).toContain('would run in main anyway'); + expect(guard(bash('cd /bad || git commit -m x', mainRoot), state)).toBeDefined(); + expect(guard(bash(`cd ${plainDir} && git push`, mainRoot), state)).toBeDefined(); + expect(guard(bash('cd "$SOMEWHERE" && git push', mainRoot), state)).toBeDefined(); + }); + + test('a relative path into main is not a deliberate choice of main', () => { + visitWorktree(); + expect(guard(bash('cd . && git push', mainRoot), state)).toBeDefined(); + expect(guard(bash('cd apps/api && bun add left-pad', mainRoot), state)).toBeDefined(); + expect(guard(bash('git -C . commit -m x', mainRoot), state)).toBeDefined(); + }); + + test('git -C is honoured like an explicit cd, under the same existence and checkout check', () => { + visitWorktree(); + expect(guard(bash(`git -C ${worktreeRoot} commit -m x`, mainRoot), state)).toBeUndefined(); + expect(guard(bash('git -C .worktrees/FEAT-1234 commit -m x', mainRoot), state)).toBeUndefined(); + expect(guard(bash(`git -C "${mainRoot}" push`, mainRoot), state)).toBeUndefined(); + expect(guard(bash('git -C /does/not/exist commit -m x', mainRoot), state)).toBeDefined(); + expect(guard(bash(`git -C ${plainDir} push`, mainRoot), state)).toBeDefined(); + expect(guard(bash(`cd ${worktreeRoot} && git -C /does/not/exist push`, mainRoot), state)).toBeDefined(); + }); + + test('read-only, routine, and mention-only commands on main pass even after worktree work', () => { + visitWorktree(); + expect(guard(bash('git status', mainRoot), state)).toBeUndefined(); + expect(guard(bash('git pull --ff-only origin main', mainRoot), state)).toBeUndefined(); + expect(guard(bash('bun install', mainRoot), state)).toBeUndefined(); + expect(guard(bash('bun run worktree:create main FEAT-9', mainRoot), state)).toBeUndefined(); + expect(guard(bash('bun run worktree:destroy FEAT-1234', mainRoot), state)).toBeUndefined(); + expect(guard(bash('bun run test', mainRoot), state)).toBeUndefined(); + expect(guard(bash('gh pr comment 1 --body "remember to git push"', mainRoot), state)).toBeUndefined(); + expect(guard(bash('cat < { + expect(guard(bash('git commit -m x', worktreeRoot), state)).toBeUndefined(); + expect(guard(bash('bun add left-pad', join(worktreeRoot, 'apps/api')), state)).toBeUndefined(); + expect(state.lastWorktree(SESSION)).toBe(worktreeRoot); + }); + + test('sessions are independent: another session in main is unaffected by this one visiting a worktree', () => { + visitWorktree('session-a'); + expect(guard(bash('git commit -m x', mainRoot, 'session-b'), state)).toBeUndefined(); + expect(guard(bash('git commit -m x', mainRoot, 'session-a'), state)).toBeDefined(); + }); + + test('a main checkout on a feature branch, or a remembered worktree that no longer exists, is not guarded', () => { + visitWorktree(); + onMain('hotfix', () => { + expect(guard(bash('git commit -m x', mainRoot), state)).toBeUndefined(); + }); + expect(guard(bash('git commit -m x', mainRoot), state)).toBeDefined(); + + state.rememberWorktree(SESSION, join(mainRoot, '.worktrees/destroyed')); + expect(guard(bash('git commit -m x', mainRoot), state)).toBeUndefined(); + }); + + test('a SessionStart event inside a worktree records it before any Bash call', () => { + expect( + guard({ hook_event_name: 'SessionStart', session_id: SESSION, cwd: join(worktreeRoot, 'apps/api') }, state), + ).toBeUndefined(); + expect(guard(bash('git push', mainRoot), state)).toBeDefined(); + }); + + test('the hook binary keeps per-session state, emits a PreToolUse deny, and stays silent otherwise', () => { + const stateDir = mkdtempSync(join(sandbox, 'bin-state-')); + const run = (input: Record) => { + const result = Bun.spawnSync(['bun', join(HOOKS_DIR, 'bashMainCheckoutGuard.ts')], { + stdin: Buffer.from(JSON.stringify(input)), + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, WORKTREE_GUARD_DEBUG: '1', WORKTREE_GUARD_STATE_DIR: stateDir }, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr.toString()).toBe(''); + return result.stdout.toString().trim(); + }; + expect(run(bash('git push', mainRoot))).toBe(''); + expect(run(bash('ls', worktreeRoot))).toBe(''); + const denied = JSON.parse(run(bash('git push', mainRoot))) as { + hookSpecificOutput: { hookEventName: string; permissionDecision: string; permissionDecisionReason: string }; + }; + expect(denied.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + expect(denied.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(denied.hookSpecificOutput.permissionDecisionReason).toContain(worktreeRoot); + expect(run(bash('git status', mainRoot))).toBe(''); + expect(run({ session_id: SESSION, tool_name: 'Read', cwd: mainRoot, tool_input: { file_path: 'x' } })).toBe(''); + }); +}); diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..ef38158c7 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,29 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bun \"$CLAUDE_PROJECT_DIR/.claude/hooks/bashMainCheckoutGuard.ts\"", + "timeout": 10, + "statusMessage": "worktrees: did this session's cwd drift back to the main checkout?" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bun \"$CLAUDE_PROJECT_DIR/.claude/hooks/bashMainCheckoutGuard.ts\"", + "timeout": 10, + "statusMessage": "worktrees: recording which checkout this session started in" + } + ] + } + ] + } +} diff --git a/.claude/skills/worktrees/SKILL.md b/.claude/skills/worktrees/SKILL.md index f0a8dfcc4..611a1622a 100644 --- a/.claude/skills/worktrees/SKILL.md +++ b/.claude/skills/worktrees/SKILL.md @@ -5,22 +5,24 @@ description: Use when starting feature work that needs isolation, when dispatchi # Worktrees -This repo has its own worktree tooling on top of git. Plain `git worktree add` gives you an isolated checkout. The repo scripts give you an isolated **runtime** — separate Postgres databases (local + test), separate MinIO buckets, a separate Redis logical DB, and separate ports for Web / Admin / Superadmin / API. That's the difference between "I can edit files" and "I can run a full dev stack alongside the main one." +This repo has its own worktree tooling on top of git. Plain `git worktree add` gives you an isolated checkout. The repo scripts give you an isolated **runtime** — separate Postgres databases (local + test), separate MinIO buckets, a separate Redis logical DB, and separate ports for Web / Admin / Superadmin / API — plus the gitignored artifacts a fresh checkout does not carry. That's the difference between "I can edit files" and "I can run a full dev stack and the test suites alongside the main one." -Always use the scripts unless you have a specific reason not to. +Always use the scripts. The raw `git worktree add` / `git worktree remove` are in the `bashMainCheckoutGuard` hook's guarded set (see below) — there is no case for them. ## The three commands | Command | What it does | |---|---| -| `bun run worktree:create ` | Allocates a slot (1–9), creates the git worktree at `.worktrees//`, generates a slot-specific `.env.local` and `.env.test`, creates the Postgres `_wt_` and `_test_wt_` databases and MinIO buckets, runs `bun install` + pushes the Prisma schema, and warns about any worktrees whose branches are already merged into the main branch. | -| `bun run worktree:create ` | Same, but attaches an existing local/`origin` branch to a new worktree (useful for PR review without disturbing the main checkout). | +| `bun run worktree:create [--skip-db]` | Checks Docker/Postgres up front, allocates a slot (1–9) under a per-slot lock, creates the git worktree at `.worktrees//`, generates slot-specific `.env.local` / `.env.test` (root and `apps/*`) synced against the branch's `.env.*.example`, creates the Postgres `_wt_` and `_test_wt_` databases and MinIO buckets, runs `bun install` and verifies workspace resolution, builds the gitignored artifacts (route trees, Prisma client + `prismaMap.gen.ts` + zod, OpenAPI spec + SDK + MSW handlers), pushes the Prisma schema to both DBs, and warns about any worktrees whose branches are already merged into the main branch. `--skip-db` skips the Docker gate, databases, buckets, and schema push. | +| `bun run worktree:create [--skip-db]` | Same, but attaches an existing local/`origin` branch to a new worktree (useful for PR review without disturbing the main checkout). | | `bun run worktree:list` | Shows every worktree with its slot, branch, and assigned ports. | | `bun run worktree:destroy ` | Drops the Postgres databases, removes the MinIO buckets, `FLUSHDB` on Redis DB ``, kills processes on the worktree's four ports, removes the git worktree, frees the slot. | `` for destroy is the worktree directory name (the branch with slashes replaced by dashes). Get it from `bun run worktree:list`. -## When to use the scripts vs. plain `git worktree add` +The scripts resolve the **main checkout** from git, so they do the right thing when run from inside a worktree too: the new worktree lands under the main checkout's `.worktrees/`, never nested. + +## When to use which mode | Scenario | Use | |---|---| @@ -29,13 +31,13 @@ Always use the scripts unless you have a specific reason not to. | Bug fix that needs to be tested locally | `bun run worktree:create` | | Reviewing a PR locally without disturbing main | `bun run worktree:create ` (attach mode) | | Anything that touches Prisma schema, migrations, or factories | `bun run worktree:create` — needs the isolated test DB | -| Editing docs/markdown/skills only, no code execution | `git worktree add` is fine — slot allocation is overhead | +| Editing docs/markdown/skills only, no code execution | `bun run worktree:create --skip-db` — no databases, Docker not required | -When in doubt, use the script. Slots are cheap (9 available); the cost of a half-isolated environment (polluting the main DB, port conflicts, stale packages) is high. +When in doubt, use the full mode. Slots are cheap (9 available); the cost of a half-isolated environment (polluting the main DB, port conflicts, stale packages) is high. ## Why not just `git worktree add`? -A bare `git worktree add` lands you in a fresh checkout with **no `node_modules`**. Because `.worktrees/` lives inside the main repo and workspaces are hoisted to the root, bun then resolves *up* to the main checkout's `node_modules` — where the `@template/*` symlinks point at **main's** packages, not your worktree's. You'd silently run main's code (e.g. a generated Prisma client that predates a migration on your branch). `bun run worktree:create` runs `bun install` in the worktree and verifies `node_modules/@template/db` actually linked, so the worktree owns its own resolution. +A bare `git worktree add` lands you in a fresh checkout with **no `node_modules`** and **none of the generated artifacts**. Because `.worktrees/` lives inside the main repo, bun can resolve *up* to the main checkout's `node_modules` — where the workspace links point at **main's** packages, not your worktree's. You'd silently run main's code (e.g. a generated Prisma client that predates a schema change on your branch), and every test file that imports the Prisma client or the SDK dies at boot with a misleading error. `bun run worktree:create` runs `bun install --force` in the worktree, verifies `apps/api/node_modules/@template/db` resolves *inside* the worktree, and builds the artifacts. ## Slot anatomy @@ -52,24 +54,25 @@ Each slot (1–9) gets a unique set of resources. If `WORKTREE_SLOT=3` (and `PRO | Redis DB index | `3` | | MinIO buckets | `template-{system,user}{,-test}-wt-3` | -The slot is recorded as `WORKTREE_SLOT=` at the top of the worktree's `.env.local`. Don't edit it. +The slot is recorded as `WORKTREE_SLOT=` at the top of the worktree's `.env.local` and `.env.test`. Don't edit it. A slot counts as taken when any registered worktree carries that marker **or** references the slot's database names in any of its env files, so a worktree that lost its marker still owns its slot. The main checkout uses slot 0 / default ports (`3000`, `3001`, `3002`, `8000`). It shows in `bun run worktree:list` as `(main)`. ## What `bun run worktree:create` runs (so you know what to expect) -1. Scans existing worktrees for allocated slots; surfaces worktrees whose branches are already merged into the main branch (clean-up candidates). -2. Claims the lowest available slot 1–9. Exits if all 9 are taken. -3. Runs `git worktree add` to create the checkout off the base branch (creates the new branch if needed; attaches an existing one in attach mode). -4. Copies the root `.env.local` into the worktree and rewrites the slot-specific ports, URLs, DB name, buckets, and Redis index. -5. Same for `.env.test`. -6. Creates the Postgres databases and MinIO buckets for the slot. -7. Runs `bun install --ignore-scripts` in the worktree and verifies `node_modules/@template/db` linked (hard-fails otherwise — see "Why not just `git worktree add`?"). -8. Pushes the Prisma schema to both the local and test DBs (`bun run db:push:dev`). -9. Smoke-tests connectivity to the slot's local DB. -10. Prints a summary with the worktree path, ports, DBs, and buckets. +1. Pre-flight, before anything is created: root `.env.local` must exist; unless `--skip-db`, resolves `docker` (PATH, `~/.docker/bin`, `Docker.app`), requires the daemon, starts a stopped `_postgres` container, and waits for Postgres to answer. Any of these missing is a hard error with the fix in the message — never a "ready" with silently skipped databases. +2. Scans registered worktrees for allocated slots (marker or DB-name reference); surfaces worktrees whose branches are already merged into the main branch (clean-up candidates). +3. Claims the lowest free slot 1–9 under a `mkdir` lock in `.worktrees/.locks/` (held until the script exits; a lock left by a dead process is reclaimed). Exits if all 9 are taken. +4. Creates the git worktree off the base branch (creates the new branch if needed; attaches an existing one in attach mode). +5. Writes `WORKTREE_SLOT=` as the first line of the worktree's `.env.local` / `.env.test` and copies root's values and `apps/*/.env.*` in, then runs `scripts/setup/sync-env.sh` **against the branch's own `.env.*.example` files**, so a key the branch's `apps/api/src/config/env.ts` requires arrives even when root's env files lag. Then rewrites the slot ports / URLs / DB names / Redis index / buckets — in the root files and in `apps/api/.env.local`, whose `PORT` and `API_URL` override root's under `with-env`. +6. Creates both Postgres databases. A database already on the slot is dropped only when no registered worktree references that slot (a leftover from a worktree removed without `worktree:destroy`); if one does, the script refuses and names it. +7. Provisions the slot's MinIO buckets. +8. `bun install --force --ignore-scripts` in the worktree and verifies `apps/api/node_modules/@template/db` resolves *inside* the worktree. +9. Builds the gitignored artifacts: `bun run generate:routes` (`apps/*/app/routeTree.gen.ts`), `bun run db:generate` (Prisma client, `prismaMap.gen.ts`, zod), `bun run generate:sdk` (OpenAPI spec, `packages/sdk/src/generated`, `packages/ui/src/test/mocks/handlers.gen.ts`). +10. Pushes the Prisma schema to both DBs (`bun run db:push:dev`) and pings the local one. +11. Prints a summary. Green **ready** means every step passed. Yellow **created WITH GAPS** lists exactly which artifact or push is missing and the command to rerun in the worktree; do not run services or tests until the list is empty. -If Docker isn't running, the DB/bucket steps print a warning but the script proceeds — bring Docker up (`bun run start:db`) and re-run the relevant steps inside the worktree later. +`--skip-db` (any position) skips steps 1's Docker gate, 6, 7, and 10 — for docs/skills work that never runs services or tests. Artifacts are still built. ## Running services in the worktree @@ -80,22 +83,28 @@ bun run local # full stack (API + web + admin + superadmin) on this slot's bun run local:db # just bring up the Docker services ``` -The slot-specific `.env.local` already points everything at the right ports/DBs/buckets — no additional config needed. Tests (`bun run test`, `bun run test:fe`) use the slot's `.env.test`. +The slot-specific env files already point everything at the right ports/DBs/buckets — no additional config needed. Tests (`bun run test`, `bun run test:fe`) use the slot's `.env.test`. -## Subagents and worktrees +## Subagents, sessions, and the main checkout When you dispatch a subagent to do code work in a worktree, **the subagent does not automatically inherit your working directory** — it defaults to the main repo path. Two consequences: 1. Always tell the subagent the **absolute worktree path** in the prompt: `Work entirely in /.worktrees/. Do NOT touch the main repo path.` 2. After the subagent finishes, verify it didn't edit files in the main repo — check `git status` in both places. +The same applies to your own session: the Bash tool's cwd can reset to the main checkout between calls (after a session restart, or after a command that `cd`'d outside the repo). A bare `bun add`, `git commit`, or `git push` then lands on main. The `bashMainCheckoutGuard` hook (`.claude/hooks/bashMainCheckoutGuard.ts`, registered in `.claude/settings.json` as PreToolUse on Bash plus SessionStart) remembers per session which worktree the cwd has been inside, and denies exactly that drift: a mutating command from the main checkout, on branch `main`, after this session has been in a worktree that still exists. The guarded set is `bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, and `bun run db:migrate` — global options before the subcommand (`git -C commit`, `git --no-pager push`, `bun --cwd . add`) and runners (`bunx`, `bun run …`) are seen through. Only a command actually being run counts: text that merely mentions one (`gh pr comment --body "then git push"`, heredoc lines) is not matched. + +The deny message names the worktree and prints the `cd && …` form. A leading `cd &&` or a `git -C ` is honoured as the deliberate choice when `` exists and is inside a git checkout — a linked worktree by any path, the main checkout only by an absolute path (`cd /abs/main && …` is a choice; `cd . && …` and `git -C . …` are not). A `cd` to a path that does not exist or is not a checkout (`cd /typo; git push`, `cd /bad || git commit`) does not bypass the guard. A session that has only ever worked in the main checkout is never touched, so main stays an ordinary place to work. Background Bash calls inherit the session cwd like foreground ones. The hook only denies; it never rewrites a command, because PreToolUse hooks run in parallel and the user-level RTK hook already rewrites Bash commands via `updatedInput`. + ## When you're done with a worktree ```bash bun run worktree:destroy ``` -This reads `WORKTREE_SLOT` from the worktree's `.env.local`, drops both Postgres databases, removes the MinIO buckets, flushes Redis DB ``, kills any processes on the four slot ports, removes the git worktree (falls back to `rm -rf` + `git worktree prune` if needed), and frees the slot. +This reads `WORKTREE_SLOT` from the worktree's `.env.local`, resolves `docker` the same way create does, drops both Postgres databases (`WITH (FORCE)`, so open connections don't block it), removes the MinIO buckets, flushes Redis DB `` on whichever container serves `localhost:6379` (that is what the slot's `REDIS_URL` points at, whether it is `_redis` or another stack's Redis holding the port), kills any processes on the four slot ports, removes the git worktree (falls back to `rm -rf` + `git worktree prune` if needed), and frees the slot. + +If Docker is unreachable at destroy time it says so; the next `worktree:create` that reclaims the slot drops the leftover databases (after confirming no registered worktree still references them). **Always destroy worktrees you're done with.** If you don't, you'll run out of slots (max 9). `bun run worktree:list` shows what's allocated; `bun run worktree:create` warns about merged-but-not-destroyed worktrees on every run. If the branch is already merged into the main branch, the destroy is safe — your work is in main already. @@ -103,16 +112,20 @@ This reads `WORKTREE_SLOT` from the worktree's `.env.local`, drops both Postgres | File | Why read | |---|---| -| `scripts/worktree/create.sh` | Full script — slot allocation, env generation, DB/bucket setup, install, schema push | +| `scripts/worktree/create.sh` | Full script — Docker pre-flight, slot lock + allocation, env generation, DB/bucket setup, install + resolution check, generated artifacts, schema push, gap summary | | `scripts/worktree/destroy.sh` | Cleanup logic — DB drop, bucket removal, Redis flush, port kill, worktree remove | +| `scripts/worktree/lib.sh` | Shared helpers — `main_checkout_root`, `resolve_docker`, `slot_resources`, `worktree_claims_slot`, `ensure_env_var`, `run_step`, `require_artifact` | | `scripts/worktree/list.sh` | Output format and how the slot is read from `.env.local` | +| `.claude/hooks/bashMainCheckoutGuard.ts` | The PreToolUse / SessionStart hook described above; `.claude/hooks/tests/` has its tests (`__TEST_REDIRECT__=1 bun test ./.claude/hooks/tests/bashMainCheckoutGuard.test.ts`) | +| `scripts/setup/sync-env.sh` | What the env sync appends (keys the branch's examples have that the copied file lacks; never overwrites a value) | | `scripts/db/push-dev.sh` | What `db:push:dev` does (worktree-aware, refuses non-local DATABASE_URLs) | ## Common mistakes | Mistake | Fix | |---|---| -| Using `git worktree add` directly when you'll run services | Use `bun run worktree:create` — get the DB/bucket/port isolation and a real `bun install` for free | +| Using `git worktree add` directly | Use `bun run worktree:create` (`--skip-db` for docs-only) — the hook denies the raw command from a drifted main cwd anyway | +| Trusting a green "ready" from the older create | Current script: green means every artifact and push landed; yellow WITH GAPS lists what to rerun | | Editing files in the main repo when you meant to work in a worktree | `cd .worktrees/` first; verify with `git rev-parse --show-toplevel` | | Dispatching a subagent without the worktree path in the prompt | Always include `Work entirely in . Do NOT touch the main repo path.` | | Leaving merged worktrees around | Run `bun run worktree:destroy ` after merge; the script also warns you on every create | @@ -127,12 +140,13 @@ This reads `WORKTREE_SLOT` from the worktree's `.env.local`, drops both Postgres ## Checklist — starting feature work -1. `bun run worktree:create main ` (or off whichever base branch). -2. `cd .worktrees/`. -3. Verify the worktree: `git rev-parse --show-toplevel` should print the worktree path. -4. Run the services you need (`bun run local`, `bun run test`, etc.) — they use slot-specific ports/DBs. -5. Do the work; commit and push from inside the worktree. -6. When merged: `cd` back to the main repo, then `bun run worktree:destroy `. +1. `bun run worktree:create main ` (or off whichever base branch; `--skip-db` for docs-only). +2. Read the summary: green **ready**, or fix the listed gaps first. +3. `cd .worktrees/`. +4. Verify the worktree: `git rev-parse --show-toplevel` should print the worktree path. +5. Run the services you need (`bun run local`, `bun run test`, etc.) — they use slot-specific ports/DBs. +6. Do the work; commit and push from inside the worktree (`cd && …` when the cwd may have drifted). +7. When merged: `cd` back to the main repo, then `bun run worktree:destroy `. ## Checklist — dispatching a subagent diff --git a/AI/ENTRYPOINT.md b/AI/ENTRYPOINT.md index 3fae33f9f..eadbb3e88 100644 --- a/AI/ENTRYPOINT.md +++ b/AI/ENTRYPOINT.md @@ -47,7 +47,7 @@ concluding that something is missing — it is usually present and documented. - Do not run git commands unless the user explicitly asks. - **NEVER run `git stash` — for any reason, ever.** Not for "baseline verification", not for "let me test if this is from my change", not for "let me try and see". If you need to compare against HEAD or a clean state: use `git show HEAD:` to read a file at HEAD, `git diff` to see changes, or read the file content directly. Stash has destroyed in-flight work before. - **NEVER use `git -c =` to bypass any rule** — this includes (but is not limited to) bypassing the stash ban, signing requirements, hook execution (`--no-verify`), or any other guardrail. If a command requires bypassing config, stop and ask. -- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach), `bun run worktree:destroy `, `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (ports, `.env.local`/`.env.test`, per-slot databases, dependency install, prisma) — a hand-rolled worktree is a broken half-environment plus orphaned state. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. +- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy `, `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every artifact and push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`) denies a bare mutating command (`bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate`) from the main checkout once this session has worked in a worktree — name the directory with `cd && …`. See `.claude/skills/worktrees/SKILL.md`. - **If something is broken, just fix it.** Don't stash-to-bisect, don't "let me see if reverting my changes makes it go away" — read the code, find the cause, fix the cause. ## 0.1. Understand the Why First diff --git a/docs/claude/DOCKER.md b/docs/claude/DOCKER.md index 05e06edd7..8d6424d8c 100644 --- a/docs/claude/DOCKER.md +++ b/docs/claude/DOCKER.md @@ -8,6 +8,7 @@ - [Services](#services) - [docker-compose.yml](#docker-composeyml) - [Connection Strings](#connection-strings) + - [Storage buckets (slot 0 / main checkout)](#storage-buckets-slot-0--main-checkout) - [Commands](#commands) - [Manual Docker Commands](#manual-docker-commands) - [Configuration](#configuration) @@ -103,7 +104,7 @@ Bucket bootstrap mirrors the pg-init pattern: `scripts/db/minio-init.sh` lives b | `template-system-test` | Test isolation — system bucket | | `template-user-test` | Test isolation — user bucket | -Worktree slots add their own buckets (`template-system-wt-`, etc.) via `scripts/worktree/create.sh`, which `mc mb`s them against the shared minio container after the worktree is provisioned. +Worktree slots add their own buckets (`template-system-wt-`, etc.) via `scripts/worktree/create.sh`, which runs `scripts/db/minio-provision.sh` against the shared minio container right after the slot's databases are created; `scripts/worktree/destroy.sh` removes them. Both scripts resolve `docker` from PATH, `~/.docker/bin`, or `Docker.app`, and create refuses to proceed (rather than skipping databases) when the daemon or `${PROJECT_NAME}_postgres` is down — `--skip-db` is the explicit opt-out for docs-only worktrees. Storage is **ephemeral** (no persistent volume on MinIO). `docker compose down && up` wipes all uploads and the entrypoint re-creates empty buckets on next boot. By design — file persistence in dev is rarely useful, and the reset story matters more than carrying state across restarts. diff --git a/docs/claude/SCRIPTS.md b/docs/claude/SCRIPTS.md index f1f52d335..c9c8c49d3 100644 --- a/docs/claude/SCRIPTS.md +++ b/docs/claude/SCRIPTS.md @@ -15,6 +15,7 @@ - [Setup Scripts](#setup-scripts) - [Deployment Scripts](#deployment-scripts) - [Docker](#docker) +- [Worktrees](#worktrees) - [Writing New Scripts](#writing-new-scripts) - [AI Workspace](#ai-workspace) - [Workflow](#workflow) @@ -51,10 +52,15 @@ scripts/ │ ├── pg-init.sh # Postgres initialization │ ├── wait-postgres.sh # Wait for postgres ready │ └── wait-redis.sh # Wait for redis ready -└── deployment/ - ├── deploy.sh # Deploy to environment - ├── with-env.sh # Run command with env vars - └── wait-for-api.sh # Health check wait +├── deployment/ +│ ├── deploy.sh # Deploy to environment +│ ├── with-env.sh # Run command with env vars +│ └── wait-for-api.sh # Health check wait +└── worktree/ + ├── create.sh # Provision an isolated worktree on a slot (DBs, buckets, env, deps, artifacts) + ├── destroy.sh # Tear a worktree down and free its slot + ├── list.sh # Worktrees with slot, branch, ports + └── lib.sh # Shared helpers (sourced by the three above) init/ ├── index.tsx # bun run init entrypoint @@ -307,6 +313,20 @@ bun run reset:db # down + up + push + seed --- +## Worktrees + +```bash +bun run worktree:create # Fork a new branch (fails fast if Docker/Postgres is down) +bun run worktree:create # Attach an existing branch +bun run worktree:create --skip-db # Docs/skills only: no databases, buckets, or schema push; Docker not required +bun run worktree:list +bun run worktree:destroy +``` + +Each worktree owns a slot (1–9): ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific `.env.local` / `.env.test` synced against the branch's `.env.*.example`, its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. Full guide: `.claude/skills/worktrees/SKILL.md`. + +--- + ## Writing New Scripts **Shell scripts**: Place in `scripts//`, make executable (`chmod +x`). diff --git a/scripts/worktree/create.sh b/scripts/worktree/create.sh index ed9188c91..9af2c7477 100755 --- a/scripts/worktree/create.sh +++ b/scripts/worktree/create.sh @@ -1,64 +1,42 @@ #!/usr/bin/env bash -# create.sh — Create an isolated git worktree with its own ports + per-slot -# Postgres DBs (local + test) + Redis DB number + MinIO buckets. -# -# Usage: -# bun run worktree:create -# Fork from and check it out in a new worktree. -# bun run worktree:create -# Attach an existing branch (local or origin/) to a new worktree. -# Useful for PR review without disturbing the main checkout. -# -# A "slot" (1-9) is allocated to each worktree. The slot drives: -# - Ports: WEB 3${SLOT}00 ADMIN 3${SLOT}01 SUPERADMIN 3${SLOT}02 -# API 8${SLOT}00 -# - DBs: ${PROJECT_NAME}_wt_${SLOT} (local) -# ${PROJECT_NAME}_test_wt_${SLOT} (test) -# - Redis: logical DB number ${SLOT} -# - Buckets: ${PROJECT_NAME}-{system,user}{,-test}-wt-${SLOT} -# -# Slot 0 is reserved for the main checkout. - set -euo pipefail -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -RED='\033[0;31m' -NC='\033[0m' - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" - -# Resolve the docker binary — Docker Desktop isn't always symlinked onto PATH -# (a minimal `bun run` PATH in particular misses it), so fall back to the -# app-bundle path. DB/Redis/MinIO steps below route through "$DOCKER"; if it -# can't be resolved the existing `if "$DOCKER" ps` guards skip gracefully. -DOCKER="" -if command -v docker >/dev/null 2>&1; then - DOCKER="docker" -elif [ -x /Applications/Docker.app/Contents/Resources/bin/docker ]; then - DOCKER="/Applications/Docker.app/Contents/Resources/bin/docker" -fi - -# Portable in-place sed (BSD vs GNU) -sedi() { - if sed --version >/dev/null 2>&1; then sed -i "$@"; else sed -i '' "$@"; fi -} - -ARG1="${1:-}" -ARG2="${2:-}" +source "$SCRIPT_DIR/lib.sh" +ROOT_DIR="$(main_checkout_root "$SCRIPT_DIR")" +PROJECT_NAME="$(project_name "$ROOT_DIR")" +PG_CONTAINER="${PROJECT_NAME}_postgres" -if [ -z "$ARG1" ]; then +usage() { echo -e "${RED}Usage:${NC}" - echo " $0 Fork from " - echo " $0 Attach an existing branch to a new worktree" + echo " $0 [--skip-db] Fork from " + echo " $0 [--skip-db] Attach an existing branch to a new worktree" + echo + echo " --skip-db Edit-only worktree: no slot databases, buckets, or schema push; Docker not required" echo echo "Examples:" echo " bun run worktree:create main feature/my-feature" echo " bun run worktree:create feature/their-pr" + echo " bun run worktree:create main docs-only --skip-db" exit 1 -fi +} + +SKIP_DB=0 +ARG1="" +ARG2="" +for arg in "$@"; do + case "$arg" in + --skip-db) SKIP_DB=1 ;; + -*) echo -e "${RED}Unknown flag: $arg${NC}"; usage ;; + *) + if [ -z "$ARG1" ]; then ARG1="$arg" + elif [ -z "$ARG2" ]; then ARG2="$arg" + else usage + fi + ;; + esac +done +[ -n "$ARG1" ] || usage if [ -n "$ARG2" ]; then BASE_BRANCH="$ARG1" @@ -70,324 +48,297 @@ else ATTACH_ONLY=1 fi -# Resolve PROJECT_NAME (drives container + DB naming) — falls back to repo dir. -PROJECT_NAME="" -if [ -f "$ROOT_DIR/.env" ]; then - PROJECT_NAME="$(grep -m1 '^PROJECT_NAME=' "$ROOT_DIR/.env" | cut -d= -f2 || true)" -fi -if [ -z "$PROJECT_NAME" ]; then - PROJECT_NAME="$(basename "$ROOT_DIR")" -fi - -PG_CONTAINER="${PROJECT_NAME}_postgres" -REDIS_CONTAINER="${PROJECT_NAME}_redis" - -# Derive worktree directory name (slashes → dashes) WT_NAME="$(echo "$NEW_BRANCH" | tr '/' '-')" WORKTREE_DIR="$ROOT_DIR/.worktrees/$WT_NAME" if [ -d "$WORKTREE_DIR" ]; then - echo -e "${RED}Error: Worktree '$WT_NAME' already exists at $WORKTREE_DIR${NC}" - exit 1 + die "Error: Worktree '$WT_NAME' already exists at $WORKTREE_DIR" fi -# Verify branch existence for the chosen mode +branch_exists() { + git -C "$ROOT_DIR" show-ref --verify --quiet "refs/heads/$1" 2>/dev/null || \ + git -C "$ROOT_DIR" show-ref --verify --quiet "refs/remotes/origin/$1" 2>/dev/null +} + if [ "$ATTACH_ONLY" -eq 1 ]; then - if ! git -C "$ROOT_DIR" show-ref --verify --quiet "refs/heads/$NEW_BRANCH" 2>/dev/null && \ - ! git -C "$ROOT_DIR" show-ref --verify --quiet "refs/remotes/origin/$NEW_BRANCH" 2>/dev/null; then + if ! branch_exists "$NEW_BRANCH"; then echo -e "${RED}Error: Branch '$NEW_BRANCH' not found (local or remote).${NC}" - echo -e "${RED}To create a new branch, pass a base too: $0 $NEW_BRANCH${NC}" - exit 1 + die "To create a new branch, pass a base too: $0 $NEW_BRANCH" fi +elif ! branch_exists "$BASE_BRANCH"; then + die "Error: Base branch '$BASE_BRANCH' not found (local or remote)" +fi + +MAIN_ENV="$ROOT_DIR/.env.local" +[ -f "$MAIN_ENV" ] || die "Error: $MAIN_ENV not found. Run 'bun run setup' (or 'bun run sync-env') first." + +DOCKER="" +DB_AVAILABLE=0 +if [ "$SKIP_DB" -eq 1 ]; then + warn "--skip-db: no slot databases, buckets, or schema push. This worktree is for editing and typecheck only." else - if ! git -C "$ROOT_DIR" show-ref --verify --quiet "refs/heads/$BASE_BRANCH" 2>/dev/null && \ - ! git -C "$ROOT_DIR" show-ref --verify --quiet "refs/remotes/origin/$BASE_BRANCH" 2>/dev/null; then - echo -e "${RED}Error: Base branch '$BASE_BRANCH' not found (local or remote)${NC}" - exit 1 - fi + DOCKER="$(resolve_docker)" || die "Error: docker not found on PATH, in ~/.docker/bin, or in /Applications/Docker.app. +Start Docker Desktop, or pass --skip-db for an edit-only worktree." + "$DOCKER" info >/dev/null 2>&1 || die "Error: the Docker daemon is not running ($DOCKER info failed). +Start Docker Desktop, or pass --skip-db for an edit-only worktree." + + case "$(container_state "$PG_CONTAINER")" in + true) ;; + false) + info "$PG_CONTAINER is stopped — starting it..." + "$DOCKER" start "$PG_CONTAINER" >/dev/null || die "Error: could not start the $PG_CONTAINER container." + ;; + *) + die "Error: no $PG_CONTAINER container. Run 'bun run local:db' once to create the stack, or pass --skip-db." + ;; + esac + + PSQL=("$DOCKER" exec "$PG_CONTAINER" psql -U postgres -v ON_ERROR_STOP=1 -qtA) + for _ in $(seq 1 30); do + if [ "$("${PSQL[@]}" -c 'SELECT 1;' 2>/dev/null || true)" = "1" ]; then + DB_AVAILABLE=1 + break + fi + sleep 1 + done + [ "$DB_AVAILABLE" -eq 1 ] || die "Error: $PG_CONTAINER is running but does not answer as postgres." + ok "Docker: $DOCKER — $PG_CONTAINER reachable" fi -# --------------------------------------------------------------------------- -# 1. Scan existing worktrees for allocated slots + warn about merged branches -# --------------------------------------------------------------------------- -echo -e "${BLUE}Scanning existing worktrees...${NC}" +info "Scanning worktrees for allocated slots..." + +MAIN_BRANCH="$(git -C "$ROOT_DIR" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' || echo main)" +MERGED_BRANCHES="$(git -C "$ROOT_DIR" branch --merged "$MAIN_BRANCH" 2>/dev/null | sed 's/^[* ]*//' || true)" USED_SLOTS=" " MERGED_WARNINGS="" MERGED_COUNT=0 -MAIN_BRANCH="$(git -C "$ROOT_DIR" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' || echo main)" -MERGED_BRANCHES="$(git -C "$ROOT_DIR" branch --merged "$MAIN_BRANCH" 2>/dev/null | sed 's/^[* ]*//' || true)" - while IFS= read -r wt_path; do - wt_path="$(echo "$wt_path" | xargs)" - [ -z "$wt_path" ] && continue - env_file="$wt_path/.env.local" - if [ -f "$env_file" ]; then - slot="$(grep -m1 '^WORKTREE_SLOT=' "$env_file" 2>/dev/null | cut -d= -f2 || true)" - if [ -n "$slot" ]; then - USED_SLOTS="${USED_SLOTS}${slot} " - wt_dir_name="$(basename "$wt_path")" - wt_branch="$(git -C "$ROOT_DIR" worktree list | grep "$wt_path" | sed -n 's/.*\[\(.*\)\].*/\1/p')" - if [ -n "$wt_branch" ] && echo "$MERGED_BRANCHES" | grep -qx "$wt_branch"; then - MERGED_WARNINGS="${MERGED_WARNINGS} Slot ${slot}: ${wt_dir_name} (${wt_branch}) — bun run worktree:destroy ${wt_dir_name}\n" - MERGED_COUNT=$((MERGED_COUNT + 1)) - fi + [ -n "$wt_path" ] && [ -d "$wt_path" ] || continue + wt_branch="$(git -C "$wt_path" branch --show-current 2>/dev/null || true)" + for i in $(seq 1 9); do + worktree_claims_slot "$wt_path" "$i" || continue + USED_SLOTS="${USED_SLOTS}${i} " + if [ -n "$wt_branch" ] && echo "$MERGED_BRANCHES" | grep -qx "$wt_branch"; then + MERGED_WARNINGS="${MERGED_WARNINGS} Slot ${i}: $(basename "$wt_path") (${wt_branch}) — bun run worktree:destroy $(basename "$wt_path")\n" + MERGED_COUNT=$((MERGED_COUNT + 1)) fi - fi -done < <(git -C "$ROOT_DIR" worktree list --porcelain | grep '^worktree ' | sed 's/^worktree //') + done +done < <(linked_worktrees) if [ "$MERGED_COUNT" -gt 0 ]; then - echo -e "${YELLOW}These worktrees are already merged into ${MAIN_BRANCH}:${NC}" - echo -e "${YELLOW}${MERGED_WARNINGS}${NC}" + warn "These worktrees have branches already merged into ${MAIN_BRANCH}:" + warn "$MERGED_WARNINGS" fi -# --------------------------------------------------------------------------- -# 2. Claim the lowest available slot (1-9; slot 0 reserved for main) -# --------------------------------------------------------------------------- +LOCK_ROOT="$ROOT_DIR/.worktrees/.locks" +mkdir -p "$LOCK_ROOT" SLOT="" +SLOT_LOCK="" +release_slot_lock() { [ -n "$SLOT_LOCK" ] && rm -rf "$SLOT_LOCK"; } +trap release_slot_lock EXIT + +claim_slot_lock() { + local lock="$LOCK_ROOT/slot-$1" + if [ -d "$lock" ] && [ -f "$lock/pid" ] && ! kill -0 "$(cat "$lock/pid")" 2>/dev/null; then + rm -rf "$lock" + fi + mkdir "$lock" 2>/dev/null || return 1 + echo $$ > "$lock/pid" + SLOT_LOCK="$lock" +} + for i in $(seq 1 9); do - if ! echo "$USED_SLOTS" | grep -q " ${i} "; then SLOT="$i"; break; fi + echo "$USED_SLOTS" | grep -q " ${i} " && continue + if claim_slot_lock "$i"; then + SLOT="$i" + break + fi done if [ -z "$SLOT" ]; then - echo -e "${RED}Error: All 9 worktree slots are in use.${NC}" - [ "$MERGED_COUNT" -gt 0 ] && echo -e "${RED}Clean up merged worktrees listed above.${NC}" - exit 1 + echo -e "${RED}Error: All 9 worktree slots are in use (or locked by a create in progress).${NC}" + [ "$MERGED_COUNT" -gt 0 ] && echo -e "${RED}Clean up merged worktrees listed above to free slots.${NC}" + die "Run 'bun run worktree:list' to see them." fi -echo -e "${GREEN}Claimed slot $SLOT${NC}" - -WEB_PORT="3${SLOT}00" -ADMIN_PORT="3${SLOT}01" -SUPERADMIN_PORT="3${SLOT}02" -API_PORT="8${SLOT}00" -DB_LOCAL="${PROJECT_NAME}_wt_${SLOT}" -DB_TEST="${PROJECT_NAME}_test_wt_${SLOT}" -REDIS_DB="$SLOT" -STORAGE_BUCKET_SYSTEM="${PROJECT_NAME}-system-wt-${SLOT}" -STORAGE_BUCKET_USER="${PROJECT_NAME}-user-wt-${SLOT}" -STORAGE_BUCKET_SYSTEM_TEST="${PROJECT_NAME}-system-test-wt-${SLOT}" -STORAGE_BUCKET_USER_TEST="${PROJECT_NAME}-user-test-wt-${SLOT}" - -# --------------------------------------------------------------------------- -# 3. Create git worktree -# --------------------------------------------------------------------------- +ok "Claimed slot $SLOT" +slot_resources "$SLOT" + if [ "$ATTACH_ONLY" -eq 1 ]; then - echo -e "${BLUE}Attaching existing branch '$NEW_BRANCH' to a new worktree...${NC}" + info "Attaching existing branch '$NEW_BRANCH' to a new worktree..." else - echo -e "${BLUE}Creating git worktree on '$NEW_BRANCH' from '$BASE_BRANCH'...${NC}" + info "Creating git worktree on '$NEW_BRANCH' from '$BASE_BRANCH'..." fi -mkdir -p "$ROOT_DIR/.worktrees" if git -C "$ROOT_DIR" show-ref --verify --quiet "refs/heads/$NEW_BRANCH" 2>/dev/null; then git -C "$ROOT_DIR" worktree add "$WORKTREE_DIR" "$NEW_BRANCH" elif [ "$ATTACH_ONLY" -eq 1 ]; then - # Origin-only branch — create local tracking branch git -C "$ROOT_DIR" worktree add -b "$NEW_BRANCH" "$WORKTREE_DIR" "origin/$NEW_BRANCH" else git -C "$ROOT_DIR" worktree add -b "$NEW_BRANCH" "$WORKTREE_DIR" "$BASE_BRANCH" fi -# --------------------------------------------------------------------------- -# 4. Generate worktree .env.local from main's .env.local with slot rewrites -# --------------------------------------------------------------------------- - -# Rewrite the database-name segment of DATABASE_URL in a file to $2, preserving -# scheme://user:pw@host:port and any ?query. No-op when the file has no -# DATABASE_URL. In this monorepo the URL lives in different files per env (root -# .env.test vs apps/api/.env.local), so we rewrite wherever it appears rather than -# appending it to one fixed file. -rewrite_database_url() { - local file="$1" db="$2" - [ -f "$file" ] && grep -q '^DATABASE_URL=' "$file" || return 0 - sedi -E "s|^(DATABASE_URL=[a-z]+://[^@]+@[^/]+/)[^?[:space:]]*|\1${db}|" "$file" -} - -# Point a localhost REDIS_URL/REDIS_QUEUE_URL at this slot's logical DB ($2). -# Leaves the 'noop' test sentinel and any non-localhost host untouched. -rewrite_redis_db() { - local file="$1" n="$2" - [ -f "$file" ] || return 0 - sedi -E "s#^(REDIS(_QUEUE)?_URL=redis://([^@/]*@)?(localhost|127\.0\.0\.1):[0-9]+)(/[0-9]+)?[[:space:]]*\$#\1/${n}#" "$file" -} - -# Copy each apps//.env. present in main into the worktree (these files -# are gitignored, so a fresh checkout has none) and slot-localize DATABASE_URL + -# REDIS. Without this the worktree has no apps/api/.env.test — so the API's env -# validation (encryption keys) fails — and apps/api/.env.local keeps main's DB. -localize_app_envs() { - local kind="$1" db="$2" - for src in "$ROOT_DIR"/apps/*/.env."$kind"; do - [ -f "$src" ] || continue - local dst="$WORKTREE_DIR/${src#"$ROOT_DIR"/}" - mkdir -p "$(dirname "$dst")" - cp "$src" "$dst" - rewrite_database_url "$dst" "$db" - rewrite_redis_db "$dst" "$REDIS_DB" - done -} +info "Generating env files (root values, the branch's keys, slot $SLOT ports/DBs/buckets)..." -MAIN_ENV="$ROOT_DIR/.env.local" -if [ ! -f "$MAIN_ENV" ]; then - echo -e "${RED}Error: $MAIN_ENV not found. Run 'bun run setup' (or sync-env) first.${NC}" - git -C "$ROOT_DIR" worktree remove "$WORKTREE_DIR" --force 2>/dev/null || true - exit 1 -fi - -cp "$MAIN_ENV" "$WORKTREE_DIR/.env.local" WT_ENV="$WORKTREE_DIR/.env.local" -sedi "1s/^/WORKTREE_SLOT=${SLOT}\n/" "$WT_ENV" - -# Set a key in $WT_ENV: replace if present, append if absent. Bare `sedi` silently -# no-ops on missing keys — which once left worktrees pointing FE URLs at the main -# checkout's ports. Use this for every slot-critical key. -ensure_env_var() { - local key="$1" - local value="$2" - if grep -q "^${key}=" "$WT_ENV"; then - sedi "s|^${key}=.*|${key}=${value}|" "$WT_ENV" - else - echo "${key}=${value}" >> "$WT_ENV" - fi -} +WT_TEST_ENV="$WORKTREE_DIR/.env.test" -# Ports + URLs -ensure_env_var "PORT" "$API_PORT" -ensure_env_var "API_URL" "http://localhost:${API_PORT}" -ensure_env_var "WEB_URL" "http://localhost:${WEB_PORT}" -ensure_env_var "ADMIN_URL" "http://localhost:${ADMIN_PORT}" -ensure_env_var "SUPERADMIN_URL" "http://localhost:${SUPERADMIN_PORT}" +{ echo "WORKTREE_SLOT=$SLOT"; cat "$MAIN_ENV"; } > "$WT_ENV" +if [ -f "$ROOT_DIR/.env.test" ]; then + { echo "WORKTREE_SLOT=$SLOT"; cat "$ROOT_DIR/.env.test"; } > "$WT_TEST_ENV" +fi +for src in "$ROOT_DIR"/apps/*/.env.local "$ROOT_DIR"/apps/*/.env.test; do + [ -f "$src" ] || continue + dst="$WORKTREE_DIR/${src#"$ROOT_DIR"/}" + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" +done -# Database — rewrite DATABASE_URL in place wherever it lives. The local DB URL is -# in apps/api/.env.local (handled by localize_app_envs below); this covers any -# root-level DATABASE_URL (root-env stacks) and is a no-op when there is none. +SYNC_ENV="$WORKTREE_DIR/scripts/setup/sync-env.sh" +[ -f "$SYNC_ENV" ] || SYNC_ENV="$ROOT_DIR/scripts/setup/sync-env.sh" +if ! SYNC_OUT="$(cd "$WORKTREE_DIR" && bash "$SYNC_ENV" 2>&1)"; then + echo "$SYNC_OUT" + die "Error: syncing env files against the branch's .env.*.example files failed." +fi +[ -n "$SYNC_OUT" ] && echo "$SYNC_OUT" | sed 's/^/ /' + +ensure_env_var "$WT_ENV" WORKTREE_SLOT "$SLOT" +ensure_env_var "$WT_ENV" PORT "$API_PORT" +ensure_env_var "$WT_ENV" API_URL "http://localhost:${API_PORT}" +ensure_env_var "$WT_ENV" WEB_URL "http://localhost:${WEB_PORT}" +ensure_env_var "$WT_ENV" ADMIN_URL "http://localhost:${ADMIN_PORT}" +ensure_env_var "$WT_ENV" SUPERADMIN_URL "http://localhost:${SUPERADMIN_PORT}" +replace_env_var_if_present "$WT_ENV" BETTER_AUTH_BASE_URL "http://localhost:${API_PORT}" +ensure_env_var "$WT_ENV" STORAGE_BUCKET_SYSTEM "$STORAGE_BUCKET_SYSTEM" +ensure_env_var "$WT_ENV" STORAGE_BUCKET_USER "$STORAGE_BUCKET_USER" rewrite_database_url "$WT_ENV" "$DB_LOCAL" - -# Storage buckets — slot-isolated -ensure_env_var "STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_SYSTEM" -ensure_env_var "STORAGE_BUCKET_USER" "$STORAGE_BUCKET_USER" - -# Redis — point any localhost REDIS_URL at this slot's logical DB (root, if present). rewrite_redis_db "$WT_ENV" "$REDIS_DB" -# App-level env: with-env composes root then apps//.env.local, and the local -# DATABASE_URL lives in apps/api/.env.local — so the slot DB must be written there, -# and these gitignored files must be created in the worktree. -localize_app_envs local "$DB_LOCAL" - -# --------------------------------------------------------------------------- -# 5. Generate worktree .env.test -# --------------------------------------------------------------------------- -MAIN_TEST_ENV="$ROOT_DIR/.env.test" -if [ -f "$MAIN_TEST_ENV" ]; then - cp "$MAIN_TEST_ENV" "$WORKTREE_DIR/.env.test" - WT_TEST_ENV="$WORKTREE_DIR/.env.test" - sedi "1s/^/WORKTREE_SLOT=${SLOT}\n/" "$WT_TEST_ENV" - - # Local helper bound to the test env file (same semantics as the one above) - ensure_test_env_var() { - local key="$1" - local value="$2" - if grep -q "^${key}=" "$WT_TEST_ENV"; then - sedi "s|^${key}=.*|${key}=${value}|" "$WT_TEST_ENV" - else - echo "${key}=${value}" >> "$WT_TEST_ENV" - fi - } - +if [ -f "$WT_TEST_ENV" ]; then + ensure_env_var "$WT_TEST_ENV" WORKTREE_SLOT "$SLOT" + ensure_env_var "$WT_TEST_ENV" STORAGE_BUCKET_SYSTEM "$STORAGE_BUCKET_SYSTEM_TEST" + ensure_env_var "$WT_TEST_ENV" STORAGE_BUCKET_USER "$STORAGE_BUCKET_USER_TEST" + replace_env_var_if_present "$WT_TEST_ENV" API_URL "http://localhost:${API_PORT}" + replace_env_var_if_present "$WT_TEST_ENV" WEB_URL "http://localhost:${WEB_PORT}" + replace_env_var_if_present "$WT_TEST_ENV" ADMIN_URL "http://localhost:${ADMIN_PORT}" + replace_env_var_if_present "$WT_TEST_ENV" SUPERADMIN_URL "http://localhost:${SUPERADMIN_PORT}" rewrite_database_url "$WT_TEST_ENV" "$DB_TEST" - ensure_test_env_var "STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_SYSTEM_TEST" - ensure_test_env_var "STORAGE_BUCKET_USER" "$STORAGE_BUCKET_USER_TEST" + rewrite_redis_db "$WT_TEST_ENV" "$REDIS_DB" else - echo -e "${YELLOW}Warning: $MAIN_TEST_ENV not found, skipping root .env.test generation${NC}" + warn "Warning: neither root .env.test nor a .env.test.example on this branch — .env.test not generated." fi -# App-level test env: apps/api/.env.test carries the encryption keys the API's env -# validation requires (and, for some stacks, the test DATABASE_URL). Generate these -# regardless of whether the root .env.test existed. -localize_app_envs test "$DB_TEST" +for f in "$WORKTREE_DIR"/apps/*/.env.local; do + replace_env_var_if_present "$f" PORT "$API_PORT" + replace_env_var_if_present "$f" API_URL "http://localhost:${API_PORT}" + replace_env_var_if_present "$f" WEB_URL "http://localhost:${WEB_PORT}" + replace_env_var_if_present "$f" ADMIN_URL "http://localhost:${ADMIN_PORT}" + replace_env_var_if_present "$f" SUPERADMIN_URL "http://localhost:${SUPERADMIN_PORT}" + rewrite_database_url "$f" "$DB_LOCAL" + rewrite_redis_db "$f" "$REDIS_DB" +done +for f in "$WORKTREE_DIR"/apps/*/.env.test; do + replace_env_var_if_present "$f" API_URL "http://localhost:${API_PORT}" + rewrite_database_url "$f" "$DB_TEST" + rewrite_redis_db "$f" "$REDIS_DB" +done -# --------------------------------------------------------------------------- -# 6. Create Postgres databases -# --------------------------------------------------------------------------- -echo -e "${BLUE}Creating Postgres databases on $PG_CONTAINER...${NC}" -if "$DOCKER" ps --format '{{.Names}}' | grep -qx "$PG_CONTAINER"; then +if [ "$DB_AVAILABLE" -eq 1 ]; then + info "Creating Postgres databases on $PG_CONTAINER..." for DB in "$DB_LOCAL" "$DB_TEST"; do - "$DOCKER" exec "$PG_CONTAINER" psql -U postgres -tc "SELECT 1 FROM pg_database WHERE datname='${DB}'" \ - | grep -q 1 \ - || "$DOCKER" exec "$PG_CONTAINER" psql -U postgres -c "CREATE DATABASE \"${DB}\"" + if [ "$("${PSQL[@]}" -c "SELECT 1 FROM pg_database WHERE datname='${DB}';")" = "1" ]; then + if CLAIMANT="$(slot_claimant "$SLOT" "$WORKTREE_DIR")"; then + die "Error: database $DB already exists and the worktree at $CLAIMANT references slot $SLOT — refusing to drop it. +Run 'bun run worktree:destroy $(basename "$CLAIMANT")' if that worktree is finished, then re-run this command." + fi + warn "Slot $SLOT still has database $DB from a worktree that was not removed with worktree:destroy — dropping it." + "${PSQL[@]}" -c "DROP DATABASE IF EXISTS \"${DB}\" WITH (FORCE);" || die "Error: dropping orphaned $DB failed." + fi + "${PSQL[@]}" -c "CREATE DATABASE \"${DB}\";" || die "Error: creating $DB failed." done - echo -e "${GREEN}Created: $DB_LOCAL, $DB_TEST${NC}" -else - echo -e "${YELLOW}Warning: $PG_CONTAINER not running — skipping DB creation. Start with 'bun run start:db' then re-run this script's DB step manually.${NC}" -fi - -# --------------------------------------------------------------------------- -# 6b. Create MinIO buckets for this slot -# --------------------------------------------------------------------------- -echo -e "${BLUE}Provisioning MinIO buckets for slot ${SLOT}...${NC}" -"$ROOT_DIR/scripts/db/minio-provision.sh" \ - "$STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_USER" \ - "$STORAGE_BUCKET_SYSTEM_TEST" "$STORAGE_BUCKET_USER_TEST" - -# --------------------------------------------------------------------------- -# 7. Install dependencies in the worktree -# --------------------------------------------------------------------------- -# A git worktree is a fresh working dir with NO node_modules. Because .worktrees/ -# lives inside the main repo, bun could otherwise resolve UP to the main checkout's -# node_modules and silently run main's code (e.g. a generated Prisma client that -# predates a migration on this branch). ALWAYS force a real install into the -# worktree — never rely on links to (or hoisting from) the main checkout. -# --ignore-scripts mirrors 'bun run setup' (prepare/husky already ran in main). -echo -e "${BLUE}Installing dependencies (bun install --force --ignore-scripts)...${NC}" -if ! (cd "$WORKTREE_DIR" && bun install --force --ignore-scripts 2>&1 | tail -5; exit "${PIPESTATUS[0]}"); then - echo -e "${RED}Error: bun install --force failed in $WORKTREE_DIR. Fix the issue, then run 'bun install --force' there.${NC}" - exit 1 + ok "Created databases: $DB_LOCAL, $DB_TEST" + + info "Provisioning MinIO buckets for slot ${SLOT}..." + MINIO_PROVISION="$WORKTREE_DIR/scripts/db/minio-provision.sh" + [ -f "$MINIO_PROVISION" ] || MINIO_PROVISION="$ROOT_DIR/scripts/db/minio-provision.sh" + PATH="$(dirname "$DOCKER"):$PATH" bash "$MINIO_PROVISION" \ + "$STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_USER" \ + "$STORAGE_BUCKET_SYSTEM_TEST" "$STORAGE_BUCKET_USER_TEST" fi -# --------------------------------------------------------------------------- -# 8. Push schema to both DBs -# --------------------------------------------------------------------------- -echo -e "${BLUE}Pushing Prisma schema to local + test DBs...${NC}" -(cd "$WORKTREE_DIR" && bun run db:push:dev 2>&1) || \ - echo -e "${YELLOW}Warning: schema push failed; run 'bun run db:push:dev' inside the worktree once issues are fixed.${NC}" - -# --------------------------------------------------------------------------- -# 9. Smoke-test DB connectivity (confirms the slot's local DB is reachable) -# --------------------------------------------------------------------------- -echo -e "${BLUE}Verifying DB connectivity...${NC}" -if "$DOCKER" ps --format '{{.Names}}' | grep -qx "$PG_CONTAINER"; then - DB_PING="$("$DOCKER" exec "$PG_CONTAINER" psql -U postgres -d "$DB_LOCAL" -tAc "SELECT 1;" 2>/dev/null || true)" - if [ "$DB_PING" = "1" ]; then - echo -e "${GREEN}DB reachable: $DB_LOCAL${NC}" +info "Installing dependencies (bun install --force --ignore-scripts)..." +(cd "$WORKTREE_DIR" && bun install --force --ignore-scripts 2>&1 | tail -5; exit "${PIPESTATUS[0]}") \ + || die "Error: bun install failed in $WORKTREE_DIR. Fix the issue, then run 'bun install --force' there." + +WT_REAL="$(cd "$WORKTREE_DIR" && pwd -P)" +DB_PKG_REAL="$(cd "$WORKTREE_DIR/apps/api/node_modules/@template/db" 2>/dev/null && pwd -P || true)" +case "$DB_PKG_REAL" in + "$WT_REAL"/*) ok "Workspace packages resolve inside the worktree ($DB_PKG_REAL)" ;; + *) die "Error: apps/api/node_modules/@template/db resolves to '${DB_PKG_REAL:-}', not inside $WORKTREE_DIR. +Run 'bun install --force' in $WORKTREE_DIR and check 'readlink apps/api/node_modules/@template/db'." ;; +esac + +GAPS="" + +run_step "Generating route trees" bun run generate:routes || true +run_step "Generating Prisma client, prismaMap, and zod schemas" bun run db:generate || true +run_step "Generating OpenAPI spec, SDK, and MSW handlers" bun run generate:sdk || true + +require_artifact apps/web/app/routeTree.gen.ts "bun run generate:routes" +require_artifact apps/admin/app/routeTree.gen.ts "bun run generate:routes" +require_artifact apps/superadmin/app/routeTree.gen.ts "bun run generate:routes" +require_artifact packages/db/src/generated/client "bun run db:generate" +require_artifact packages/db/src/generated/prismaMap.gen.ts "bun run db:generate" +require_artifact packages/db/src/generated/zod "bun run db:generate" +require_artifact packages/sdk/src/generated/sdk.gen.ts "bun run generate:sdk" +require_artifact packages/ui/src/test/mocks/handlers.gen.ts "bun run generate:sdk" + +if [ "$DB_AVAILABLE" -eq 1 ]; then + run_step "Pushing Prisma schema to $DB_LOCAL and $DB_TEST" bun run db:push:dev \ + || GAPS="${GAPS} - schema not pushed to $DB_LOCAL / $DB_TEST — rerun in the worktree: bun run db:push:dev\n" + + if [ "$("${PSQL[@]}" -d "$DB_LOCAL" -c "SELECT 1;" 2>/dev/null || true)" = "1" ]; then + ok "DB reachable: $DB_LOCAL" else - echo -e "${YELLOW}Warning: could not verify DB $DB_LOCAL is reachable.${NC}" + GAPS="${GAPS} - $DB_LOCAL does not answer — check DATABASE_URL in $WORKTREE_DIR/apps/api/.env.local\n" fi -else - echo -e "${YELLOW}Warning: $PG_CONTAINER not running — skipping DB connectivity check.${NC}" fi -# --------------------------------------------------------------------------- -# 10. Summary -# --------------------------------------------------------------------------- -cat < -# -# is the worktree directory name under .worktrees/ (slashes in the -# branch get converted to dashes — see worktree:list). - set -euo pipefail -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -RED='\033[0;31m' -NC='\033[0m' - SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" - -# Resolve the docker binary — Docker Desktop isn't always symlinked onto PATH -# (a minimal `bun run` PATH in particular misses it), so fall back to the -# app-bundle path. DB/Redis/MinIO steps below route through "$DOCKER"; if it -# can't be resolved the existing `if "$DOCKER" ps` guards skip gracefully. -DOCKER="" -if command -v docker >/dev/null 2>&1; then - DOCKER="docker" -elif [ -x /Applications/Docker.app/Contents/Resources/bin/docker ]; then - DOCKER="/Applications/Docker.app/Contents/Resources/bin/docker" -fi +source "$SCRIPT_DIR/lib.sh" +ROOT_DIR="$(main_checkout_root "$SCRIPT_DIR")" +PROJECT_NAME="$(project_name "$ROOT_DIR")" +PG_CONTAINER="${PROJECT_NAME}_postgres" +MINIO_CONTAINER="${PROJECT_NAME}_minio" NAME="${1:-}" if [ -z "$NAME" ]; then echo -e "${RED}Usage: $0 ${NC}" - echo " Run 'bun run worktree:list' to see available worktrees." + echo " Worktree directory name under .worktrees/ (branch with slashes → dashes)" + echo + echo "Run 'bun run worktree:list' to see available worktrees." exit 1 fi if echo "$NAME" | grep -qE '(\.\.|/)'; then - echo -e "${RED}Error: Name cannot contain '..' or path separators${NC}" - exit 1 + die "Error: Name cannot contain '..' or path separators" fi WORKTREE_DIR="$ROOT_DIR/.worktrees/$NAME" if [ ! -d "$WORKTREE_DIR" ]; then - echo -e "${RED}Error: Worktree '$NAME' not found at $WORKTREE_DIR${NC}" - exit 1 + die "Error: Worktree '$NAME' not found at $WORKTREE_DIR" fi -PROJECT_NAME="" -if [ -f "$ROOT_DIR/.env" ]; then - PROJECT_NAME="$(grep -m1 '^PROJECT_NAME=' "$ROOT_DIR/.env" | cut -d= -f2 || true)" -fi -[ -z "$PROJECT_NAME" ] && PROJECT_NAME="$(basename "$ROOT_DIR")" - -PG_CONTAINER="${PROJECT_NAME}_postgres" -REDIS_CONTAINER="${PROJECT_NAME}_redis" - -# --- 1. Read slot from worktree's .env.local -------------------------------- -WT_ENV="$WORKTREE_DIR/.env.local" -SLOT="" -if [ -f "$WT_ENV" ]; then - SLOT="$(grep -m1 '^WORKTREE_SLOT=' "$WT_ENV" | cut -d= -f2 || true)" -fi +SLOT="$(env_value "$WORKTREE_DIR/.env.local" WORKTREE_SLOT)" +[ -n "$SLOT" ] || SLOT="$(env_value "$WORKTREE_DIR/.env.test" WORKTREE_SLOT)" if [ -z "$SLOT" ]; then - echo -e "${YELLOW}Warning: No WORKTREE_SLOT found — skipping DB / Redis / port cleanup.${NC}" + warn "Warning: No WORKTREE_SLOT found in $WORKTREE_DIR/.env.local" + warn "Proceeding with worktree removal only (no DB / bucket / Redis / port cleanup)" else - DB_LOCAL="${PROJECT_NAME}_wt_${SLOT}" - DB_TEST="${PROJECT_NAME}_test_wt_${SLOT}" + slot_resources "$SLOT" - # --- 2. Drop Postgres databases ------------------------------------------- - if "$DOCKER" ps --format '{{.Names}}' | grep -qx "$PG_CONTAINER"; then - echo -e "${BLUE}Dropping databases: $DB_LOCAL, $DB_TEST...${NC}" - "$DOCKER" exec "$PG_CONTAINER" psql -U postgres -c "DROP DATABASE IF EXISTS \"${DB_LOCAL}\";" >/dev/null - "$DOCKER" exec "$PG_CONTAINER" psql -U postgres -c "DROP DATABASE IF EXISTS \"${DB_TEST}\";" >/dev/null - echo -e "${GREEN}Databases dropped${NC}" + DOCKER="$(resolve_docker || true)" + if [ -z "$DOCKER" ] || ! "$DOCKER" info >/dev/null 2>&1; then + warn "Warning: Docker is not reachable — slot $SLOT databases, buckets, and Redis DB are NOT cleaned up." + warn "Start Docker and drop $DB_LOCAL / $DB_TEST by hand, or worktree:create will drop them when it reclaims slot $SLOT." else - echo -e "${YELLOW}Warning: $PG_CONTAINER not running — skipping DB drop.${NC}" - fi - - # --- 2b. Remove MinIO buckets --------------------------------------------- - MINIO_CONTAINER="${PROJECT_NAME}_minio" - if "$DOCKER" ps --format '{{.Names}}' | grep -qx "$MINIO_CONTAINER"; then - STORAGE_BUCKET_SYSTEM="${PROJECT_NAME}-system-wt-${SLOT}" - STORAGE_BUCKET_USER="${PROJECT_NAME}-user-wt-${SLOT}" - STORAGE_BUCKET_SYSTEM_TEST="${PROJECT_NAME}-system-test-wt-${SLOT}" - STORAGE_BUCKET_USER_TEST="${PROJECT_NAME}-user-test-wt-${SLOT}" + if [ "$(container_state "$PG_CONTAINER")" = "true" ]; then + PSQL=("$DOCKER" exec "$PG_CONTAINER" psql -U postgres -v ON_ERROR_STOP=1 -q) + info "Dropping databases: $DB_LOCAL, $DB_TEST..." + if "${PSQL[@]}" -c "DROP DATABASE IF EXISTS \"${DB_LOCAL}\" WITH (FORCE);" \ + && "${PSQL[@]}" -c "DROP DATABASE IF EXISTS \"${DB_TEST}\" WITH (FORCE);"; then + ok "Databases dropped" + else + warn "Warning: Could not drop databases. worktree:create drops them when it reclaims slot $SLOT." + fi + else + warn "Warning: $PG_CONTAINER not running — databases NOT dropped. worktree:create drops them when it reclaims slot $SLOT." + fi - echo -e "${BLUE}Removing MinIO buckets for slot ${SLOT}...${NC}" - for BUCKET in "$STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_USER" \ - "$STORAGE_BUCKET_SYSTEM_TEST" "$STORAGE_BUCKET_USER_TEST"; do - "$DOCKER" run --rm --network "${PROJECT_NAME}_default" \ - -e MC_HOST_local="http://minioadmin:minioadmin@minio:9000" \ - minio/mc:latest rb --force "local/${BUCKET}" >/dev/null 2>&1 || true - done - echo -e "${GREEN}MinIO buckets removed${NC}" - fi + if [ "$(container_state "$MINIO_CONTAINER")" = "true" ]; then + info "Removing MinIO buckets for slot ${SLOT}..." + for BUCKET in "$STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_USER" \ + "$STORAGE_BUCKET_SYSTEM_TEST" "$STORAGE_BUCKET_USER_TEST"; do + "$DOCKER" run --rm --network "${PROJECT_NAME}_default" \ + -e MC_HOST_local="http://minioadmin:minioadmin@minio:9000" \ + minio/mc:latest rb --force "local/${BUCKET}" >/dev/null 2>&1 || true + done + ok "MinIO buckets removed" + else + warn "Warning: $MINIO_CONTAINER not running — buckets NOT removed." + fi - # --- 3. Flush Redis logical DB -------------------------------------------- - if "$DOCKER" ps --format '{{.Names}}' | grep -qx "$REDIS_CONTAINER"; then - echo -e "${BLUE}Flushing Redis DB ${SLOT}...${NC}" - "$DOCKER" exec "$REDIS_CONTAINER" redis-cli -n "$SLOT" FLUSHDB >/dev/null \ - && echo -e "${GREEN}Redis DB ${SLOT} flushed${NC}" \ - || echo -e "${YELLOW}Warning: Could not flush Redis DB${NC}" + REDIS_CONTAINER="$(container_on_port 6379)" + if [ -z "$REDIS_CONTAINER" ]; then + warn "Warning: nothing is serving localhost:6379 — Redis DB $SLOT not flushed." + elif "$DOCKER" exec "$REDIS_CONTAINER" redis-cli -n "$SLOT" FLUSHDB >/dev/null; then + ok "Redis DB $SLOT flushed ($REDIS_CONTAINER)" + else + warn "Warning: FLUSHDB $SLOT failed on $REDIS_CONTAINER." + fi fi - # --- 4. Kill processes on slot ports -------------------------------------- - PORTS=("3${SLOT}00" "3${SLOT}01" "3${SLOT}02" "8${SLOT}00") - echo -e "${BLUE}Killing processes on ports: ${PORTS[*]}...${NC}" - for PORT in "${PORTS[@]}"; do + PORTS="$(slot_ports "$SLOT")" + info "Killing processes on ports: ${PORTS}..." + for PORT in $PORTS; do PID=$(lsof -ti:"$PORT" 2>/dev/null || true) if [ -n "$PID" ]; then kill -9 "$PID" 2>/dev/null || true @@ -118,14 +88,14 @@ else done fi -# --- 5. Remove the git worktree -------------------------------------------- -echo -e "${BLUE}Removing git worktree...${NC}" +info "Removing git worktree..." git -C "$ROOT_DIR" worktree remove "$WORKTREE_DIR" --force 2>/dev/null || { - echo -e "${YELLOW}git worktree remove failed; cleaning up manually.${NC}" + warn "git worktree remove failed, cleaning up manually..." rm -rf "$WORKTREE_DIR" git -C "$ROOT_DIR" worktree prune } echo -echo -e "${GREEN}Worktree '$NAME' destroyed${NC}" -[ -n "$SLOT" ] && echo -e "${GREEN}Slot $SLOT freed${NC}" +ok "Worktree '$NAME' destroyed" +[ -n "$SLOT" ] && ok "Slot $SLOT freed" +echo diff --git a/scripts/worktree/lib.sh b/scripts/worktree/lib.sh new file mode 100755 index 000000000..25965eb96 --- /dev/null +++ b/scripts/worktree/lib.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' + +info() { echo -e "${BLUE}$*${NC}"; } +ok() { echo -e "${GREEN}$*${NC}"; } +warn() { echo -e "${YELLOW}$*${NC}"; } +die() { echo -e "${RED}$*${NC}" >&2; exit 1; } + +sedi() { + if sed --version >/dev/null 2>&1; then sed -i "$@"; else sed -i '' "$@"; fi +} + +main_checkout_root() { + local common + common="$(git -C "$1" rev-parse --git-common-dir)" + common="$(cd "$1" && cd "$common" && pwd -P)" + dirname "$common" +} + +env_value() { + grep -m1 "^${2}=" "$1" 2>/dev/null | cut -d= -f2- || true +} + +project_name() { + local name="" + [ -f "$1/.env" ] && name="$(env_value "$1/.env" PROJECT_NAME)" + echo "${name:-$(basename "$1")}" +} + +resolve_docker() { + local candidate + for candidate in "$(command -v docker 2>/dev/null || true)" "$HOME/.docker/bin/docker" /Applications/Docker.app/Contents/Resources/bin/docker; do + if [ -n "$candidate" ] && [ -x "$candidate" ]; then + echo "$candidate" + return 0 + fi + done + return 1 +} + +container_state() { + "$DOCKER" inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true +} + +container_on_port() { + "$DOCKER" ps --filter "publish=$1" --format '{{.Names}}' 2>/dev/null | head -1 +} + +slot_ports() { + echo "3${1}00 3${1}01 3${1}02 8${1}00" +} + +slot_resources() { + local slot="$1" + WEB_PORT="3${slot}00" + ADMIN_PORT="3${slot}01" + SUPERADMIN_PORT="3${slot}02" + API_PORT="8${slot}00" + DB_LOCAL="${PROJECT_NAME}_wt_${slot}" + DB_TEST="${PROJECT_NAME}_test_wt_${slot}" + REDIS_DB="$slot" + STORAGE_BUCKET_SYSTEM="${PROJECT_NAME}-system-wt-${slot}" + STORAGE_BUCKET_USER="${PROJECT_NAME}-user-wt-${slot}" + STORAGE_BUCKET_SYSTEM_TEST="${PROJECT_NAME}-system-test-wt-${slot}" + STORAGE_BUCKET_USER_TEST="${PROJECT_NAME}-user-test-wt-${slot}" +} + +ensure_env_var() { + local file="$1" key="$2" value="$3" + if grep -q "^${key}=" "$file"; then + sedi "s|^${key}=.*|${key}=${value}|" "$file" + else + echo "${key}=${value}" >> "$file" + fi +} + +replace_env_var_if_present() { + local file="$1" key="$2" value="$3" + [ -f "$file" ] && grep -q "^${key}=" "$file" || return 0 + sedi "s|^${key}=.*|${key}=${value}|" "$file" +} + +rewrite_database_url() { + local file="$1" db="$2" + [ -f "$file" ] && grep -q '^DATABASE_URL=' "$file" || return 0 + sedi -E "s|^(DATABASE_URL=[a-z]+://[^@]+@[^/]+/)[^?[:space:]]*|\1${db}|" "$file" +} + +rewrite_redis_db() { + local file="$1" n="$2" + [ -f "$file" ] || return 0 + sedi -E "s#^(REDIS(_QUEUE)?_URL=redis://([^@/]*@)?(localhost|127\.0\.0\.1):[0-9]+)(/[0-9]+)?[[:space:]]*\$#\1/${n}#" "$file" +} + +linked_worktrees() { + git -C "$ROOT_DIR" worktree list --porcelain | sed -n 's/^worktree //p' | grep -vx "$ROOT_DIR" || true +} + +worktree_env_files() { + local f + for f in "$1/.env.local" "$1/.env.test" "$1"/apps/*/.env.local "$1"/apps/*/.env.test; do + [ -f "$f" ] && echo "$f" + done + return 0 +} + +worktree_claims_slot() { + local wt="$1" slot="$2" f + while IFS= read -r f; do + [ -n "$f" ] || continue + grep -q "^WORKTREE_SLOT=${slot}\$" "$f" && return 0 + grep -Eq "${PROJECT_NAME}_(test_)?wt_${slot}([^0-9]|\$)" "$f" && return 0 + done < <(worktree_env_files "$wt") + return 1 +} + +slot_claimant() { + local slot="$1" exclude="${2:-}" wt + while IFS= read -r wt; do + [ -n "$wt" ] && [ -d "$wt" ] && [ "$wt" != "$exclude" ] || continue + if worktree_claims_slot "$wt" "$slot"; then + echo "$wt" + return 0 + fi + done < <(linked_worktrees) + return 1 +} + +run_step() { + local label="$1" + shift + info "$label..." + local out + if out="$(cd "$WORKTREE_DIR" && "$@" 2>&1)"; then + return 0 + fi + echo "$out" | tail -15 + echo -e "${RED}$label failed.${NC}" + return 1 +} + +require_artifact() { + local rel="$1" hint="$2" + if [ ! -e "$WORKTREE_DIR/$rel" ]; then + GAPS="${GAPS} - $rel missing — rerun in the worktree: $hint\n" + fi +} diff --git a/scripts/worktree/list.sh b/scripts/worktree/list.sh index 5069bf35a..441f27e45 100755 --- a/scripts/worktree/list.sh +++ b/scripts/worktree/list.sh @@ -1,13 +1,9 @@ #!/usr/bin/env bash -# list.sh — Show all worktrees + the slot/ports they own. -# -# Usage: -# bun run worktree:list - set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +source "$SCRIPT_DIR/lib.sh" +ROOT_DIR="$(main_checkout_root "$SCRIPT_DIR")" printf "\n%-6s %-35s %-40s %s\n" "Slot" "Name" "Branch" "Ports (web/admin/super/api)" printf "%-6s %-35s %-40s %s\n" "----" "----" "------" "----------------------------" @@ -18,24 +14,19 @@ while IFS= read -r line; do [ -z "$branch" ] && branch="(detached)" name="$(basename "$wt_path")" - env_file="$wt_path/.env.local" - slot="" - - if [ -f "$env_file" ]; then - slot="$(grep -m1 '^WORKTREE_SLOT=' "$env_file" 2>/dev/null | cut -d= -f2 || true)" - fi + slot="$(env_value "$wt_path/.env.local" WORKTREE_SLOT)" if [ -z "$slot" ]; then if [ "$wt_path" = "$ROOT_DIR" ]; then name="(main)" slot="0" - ports="3000 3001 3002 8000" + ports="$(slot_ports 0)" else slot="-" ports="(not configured)" fi else - ports="3${slot}00 3${slot}01 3${slot}02 8${slot}00" + ports="$(slot_ports "$slot")" fi printf "%-6s %-35s %-40s %s\n" "$slot" "$name" "$branch" "$ports" From e097f0d0e619ee178dd562cac1044d22de6ed317 Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Wed, 23 Sep 2026 00:56:15 -0300 Subject: [PATCH 2/8] =?UTF-8?q?chore(worktrees):=20adversarial=20round=20o?= =?UTF-8?q?ne=20=E2=80=94=20segment-aware=20guard,=20slot=20registry,=20ha?= =?UTF-8?q?rdened=20create/destroy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard hook rewritten as a segment analyser: newline/indent/continuation splitting, runner and shell -c / eval unwrapping, quoted-string and heredoc masking, per-segment directory cursor (cd moves it; git -C / bun --cwd are segment-local), --dry-run and --abort/--continue exclusions, exact db:migrate script name, REQUIRE_MAIN_BRANCH isolated and compared against origin/HEAD, state files pruned after a day. 31 tests. Scripts: slot registry under .worktrees/.slots/, marker-or-reference slot ownership with conflict refusal, fork-onto-existing-branch refusal, whole multi-line quoted values in sync-env plus an env-source gap check, per-pid port kill, merged-branch warning for + prefixed branches, destroy restricted to registered worktrees, WORKTREE_SLOT validation, stale lock reclaim by dead pid or age, destroy skips DB drop when another worktree claims the slot, credential-less DATABASE_URL rewrite verified, PROJECT_NAME fallback aligned with docker-compose. Docs carry the identical deny condition and the new script behaviours. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/bashMainCheckoutGuard.ts | 332 ++++++++++++--- .../hooks/tests/bashMainCheckoutGuard.test.ts | 381 ++++++++++++++---- .claude/skills/worktrees/SKILL.md | 49 ++- AI/ENTRYPOINT.md | 2 +- docs/claude/SCRIPTS.md | 2 +- scripts/setup/sync-env.sh | 21 +- scripts/worktree/create.sh | 66 +-- scripts/worktree/destroy.sh | 56 ++- scripts/worktree/lib.sh | 143 ++++++- 9 files changed, 855 insertions(+), 197 deletions(-) diff --git a/.claude/hooks/bashMainCheckoutGuard.ts b/.claude/hooks/bashMainCheckoutGuard.ts index 8b248753a..81937e608 100644 --- a/.claude/hooks/bashMainCheckoutGuard.ts +++ b/.claude/hooks/bashMainCheckoutGuard.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; @@ -10,34 +10,145 @@ type HookInput = { tool_input?: { command?: string }; }; -const SEPARATOR = String.raw`(?:^|[;&|(]\s*)`; -const GLOBAL_OPTIONS = String.raw`(?:\s+-{1,2}[\w-]+(?:[= ]\S+)?)*`; -const RUNNER = String.raw`(?:(?:bunx|bun\s+run${GLOBAL_OPTIONS}|bun\s+x|npx|sudo|time|env(?:\s+\w+=\S*)*)\s+)*`; -const PATH_TOKEN = String.raw`(?:"([^"]+)"|'([^']+)'|([^\s;&|)]+))`; - -export const MUTATING = new RegExp( - `${SEPARATOR}${RUNNER}(?:` + - String.raw`bun${GLOBAL_OPTIONS}\s+(?:add|remove|update)\b` + - `|` + - String.raw`git${GLOBAL_OPTIONS}\s+(?:commit|push|merge|rebase|cherry-pick|worktree\s+(?:add|remove|move|prune))\b` + - `|` + - String.raw`prisma\s+migrate\s+dev\b` + - `|` + - String.raw`db:migrate\b` + - `)`, +export const REQUIRE_MAIN_BRANCH = true; + +const MASK = '\u0001'; +const SEPARATOR_AT = /^(?:\r?\n|;|&&|\|\||\||&|\(|\)|\{|\})/; +const HEREDOC_AT = /^<<(-?)\s*(['"]?)(\w+)\2/; +const LEADING_KEYWORD = /^(?:if|then|else|elif|fi|do|done|while|until|!)(?=\s|$)\s*/; +const LEADING_RUNNER = new RegExp( + String.raw`^(?:${[ + String.raw`[A-Za-z_]\w*=\S*`, + String.raw`env(?:\s+-i)?`, + 'command', + 'exec', + 'nohup', + String.raw`sudo(?:\s+-\S+)*`, + String.raw`timeout(?:\s+-\S+(?:\s+\S+)?)*\s+\d+\S*`, + String.raw`nice(?:\s+-n\s+\S+|\s+-\d+)?`, + 'time', + String.raw`xargs(?:\s+-\S+)*`, + String.raw`rtk(?:\s+proxy)?`, + 'bunx', + String.raw`bun\s+x`, + 'npx', + ].join('|')})(?=\s|$)\s*`, ); +const SHELL_C = /^(?:sh|bash|zsh|dash)(?:\s+-[A-Za-z]+)*\s+-[A-Za-z]*c[A-Za-z]*\s+/; +const EVAL = /^eval\s+/; +const CHANGE_DIR = /^(cd|pushd|popd)(?=\s|$)\s*/; +const GIT_MUTATING = new Set(['commit', 'push', 'merge', 'rebase', 'cherry-pick']); +const GIT_RESUMABLE = new Set(['merge', 'rebase', 'cherry-pick']); +const GIT_WORKTREE_MUTATING = new Set(['add', 'remove', 'move', 'prune']); +const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']); +const GIT_RESUMED = /\s--(?:abort|continue|quit|skip)(?=\s|$)/; +const DRY_RUN = /\s--dry-run(?=\s|$)/; +const BUN_MUTATING = new Set(['add', 'remove', 'update']); +const BUN_SCRIPTS = new Set(['db:migrate']); +const BUN_OPTIONS_WITH_VALUE = new Set([ + '--cwd', + '--filter', + '-F', + '--env-file', + '--config', + '-c', + '--port', + '--shell', +]); +const PRISMA_MIGRATE_DEV = /^prisma\s+migrate\s+dev(?=\s|$)/; +const QUOTED = /^(["'])([\s\S]*?)\1/; +const ANY_QUOTED = /(["'])([\s\S]*?)\1/g; -const LEADING_CD = new RegExp(String.raw`^\s*\(?\s*(?:cd|pushd)\s+${PATH_TOKEN}`); -const GIT_C = new RegExp(String.raw`\bgit(?:\s+-{1,2}[\w-]+(?:[= ]\S+)?)*\s+-C\s+${PATH_TOKEN}`, 'g'); +const joinContinuations = (command: string) => command.replace(/\\\r?\n/g, ' '); -const pathFromMatch = (match: RegExpMatchArray) => match[1] ?? match[2] ?? match[3]; +type Segment = { raw: string; masked: string }; -const explicitTargets = (command: string): string[] => { - const targets: string[] = []; - const leading = command.match(LEADING_CD); - if (leading) targets.push(pathFromMatch(leading)); - for (const match of command.matchAll(GIT_C)) targets.push(pathFromMatch(match)); - return targets; +export const segmentCommand = (command: string): Segment[] => { + const text = joinContinuations(command); + const segments: Segment[] = []; + let raw = ''; + let masked = ''; + let pendingHeredocs: { delimiter: string; stripTabs: boolean }[] = []; + const flush = () => { + const lead = masked.length - masked.trimStart().length; + const trail = masked.length - masked.trimEnd().length; + if (masked.trim()) segments.push({ raw: raw.slice(lead, raw.length - trail), masked: masked.trim() }); + raw = ''; + masked = ''; + }; + const skipHeredocBodies = (from: number): number => { + let i = from; + for (const { delimiter, stripTabs } of pendingHeredocs) { + while (i < text.length) { + const lineEnd = text.indexOf('\n', i); + const line = text.slice(i, lineEnd === -1 ? text.length : lineEnd).replace(/\r$/, ''); + i = lineEnd === -1 ? text.length : lineEnd + 1; + if ((stripTabs ? line.replace(/^\t+/, '') : line) === delimiter) break; + } + } + pendingHeredocs = []; + return i; + }; + let i = 0; + while (i < text.length) { + const rest = text.slice(i); + const ch = text[i]; + if (ch === "'" || ch === '"') { + let end = i + 1; + while (end < text.length && text[end] !== ch) end += ch === '"' && text[end] === '\\' ? 2 : 1; + const inner = text.slice(i + 1, Math.min(end, text.length)); + const close = end < text.length ? ch : ''; + raw += ch + inner + close; + masked += ch + MASK.repeat(inner.length) + close; + i = end + 1; + continue; + } + const heredoc = rest.match(HEREDOC_AT); + if (heredoc && !rest.startsWith('<<<')) { + pendingHeredocs.push({ delimiter: heredoc[3], stripTabs: heredoc[1] === '-' }); + raw += heredoc[0]; + masked += heredoc[0]; + i += heredoc[0].length; + continue; + } + const separator = rest.match(SEPARATOR_AT); + if (separator) { + flush(); + i += separator[0].length; + if (separator[0].endsWith('\n') && pendingHeredocs.length > 0) i = skipHeredocBodies(i); + continue; + } + raw += ch; + masked += ch; + i += 1; + } + flush(); + return segments; +}; + +const take = (segment: Segment, pattern: RegExp): RegExpMatchArray | undefined => { + const match = segment.masked.match(pattern); + if (!match) return undefined; + segment.raw = segment.raw.slice(match[0].length); + segment.masked = segment.masked.slice(match[0].length); + return match; +}; + +const stripPrefixes = (segment: Segment) => { + while (take(segment, LEADING_KEYWORD) || take(segment, LEADING_RUNNER)) { + if (!segment.masked) return; + } +}; + +const unquote = (token: string) => { + const quoted = token.match(QUOTED); + return quoted && quoted[0].length === token.length ? quoted[2] : token; +}; + +const rawWord = (segment: Segment, maskedWord: string | undefined): string | undefined => { + if (!maskedWord) return undefined; + const index = segment.masked.indexOf(maskedWord); + return index === -1 ? undefined : segment.raw.slice(index, index + maskedWord.length); }; type Checkout = { root: string; isMain: boolean }; @@ -64,27 +175,126 @@ const findCheckout = (from: string): Checkout | undefined => { } }; -const namesCheckoutDeliberately = (raw: string, cwd: string): boolean => { - if (/[$`]/.test(raw)) return false; - const expanded = raw === '~' || raw.startsWith('~/') ? join(homedir(), raw.slice(1)) : raw; - const path = isAbsolute(expanded) ? expanded : resolve(cwd, expanded); - if (!existsSync(path) || !statSync(path).isDirectory()) return false; - const checkout = findCheckout(path); - if (!checkout) return false; - return checkout.isMain ? isAbsolute(expanded) : true; +const resolveDirectory = (rawToken: string, from: string | undefined): string | undefined => { + const token = unquote(rawToken); + if (!token || /[$`*?]/.test(token) || token === '-') return undefined; + const expanded = token === '~' || token.startsWith('~/') ? join(homedir(), token.slice(1)) : token; + if (!isAbsolute(expanded) && !from) return undefined; + const path = isAbsolute(expanded) ? expanded : resolve(from as string, expanded); + return existsSync(path) && statSync(path).isDirectory() ? path : undefined; }; -const unresolvedTargets = (command: string, cwd: string): string[] => - explicitTargets(command).filter((target) => !namesCheckoutDeliberately(target, cwd)); +type Mutation = { command: string; target?: string }; + +type Invocation = { subcommand?: string; rest: string[]; target?: string }; + +const parseInvocation = ( + words: string[], + optionsWithValue: Set, + targetOption: string, + from = 1, +): Invocation => { + let target: string | undefined; + let i = from; + while (i < words.length && words[i].startsWith('-')) { + const [name, inlineValue] = words[i].split(/=(.*)/s); + if (name === targetOption) target = inlineValue ?? words[i + 1]; + i += inlineValue === undefined && optionsWithValue.has(name) ? 2 : 1; + } + return { subcommand: words[i], rest: words.slice(i + 1), target }; +}; + +const mutationIn = (segment: Segment): Mutation | undefined => { + const { masked } = segment; + const padded = ` ${masked}`; + if (DRY_RUN.test(padded)) return undefined; + const words = masked.split(/\s+/); + if (words[0] === 'git') { + const { subcommand, rest, target: maskedTarget } = parseInvocation(words, GIT_OPTIONS_WITH_VALUE, '-C'); + const target = rawWord(segment, maskedTarget); + if (!subcommand) return undefined; + if (GIT_MUTATING.has(subcommand)) { + if (GIT_RESUMABLE.has(subcommand) && GIT_RESUMED.test(padded)) return undefined; + return { command: masked, target }; + } + if (subcommand === 'worktree' && GIT_WORKTREE_MUTATING.has(rest[0])) return { command: masked, target }; + return undefined; + } + if (words[0] === 'bun') { + const outer = parseInvocation(words, BUN_OPTIONS_WITH_VALUE, '--cwd'); + if (!outer.subcommand) return undefined; + if (BUN_MUTATING.has(outer.subcommand)) return { command: masked, target: rawWord(segment, outer.target) }; + if (outer.subcommand !== 'run') return undefined; + const inner = parseInvocation(['run', ...outer.rest], BUN_OPTIONS_WITH_VALUE, '--cwd'); + const target = rawWord(segment, inner.target ?? outer.target); + if (!inner.subcommand) return undefined; + if (BUN_SCRIPTS.has(inner.subcommand)) return { command: masked, target }; + if (inner.subcommand === 'prisma' && PRISMA_MIGRATE_DEV.test(['prisma', ...inner.rest].join(' '))) + return { command: masked, target }; + return undefined; + } + if (PRISMA_MIGRATE_DEV.test(masked)) return { command: masked }; + return undefined; +}; + +export type Landing = { command: string; directory: string | undefined; namedTarget?: string }; + +const collectMutations = (command: string, cwd: string | undefined, landings: Landing[]) => { + let cursor: string | undefined = cwd; + for (const segment of segmentCommand(command)) { + stripPrefixes(segment); + if (!segment.masked) continue; + const changeDir = take(segment, CHANGE_DIR); + if (changeDir) { + if (changeDir[1] === 'popd') { + cursor = undefined; + continue; + } + const token = segment.masked.match(/^\S+/)?.[0]; + cursor = resolveDirectory(token ? (rawWord(segment, token) ?? token) : '~', cursor); + continue; + } + if (take(segment, SHELL_C)) { + collectMutations(segment.raw.match(QUOTED)?.[2] ?? segment.raw, cursor, landings); + continue; + } + if (take(segment, EVAL)) { + const quoted = [...segment.raw.matchAll(ANY_QUOTED)].map((match) => match[2]); + collectMutations(quoted.length > 0 ? quoted.join(' ') : segment.raw, cursor, landings); + continue; + } + const mutation = mutationIn(segment); + if (!mutation) continue; + const directory = mutation.target ? resolveDirectory(mutation.target, cursor) : cursor; + landings.push({ command: mutation.command, directory, namedTarget: mutation.target }); + } +}; + +export const mutationsIn = (command: string, cwd?: string): Landing[] => { + const landings: Landing[] = []; + collectMutations(command, cwd, landings); + return landings; +}; + +export const isMutating = (command: string) => mutationsIn(command).length > 0; const currentBranch = (root: string): string | undefined => { const head = readFileSync(join(root, '.git/HEAD'), 'utf8').trim(); return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : undefined; }; +export const defaultBranch = (root: string): string => { + const originHead = join(root, '.git/refs/remotes/origin/HEAD'); + if (!existsSync(originHead)) return 'main'; + const ref = readFileSync(originHead, 'utf8').trim(); + const prefix = 'ref: refs/remotes/origin/'; + return ref.startsWith(prefix) ? ref.slice(prefix.length) : 'main'; +}; + export type SessionState = { lastWorktree: (sessionId: string) => string | undefined; rememberWorktree: (sessionId: string, worktreeRoot: string) => void; + prune: (maxAgeMs: number) => void; }; export const fileSessionState = (dir = join(tmpdir(), 'template-worktree-guard')): SessionState => { @@ -96,10 +306,33 @@ export const fileSessionState = (dir = join(tmpdir(), 'template-worktree-guard') mkdirSync(dir, { recursive: true }); writeFileSync(file(sessionId), `${worktreeRoot}\n`); }, + prune: (maxAgeMs) => { + if (!existsSync(dir)) return; + const cutoff = Date.now() - maxAgeMs; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true }); + } + }, }; }; -export const guard = (input: HookInput, state: SessionState): string | undefined => { +export type GuardOptions = { requireMainBranch?: boolean }; + +const landsOnMain = ({ directory }: Landing) => { + if (!directory) return true; + const target = findCheckout(directory); + return !target || target.isMain; +}; + +const describe = ({ command, directory, namedTarget }: Landing) => { + if (directory) return ` \`${command}\` would run in ${directory}, which is the main checkout.`; + if (namedTarget) return ` \`${command}\` names ${namedTarget}, which does not exist or cannot be resolved.`; + return ` \`${command}\` runs in a directory this hook cannot resolve.`; +}; + +export const guard = (input: HookInput, state: SessionState, options: GuardOptions = {}): string | undefined => { + const requireMainBranch = options.requireMainBranch ?? REQUIRE_MAIN_BRANCH; if (!input.cwd || !input.session_id) return undefined; const checkout = findCheckout(input.cwd); if (!checkout) return undefined; @@ -110,30 +343,29 @@ export const guard = (input: HookInput, state: SessionState): string | undefined const command = input.tool_input?.command; if (input.tool_name !== 'Bash' || !command) return undefined; - if (!MUTATING.test(command)) return undefined; - - const targets = explicitTargets(command); - const unresolved = unresolvedTargets(command, input.cwd); - if (targets.length > 0 && unresolved.length === 0) return undefined; const worktree = state.lastWorktree(input.session_id); if (!worktree || findCheckout(worktree)?.isMain !== false) return undefined; - if (currentBranch(checkout.root) !== 'main') return undefined; + const branch = currentBranch(checkout.root); + if (requireMainBranch && branch !== defaultBranch(checkout.root)) return undefined; + + const landings = mutationsIn(command, input.cwd).filter(landsOnMain); + if (landings.length === 0) return undefined; + return [ - `This session has been working in the worktree ${worktree}, but the Bash cwd is now the MAIN checkout (${checkout.root}, branch main) — the session cwd resets to main between calls.`, - ...unresolved.map( - (target) => - `The command names ${target}, which does not exist, is not a git checkout, or is only a relative path into main — so it would run in main anyway.`, - ), - 'Dependency changes, commits, pushes, merges, worktree registry changes, and new migrations would land on main. Name the directory:', + `This session has been working in the worktree ${worktree}, but the Bash cwd is now the MAIN checkout (${checkout.root}, branch ${branch}) — the session cwd resets to main between calls.`, + ...landings.map(describe), + 'Dependency changes, commits, pushes, merges, worktree registry changes, and new migrations would land on main. Run it from the worktree:', ` cd ${worktree} && ${command}`, - `If main really is the target, say so the same way: cd ${checkout.root} && ${command}`, + `If the main checkout really is the target, check out a working branch there first (it is on ${branch}) or run the command outside this session.`, ].join('\n'); }; const main = () => { const input = JSON.parse(readFileSync(0, 'utf8')) as HookInput; - const reason = guard(input, fileSessionState(process.env.WORKTREE_GUARD_STATE_DIR)); + const state = fileSessionState(process.env.WORKTREE_GUARD_STATE_DIR); + if (input.hook_event_name === 'SessionStart') state.prune(24 * 60 * 60 * 1000); + const reason = guard(input, state); if (!reason) return; process.stdout.write( `${JSON.stringify({ diff --git a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts index a728d2306..3f9198969 100644 --- a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts +++ b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts @@ -1,8 +1,16 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { fileSessionState, guard, MUTATING, type SessionState } from '../bashMainCheckoutGuard'; +import { + defaultBranch, + fileSessionState, + guard, + isMutating, + mutationsIn, + REQUIRE_MAIN_BRANCH, + type SessionState, +} from '../bashMainCheckoutGuard'; const HOOKS_DIR = resolve(import.meta.dir, '..'); @@ -24,12 +32,16 @@ const bash = (command: string, cwd: string, sessionId = SESSION) => ({ const visitWorktree = (sessionId = SESSION) => guard(bash('ls', worktreeRoot, sessionId), state); -const onMain = (head: string, run: () => void) => { +const fromMain = (command: string) => guard(bash(command, mainRoot), state); + +const withRefs = (head: string, originHead: string, run: () => void) => { writeFileSync(join(mainRoot, '.git/HEAD'), `ref: refs/heads/${head}\n`); + writeFileSync(join(mainRoot, '.git/refs/remotes/origin/HEAD'), `ref: refs/remotes/origin/${originHead}\n`); try { run(); } finally { writeFileSync(join(mainRoot, '.git/HEAD'), 'ref: refs/heads/main\n'); + writeFileSync(join(mainRoot, '.git/refs/remotes/origin/HEAD'), 'ref: refs/remotes/origin/main\n'); } }; @@ -38,11 +50,15 @@ beforeAll(() => { mainRoot = join(sandbox, 'repo'); mkdirSync(join(mainRoot, '.git/worktrees/FEAT-1234'), { recursive: true }); + mkdirSync(join(mainRoot, '.git/refs/remotes/origin'), { recursive: true }); writeFileSync(join(mainRoot, '.git/HEAD'), 'ref: refs/heads/main\n'); + writeFileSync(join(mainRoot, '.git/refs/remotes/origin/HEAD'), 'ref: refs/remotes/origin/main\n'); mkdirSync(join(mainRoot, 'apps/api'), { recursive: true }); + mkdirSync(join(mainRoot, 'packages/db'), { recursive: true }); worktreeRoot = join(mainRoot, '.worktrees/FEAT-1234'); mkdirSync(join(worktreeRoot, 'apps/api'), { recursive: true }); + mkdirSync(join(worktreeRoot, 'packages/db'), { recursive: true }); writeFileSync(join(worktreeRoot, '.git'), `gitdir: ${mainRoot}/.git/worktrees/FEAT-1234\n`); plainDir = join(sandbox, 'not-a-checkout'); @@ -57,8 +73,8 @@ afterAll(() => { rmSync(sandbox, { recursive: true, force: true }); }); -describe('MUTATING', () => { - test('the guarded set, with global options before the subcommand and through command runners', () => { +describe('isMutating', () => { + test('the guarded set, with global options before the subcommand', () => { for (const command of [ 'bun add left-pad', 'bun --cwd . add left-pad', @@ -82,109 +98,293 @@ describe('MUTATING', () => { 'true || git push', '(git push)', ]) { - expect(MUTATING.test(command), command).toBe(true); + expect(isMutating(command), command).toBe(true); + } + }); + + test('finding 1: newlines, indentation, comments, and line continuations do not hide a command', () => { + for (const command of [ + 'echo hi\ngit push', + ' git push', + '\tgit push', + '# comment\ngit push', + 'cd /tmp\ngit push', + 'git \\\n push', + 'echo a\r\ngit push', + 'echo a\n\n bun add x', + ]) { + expect(isMutating(command), JSON.stringify(command)).toBe(true); + } + }); + + test('finding 2: wrappers, env assignments, shell -c, eval, and compound bodies are seen through', () => { + for (const command of [ + 'FOO=1 git push', + 'A=1 B=2 git push', + 'env git push', + 'env -i A=1 git push', + 'command git push', + 'exec git push', + 'nohup git push', + 'timeout 10 git push', + 'timeout -k 5 10 git push', + 'nice -n 5 git push', + 'nice git push', + 'time git push', + 'echo origin | xargs git push', + 'rtk git push', + 'rtk proxy git push', + 'rtk proxy bun add x', + 'bunx prisma migrate dev', + 'bun x prisma migrate dev', + 'npx prisma migrate dev', + 'sh -c "git push"', + "bash -c 'cd /tmp && git push'", + 'bash -lc "git commit -m x"', + 'eval "git push"', + "eval 'bun add x'", + 'eval git push', + '{ git push; }', + 'if true; then git push; fi', + 'for x in a b; do git push; done', + 'while true; do git commit -m x; done', + 'true && { git push; }', + 'if git push; then echo ok; fi', + '! git push', + ]) { + expect(isMutating(command), command).toBe(true); + } + }); + + test('finding 4: quoted strings and heredoc bodies are never matched', () => { + for (const command of [ + 'gh pr comment 1 --body "fix; git push later"', + 'gh pr comment 1 --body "run bun install && git push"', + 'echo "then; bun add x"', + "echo 'git push; bun add x'", + 'grep -rn "; git commit" docs/', + 'rg "git commit|git push" .', + 'gh pr create --body "$(cat < notes.md\n; git push\nEOF\necho done', + 'git log --grep="git push"', + ]) { + expect(isMutating(command), JSON.stringify(command)).toBe(false); } }); - test('read-only, routine, and mention-only commands are outside it', () => { + test('finding 4: non-mutating forms of guarded subcommands', () => { + for (const command of [ + 'git push --dry-run', + 'git push --dry-run origin HEAD', + 'git merge --abort', + 'git rebase --continue', + 'git rebase --quit', + 'git cherry-pick --skip', + 'git worktree list', + 'git worktree prune --dry-run', + 'git commit --dry-run', + 'bun add --dry-run left-pad', + ]) { + expect(isMutating(command), command).toBe(false); + } + }); + + test('finding 14: only the real migrate script names count', () => { + expect(isMutating('bun run db:migrate')).toBe(true); + expect(isMutating('bun run db:migrate:anything')).toBe(false); + expect(isMutating('bun run db:migrate-status')).toBe(false); + expect(isMutating('bun run db:push:dev')).toBe(false); + expect(isMutating('bun run db:deploy')).toBe(false); + }); + + test('read-only and routine commands are outside the set', () => { for (const command of [ 'git status', 'git pull --ff-only origin main', 'git -C . log --oneline', - 'git worktree list', 'bun install', 'bun run worktree:create main FEAT-9', 'bun run worktree:destroy FEAT-1234', 'bun run test', - 'bun run db:push:dev', - 'gh pr comment 1 --body "remember to git push"', - 'echo "then run: bun add left-pad"', - 'cat < { + expect(isMutating('cat < { + test('a cd applies to the segments after it, in order', () => { + expect(mutationsIn(`cd ${worktreeRoot} && git push`, mainRoot).map((m) => m.directory)).toEqual([worktreeRoot]); + expect(mutationsIn(`cd ${worktreeRoot} && cd .. && git push`, mainRoot).map((m) => m.directory)).toEqual([ + join(mainRoot, '.worktrees'), + ]); + expect(mutationsIn(`cd ${worktreeRoot}; cd ${mainRoot}; git push`, mainRoot).map((m) => m.directory)).toEqual([ + mainRoot, + ]); + expect(mutationsIn('cd .worktrees/FEAT-1234 && git push', mainRoot).map((m) => m.directory)).toEqual([ + worktreeRoot, + ]); + expect(mutationsIn(`cd ${worktreeRoot}\ngit push`, mainRoot).map((m) => m.directory)).toEqual([worktreeRoot]); + }); + + test('git -C and bun --cwd apply only to their own segment', () => { + expect(mutationsIn(`git -C ${worktreeRoot} status && git push`, mainRoot).map((m) => m.directory)).toEqual([ + mainRoot, + ]); + expect(mutationsIn(`git -C ${worktreeRoot} push && git status`, mainRoot).map((m) => m.directory)).toEqual([ + worktreeRoot, + ]); + expect(mutationsIn(`cd ${worktreeRoot} && git -C . push`, mainRoot).map((m) => m.directory)).toEqual([ + worktreeRoot, + ]); + expect(mutationsIn(`cd ${worktreeRoot} && bun --cwd . add x`, mainRoot).map((m) => m.directory)).toEqual([ + worktreeRoot, + ]); + expect(mutationsIn(`bun --cwd ${worktreeRoot}/apps/api add x`, mainRoot).map((m) => m.directory)).toEqual([ + join(worktreeRoot, 'apps/api'), + ]); + expect(mutationsIn('bun run --cwd packages/db db:migrate', mainRoot).map((m) => m.directory)).toEqual([ + join(mainRoot, 'packages/db'), + ]); + }); + + test('unknown targets leave the directory unresolved', () => { + expect(mutationsIn('cd /does/not/exist; git push', mainRoot)[0].directory).toBeUndefined(); + expect(mutationsIn('cd "$SOMEWHERE" && git push', mainRoot)[0].directory).toBeUndefined(); + expect(mutationsIn('cd - && git push', mainRoot)[0].directory).toBeUndefined(); + expect(mutationsIn('popd && git push', mainRoot)[0].directory).toBeUndefined(); + expect(mutationsIn('git -C /does/not/exist push', mainRoot)[0]).toMatchObject({ + directory: undefined, + namedTarget: '/does/not/exist', + }); + expect(mutationsIn(`cd ${worktreeRoot} && git -C /does/not/exist push`, mainRoot)[0].directory).toBeUndefined(); + }); + + test('quoted paths resolve like bare ones', () => { + expect(mutationsIn(`cd "${worktreeRoot}" && git push`, mainRoot)[0].directory).toBe(worktreeRoot); + expect(mutationsIn(`cd '${worktreeRoot}/apps/api'; git push`, mainRoot)[0].directory).toBe( + join(worktreeRoot, 'apps/api'), + ); + expect(mutationsIn(`git -C "${worktreeRoot}" push`, mainRoot)[0].directory).toBe(worktreeRoot); + }); + + test('shell -c and eval inherit the cursor', () => { + expect(mutationsIn(`cd ${worktreeRoot} && bash -c 'git push'`, mainRoot)[0].directory).toBe(worktreeRoot); + expect(mutationsIn(`bash -c 'cd ${worktreeRoot} && git push'`, mainRoot)[0].directory).toBe(worktreeRoot); + expect(mutationsIn(`eval "cd ${worktreeRoot}; git push"`, mainRoot)[0].directory).toBe(worktreeRoot); + }); }); describe('bashMainCheckoutGuard', () => { test('a session that has only ever been in the main checkout is never guarded', () => { - expect(guard(bash('bun add left-pad', mainRoot), state)).toBeUndefined(); - expect(guard(bash('git commit -m "docs"', mainRoot), state)).toBeUndefined(); + expect(fromMain('bun add left-pad')).toBeUndefined(); + expect(fromMain('git commit -m "docs"')).toBeUndefined(); expect(guard(bash('git push origin main', join(mainRoot, 'apps/api')), state)).toBeUndefined(); }); test('after the session has been in a worktree, a bare mutation from main is denied and names that worktree', () => { visitWorktree(); - const reason = guard(bash('bun add @inixiative/json-rules@2.22.0', mainRoot), state); + const reason = fromMain('bun add @inixiative/json-rules@2.22.0'); expect(reason).toContain('MAIN checkout'); expect(reason).toContain(`cd ${worktreeRoot} && bun add @inixiative/json-rules@2.22.0`); - expect(reason).toContain(`cd ${mainRoot} && bun add @inixiative/json-rules@2.22.0`); + expect(reason).toContain(`would run in ${mainRoot}, which is the main checkout`); }); - test('commits, pushes, merges, worktree registry changes, and new migrations are the guarded set', () => { + test('finding 1: a hidden second line from main is denied', () => { visitWorktree(); - expect(guard(bash('git commit -m "wip"', mainRoot), state)).toBeDefined(); - expect(guard(bash('git add -A && git push origin HEAD', mainRoot), state)).toBeDefined(); - expect(guard(bash('git rebase origin/main', mainRoot), state)).toBeDefined(); - expect(guard(bash('git worktree add ../x feature', mainRoot), state)).toBeDefined(); - expect(guard(bash('bun run db:migrate --name add_thing', mainRoot), state)).toBeDefined(); - expect(guard(bash('bunx prisma migrate dev', join(mainRoot, 'apps/api')), state)).toBeDefined(); + expect(fromMain('echo hi\ngit push')).toBeDefined(); + expect(fromMain('# comment\ngit push')).toBeDefined(); + expect(fromMain('git \\\n push')).toBeDefined(); }); - test('global options before the subcommand do not bypass the guard', () => { + test('finding 2: wrappers from main are denied', () => { visitWorktree(); - expect(guard(bash('git -C . commit -m x', mainRoot), state)).toBeDefined(); - expect(guard(bash('git --no-pager push', mainRoot), state)).toBeDefined(); - expect(guard(bash('bun --cwd . add left-pad', mainRoot), state)).toBeDefined(); + for (const command of [ + 'FOO=1 git push', + 'rtk proxy git push', + 'sh -c "git push"', + 'eval "git push"', + '{ git push; }', + ]) { + expect(fromMain(command), command).toBeDefined(); + } }); - test('an explicit cd into an existing checkout is a deliberate choice, even when it targets main', () => { + test('finding 3: naming a worktree somewhere in the command does not whitelist the whole command', () => { visitWorktree(); - expect(guard(bash(`cd ${mainRoot} && bun add left-pad`, mainRoot), state)).toBeUndefined(); - expect(guard(bash(`cd ${worktreeRoot} && git commit -m x`, mainRoot), state)).toBeUndefined(); - expect(guard(bash(`cd "${worktreeRoot}" && git commit -m x`, mainRoot), state)).toBeUndefined(); - expect(guard(bash(`cd '${worktreeRoot}/apps/api'; git push`, mainRoot), state)).toBeUndefined(); - expect(guard(bash('cd .worktrees/FEAT-1234 && git push', mainRoot), state)).toBeUndefined(); + expect(fromMain(`git -C ${worktreeRoot} status && git push`)).toBeDefined(); + expect(fromMain(`cd ${worktreeRoot} && cd .. && git push`)).toBeDefined(); + expect(fromMain(`cd ${worktreeRoot}; cd ${mainRoot}; git push`)).toBeDefined(); + expect(fromMain(`cd ${mainRoot} && bun add left-pad`)).toBeDefined(); + expect(fromMain(`git -C "${mainRoot}" push`)).toBeDefined(); + expect(fromMain('cd . && git push')).toBeDefined(); + expect(fromMain('cd apps/api && bun add left-pad')).toBeDefined(); }); - test('a cd whose target does not exist or is not a checkout does not bypass the guard', () => { + test('finding 3: a mutation whose effective directory is a worktree passes', () => { visitWorktree(); - const missing = guard(bash('cd /does/not/exist; git push', mainRoot), state); - expect(missing).toContain('/does/not/exist'); - expect(missing).toContain('would run in main anyway'); - expect(guard(bash('cd /bad || git commit -m x', mainRoot), state)).toBeDefined(); - expect(guard(bash(`cd ${plainDir} && git push`, mainRoot), state)).toBeDefined(); - expect(guard(bash('cd "$SOMEWHERE" && git push', mainRoot), state)).toBeDefined(); + expect(fromMain(`cd ${worktreeRoot} && git commit -m x`)).toBeUndefined(); + expect(fromMain(`cd "${worktreeRoot}" && git commit -m x`)).toBeUndefined(); + expect(fromMain(`cd '${worktreeRoot}/apps/api'; git push`)).toBeUndefined(); + expect(fromMain('cd .worktrees/FEAT-1234 && git push')).toBeUndefined(); + expect(fromMain(`git -C ${worktreeRoot} commit -m x`)).toBeUndefined(); + expect(fromMain('git -C .worktrees/FEAT-1234 commit -m x')).toBeUndefined(); + expect(fromMain(`git -C ${worktreeRoot} push && git status`)).toBeUndefined(); + expect(fromMain(`cd ${worktreeRoot} && git -C . push`)).toBeUndefined(); + expect(fromMain(`bun --cwd ${worktreeRoot} add left-pad`)).toBeUndefined(); + expect(fromMain(`bash -c 'cd ${worktreeRoot} && git push'`)).toBeUndefined(); }); - test('a relative path into main is not a deliberate choice of main', () => { + test('a cd whose target does not exist or is not a checkout does not bypass the guard', () => { visitWorktree(); - expect(guard(bash('cd . && git push', mainRoot), state)).toBeDefined(); - expect(guard(bash('cd apps/api && bun add left-pad', mainRoot), state)).toBeDefined(); - expect(guard(bash('git -C . commit -m x', mainRoot), state)).toBeDefined(); + const missing = fromMain('cd /does/not/exist; git push'); + expect(missing).toContain('cannot resolve'); + expect(fromMain('cd /bad || git commit -m x')).toBeDefined(); + expect(fromMain(`cd ${plainDir} && git push`)).toBeDefined(); + expect(fromMain('cd "$SOMEWHERE" && git push')).toBeDefined(); + const named = fromMain('git -C /does/not/exist commit -m x'); + expect(named).toContain('names /does/not/exist'); + expect(fromMain(`git -C ${plainDir} push`)).toBeDefined(); + expect(fromMain(`cd ${worktreeRoot} && git -C /does/not/exist push`)).toBeDefined(); }); - test('git -C is honoured like an explicit cd, under the same existence and checkout check', () => { + test('finding 4: mention-only text and non-mutating forms pass from main', () => { visitWorktree(); - expect(guard(bash(`git -C ${worktreeRoot} commit -m x`, mainRoot), state)).toBeUndefined(); - expect(guard(bash('git -C .worktrees/FEAT-1234 commit -m x', mainRoot), state)).toBeUndefined(); - expect(guard(bash(`git -C "${mainRoot}" push`, mainRoot), state)).toBeUndefined(); - expect(guard(bash('git -C /does/not/exist commit -m x', mainRoot), state)).toBeDefined(); - expect(guard(bash(`git -C ${plainDir} push`, mainRoot), state)).toBeDefined(); - expect(guard(bash(`cd ${worktreeRoot} && git -C /does/not/exist push`, mainRoot), state)).toBeDefined(); + for (const command of [ + 'gh pr comment 1 --body "fix; git push later"', + 'gh pr comment 1 --body "run bun install && git push"', + 'echo "then; bun add x"', + 'grep -rn "; git commit" docs/', + 'rg "git commit|git push" .', + 'gh pr create --body "$(cat < { + test('read-only and routine commands on main pass even after worktree work', () => { visitWorktree(); - expect(guard(bash('git status', mainRoot), state)).toBeUndefined(); - expect(guard(bash('git pull --ff-only origin main', mainRoot), state)).toBeUndefined(); - expect(guard(bash('bun install', mainRoot), state)).toBeUndefined(); - expect(guard(bash('bun run worktree:create main FEAT-9', mainRoot), state)).toBeUndefined(); - expect(guard(bash('bun run worktree:destroy FEAT-1234', mainRoot), state)).toBeUndefined(); - expect(guard(bash('bun run test', mainRoot), state)).toBeUndefined(); - expect(guard(bash('gh pr comment 1 --body "remember to git push"', mainRoot), state)).toBeUndefined(); - expect(guard(bash('cat < { @@ -199,25 +399,60 @@ describe('bashMainCheckoutGuard', () => { expect(guard(bash('git commit -m x', mainRoot, 'session-a'), state)).toBeDefined(); }); - test('a main checkout on a feature branch, or a remembered worktree that no longer exists, is not guarded', () => { + test('a remembered worktree that no longer exists is not guarded', () => { visitWorktree(); - onMain('hotfix', () => { - expect(guard(bash('git commit -m x', mainRoot), state)).toBeUndefined(); + state.rememberWorktree(SESSION, join(mainRoot, '.worktrees/destroyed')); + expect(fromMain('git commit -m x')).toBeUndefined(); + }); + + test('finding 13: REQUIRE_MAIN_BRANCH is on, so a main checkout on another branch is not guarded', () => { + expect(REQUIRE_MAIN_BRANCH).toBe(true); + visitWorktree(); + withRefs('hotfix', 'main', () => { + expect(fromMain('git commit -m x')).toBeUndefined(); }); - expect(guard(bash('git commit -m x', mainRoot), state)).toBeDefined(); + expect(fromMain('git commit -m x')).toBeDefined(); + }); - state.rememberWorktree(SESSION, join(mainRoot, '.worktrees/destroyed')); - expect(guard(bash('git commit -m x', mainRoot), state)).toBeUndefined(); + test('finding 13: with the requirement off, the branch no longer matters', () => { + visitWorktree(); + withRefs('hotfix', 'main', () => { + expect(guard(bash('git commit -m x', mainRoot), state, { requireMainBranch: false })).toBeDefined(); + }); + }); + + test('finding 13: the guarded branch is the one origin/HEAD points at, as create.sh reads it', () => { + expect(defaultBranch(mainRoot)).toBe('main'); + visitWorktree(); + withRefs('develop', 'develop', () => { + expect(defaultBranch(mainRoot)).toBe('develop'); + expect(fromMain('git commit -m x')).toBeDefined(); + }); + withRefs('main', 'develop', () => { + expect(fromMain('git commit -m x')).toBeUndefined(); + }); }); test('a SessionStart event inside a worktree records it before any Bash call', () => { expect( guard({ hook_event_name: 'SessionStart', session_id: SESSION, cwd: join(worktreeRoot, 'apps/api') }, state), ).toBeUndefined(); - expect(guard(bash('git push', mainRoot), state)).toBeDefined(); + expect(fromMain('git push')).toBeDefined(); + }); + + test('state files older than a day are pruned, fresh ones kept', () => { + const dir = mkdtempSync(join(sandbox, 'prune-')); + const pruned = fileSessionState(dir); + pruned.rememberWorktree('old', worktreeRoot); + pruned.rememberWorktree('fresh', worktreeRoot); + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + utimesSync(join(dir, 'old'), twoDaysAgo, twoDaysAgo); + pruned.prune(24 * 60 * 60 * 1000); + expect(pruned.lastWorktree('old')).toBeUndefined(); + expect(pruned.lastWorktree('fresh')).toBe(worktreeRoot); }); - test('the hook binary keeps per-session state, emits a PreToolUse deny, and stays silent otherwise', () => { + test('the hook binary keeps per-session state, emits a PreToolUse deny, prunes on SessionStart, and stays silent otherwise', () => { const stateDir = mkdtempSync(join(sandbox, 'bin-state-')); const run = (input: Record) => { const result = Bun.spawnSync(['bun', join(HOOKS_DIR, 'bashMainCheckoutGuard.ts')], { @@ -230,8 +465,12 @@ describe('bashMainCheckoutGuard', () => { expect(result.stderr.toString()).toBe(''); return result.stdout.toString().trim(); }; + writeFileSync(join(stateDir, 'stale'), `${worktreeRoot}\n`); + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + utimesSync(join(stateDir, 'stale'), twoDaysAgo, twoDaysAgo); expect(run(bash('git push', mainRoot))).toBe(''); - expect(run(bash('ls', worktreeRoot))).toBe(''); + expect(run({ hook_event_name: 'SessionStart', session_id: SESSION, cwd: worktreeRoot })).toBe(''); + expect(fileSessionState(stateDir).lastWorktree('stale')).toBeUndefined(); const denied = JSON.parse(run(bash('git push', mainRoot))) as { hookSpecificOutput: { hookEventName: string; permissionDecision: string; permissionDecisionReason: string }; }; diff --git a/.claude/skills/worktrees/SKILL.md b/.claude/skills/worktrees/SKILL.md index 611a1622a..bd0062dc2 100644 --- a/.claude/skills/worktrees/SKILL.md +++ b/.claude/skills/worktrees/SKILL.md @@ -13,10 +13,10 @@ Always use the scripts. The raw `git worktree add` / `git worktree remove` are i | Command | What it does | |---|---| -| `bun run worktree:create [--skip-db]` | Checks Docker/Postgres up front, allocates a slot (1–9) under a per-slot lock, creates the git worktree at `.worktrees//`, generates slot-specific `.env.local` / `.env.test` (root and `apps/*`) synced against the branch's `.env.*.example`, creates the Postgres `_wt_` and `_test_wt_` databases and MinIO buckets, runs `bun install` and verifies workspace resolution, builds the gitignored artifacts (route trees, Prisma client + `prismaMap.gen.ts` + zod, OpenAPI spec + SDK + MSW handlers), pushes the Prisma schema to both DBs, and warns about any worktrees whose branches are already merged into the main branch. `--skip-db` skips the Docker gate, databases, buckets, and schema push. | +| `bun run worktree:create [--skip-db]` | Checks Docker/Postgres up front, allocates a slot (1–9) under a per-slot lock and records it in `.worktrees/.slots/`, creates the git worktree at `.worktrees//`, generates slot-specific `.env.local` / `.env.test` (root and `apps/*`) synced against the branch's `.env.*.example`, creates the Postgres `_wt_` and `_test_wt_` databases and MinIO buckets, runs `bun install` and verifies workspace resolution, builds the gitignored artifacts (route trees, Prisma client + `prismaMap.gen.ts` + zod, OpenAPI spec + SDK + MSW handlers), pushes the Prisma schema to both DBs, and warns about any worktrees whose branches are already merged into the default branch. Refuses when `` already exists (attach it instead). `--skip-db` skips the Docker gate, databases, buckets, and schema push. | | `bun run worktree:create [--skip-db]` | Same, but attaches an existing local/`origin` branch to a new worktree (useful for PR review without disturbing the main checkout). | | `bun run worktree:list` | Shows every worktree with its slot, branch, and assigned ports. | -| `bun run worktree:destroy ` | Drops the Postgres databases, removes the MinIO buckets, `FLUSHDB` on Redis DB ``, kills processes on the worktree's four ports, removes the git worktree, frees the slot. | +| `bun run worktree:destroy ` | Drops the Postgres databases, removes the MinIO buckets, `FLUSHDB` on Redis DB ``, kills every listener on the worktree's four ports, removes the git worktree, frees the slot. | `` for destroy is the worktree directory name (the branch with slashes replaced by dashes). Get it from `bun run worktree:list`. @@ -54,25 +54,25 @@ Each slot (1–9) gets a unique set of resources. If `WORKTREE_SLOT=3` (and `PRO | Redis DB index | `3` | | MinIO buckets | `template-{system,user}{,-test}-wt-3` | -The slot is recorded as `WORKTREE_SLOT=` at the top of the worktree's `.env.local` and `.env.test`. Don't edit it. A slot counts as taken when any registered worktree carries that marker **or** references the slot's database names in any of its env files, so a worktree that lost its marker still owns its slot. +Slot ownership is recorded in two places: `.worktrees/.slots/` (one file per slot, containing the owning worktree's path — the registry) and `WORKTREE_SLOT=` on the **first line** of the worktree's `.env.local` and `.env.test` (the marker). Don't edit either. A slot counts as taken when the registry names a registered worktree for it, or when any registered worktree carries the marker **or** references the slot's database names in any of its env files — so a worktree that lost its marker still owns its slot. A registry entry whose worktree is gone is removed (with a notice) on the next create. A worktree whose files name **more than one** slot is a conflict: create reserves every slot it names and warns; destroy refuses to guess and asks you to fix the env files first. The main checkout uses slot 0 / default ports (`3000`, `3001`, `3002`, `8000`). It shows in `bun run worktree:list` as `(main)`. ## What `bun run worktree:create` runs (so you know what to expect) -1. Pre-flight, before anything is created: root `.env.local` must exist; unless `--skip-db`, resolves `docker` (PATH, `~/.docker/bin`, `Docker.app`), requires the daemon, starts a stopped `_postgres` container, and waits for Postgres to answer. Any of these missing is a hard error with the fix in the message — never a "ready" with silently skipped databases. -2. Scans registered worktrees for allocated slots (marker or DB-name reference); surfaces worktrees whose branches are already merged into the main branch (clean-up candidates). -3. Claims the lowest free slot 1–9 under a `mkdir` lock in `.worktrees/.locks/` (held until the script exits; a lock left by a dead process is reclaimed). Exits if all 9 are taken. -4. Creates the git worktree off the base branch (creates the new branch if needed; attaches an existing one in attach mode). -5. Writes `WORKTREE_SLOT=` as the first line of the worktree's `.env.local` / `.env.test` and copies root's values and `apps/*/.env.*` in, then runs `scripts/setup/sync-env.sh` **against the branch's own `.env.*.example` files**, so a key the branch's `apps/api/src/config/env.ts` requires arrives even when root's env files lag. Then rewrites the slot ports / URLs / DB names / Redis index / buckets — in the root files and in `apps/api/.env.local`, whose `PORT` and `API_URL` override root's under `with-env`. +1. Pre-flight, before anything is created: root `.env.local` must exist; forking onto a branch that already exists is refused; unless `--skip-db`, resolves `docker` (PATH, `~/.docker/bin`, `Docker.app`), requires the daemon, starts a stopped `_postgres` container, and waits for Postgres to answer. Any of these missing is a hard error with the fix in the message — never a "ready" with silently skipped databases. +2. Prunes stale registry entries, scans registered worktrees for allocated slots (registry, marker, or DB-name reference), and surfaces worktrees whose branches are already merged into the default branch — including branches checked out in a worktree, which `git branch --merged` prefixes with `+`. +3. Claims the lowest free slot 1–9 under a `mkdir` lock in `.worktrees/.locks/slot-` (pid-stamped, held until the script exits). A lock whose pid is dead, or that has no pid file and is older than 60 seconds, is reclaimed with a notice. Exits if all 9 are taken. +4. Creates the git worktree off the base branch (creates the new branch if needed; attaches an existing one in attach mode) and writes the registry entry. +5. Copies root's `.env.local` / `.env.test` and `apps/*/.env.*` in, runs `scripts/setup/sync-env.sh` **against the branch's own `.env.*.example` files** (whole multi-line quoted values, so the webhook PEM keys arrive intact), then puts `WORKTREE_SLOT=` on the first line of `.env.local` / `.env.test` and rewrites the slot ports / URLs / DB names / Redis index / buckets — in the root files and in `apps/api/.env.local`, whose `PORT` and `API_URL` override root's under `with-env`. A `DATABASE_URL` that cannot be pointed at the slot database (credential-less URLs included) is a hard error. Every generated env file must source cleanly in a subshell; one that does not is a gap. 6. Creates both Postgres databases. A database already on the slot is dropped only when no registered worktree references that slot (a leftover from a worktree removed without `worktree:destroy`); if one does, the script refuses and names it. 7. Provisions the slot's MinIO buckets. 8. `bun install --force --ignore-scripts` in the worktree and verifies `apps/api/node_modules/@template/db` resolves *inside* the worktree. 9. Builds the gitignored artifacts: `bun run generate:routes` (`apps/*/app/routeTree.gen.ts`), `bun run db:generate` (Prisma client, `prismaMap.gen.ts`, zod), `bun run generate:sdk` (OpenAPI spec, `packages/sdk/src/generated`, `packages/ui/src/test/mocks/handlers.gen.ts`). 10. Pushes the Prisma schema to both DBs (`bun run db:push:dev`) and pings the local one. -11. Prints a summary. Green **ready** means every step passed. Yellow **created WITH GAPS** lists exactly which artifact or push is missing and the command to rerun in the worktree; do not run services or tests until the list is empty. +11. Prints a summary. Green **ready** means every step passed. Yellow **created WITH GAPS** lists exactly which env file, artifact, or push is missing and the command to rerun in the worktree; do not run services or tests until the list is empty. -`--skip-db` (any position) skips steps 1's Docker gate, 6, 7, and 10 — for docs/skills work that never runs services or tests. Artifacts are still built. +`--skip-db` (any position) skips step 1's Docker gate and steps 6, 7, and 10 — for docs/skills work that never runs services or tests. Artifacts are still built. ## Running services in the worktree @@ -92,9 +92,17 @@ When you dispatch a subagent to do code work in a worktree, **the subagent does 1. Always tell the subagent the **absolute worktree path** in the prompt: `Work entirely in /.worktrees/. Do NOT touch the main repo path.` 2. After the subagent finishes, verify it didn't edit files in the main repo — check `git status` in both places. -The same applies to your own session: the Bash tool's cwd can reset to the main checkout between calls (after a session restart, or after a command that `cd`'d outside the repo). A bare `bun add`, `git commit`, or `git push` then lands on main. The `bashMainCheckoutGuard` hook (`.claude/hooks/bashMainCheckoutGuard.ts`, registered in `.claude/settings.json` as PreToolUse on Bash plus SessionStart) remembers per session which worktree the cwd has been inside, and denies exactly that drift: a mutating command from the main checkout, on branch `main`, after this session has been in a worktree that still exists. The guarded set is `bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, and `bun run db:migrate` — global options before the subcommand (`git -C commit`, `git --no-pager push`, `bun --cwd . add`) and runners (`bunx`, `bun run …`) are seen through. Only a command actually being run counts: text that merely mentions one (`gh pr comment --body "then git push"`, heredoc lines) is not matched. +The same applies to your own session: the Bash tool's cwd can reset to the main checkout between calls (after a session restart, or after a command that `cd`'d outside the repo). A bare `bun add`, `git commit`, or `git push` then lands on main. -The deny message names the worktree and prints the `cd && …` form. A leading `cd &&` or a `git -C ` is honoured as the deliberate choice when `` exists and is inside a git checkout — a linked worktree by any path, the main checkout only by an absolute path (`cd /abs/main && …` is a choice; `cd . && …` and `git -C . …` are not). A `cd` to a path that does not exist or is not a checkout (`cd /typo; git push`, `cd /bad || git commit`) does not bypass the guard. A session that has only ever worked in the main checkout is never touched, so main stays an ordinary place to work. Background Bash calls inherit the session cwd like foreground ones. The hook only denies; it never rewrites a command, because PreToolUse hooks run in parallel and the user-level RTK hook already rewrites Bash commands via `updatedInput`. +The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` whose effective directory (after any earlier `cd`, or its own `git -C` / `bun --cwd`) is the main checkout or cannot be resolved. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. + +Consequences worth knowing: + +- A `cd` earlier in the command moves the effective directory for everything after it; `git -C ` and `bun --cwd ` apply only to their own segment. So `cd && git push` passes, `git -C status && git push` is denied, and `cd && cd .. && git push` is denied. +- A `cd` to a path that does not exist, is not a git checkout, or contains a variable makes the directory unresolvable, and unresolvable means denied. +- `REQUIRE_MAIN_BRANCH` is an architect decision: while the main checkout sits on a feature branch the hook is inert, so a drifted commit lands on that branch. It is one constant in the hook, with a test either way, so it can be flipped in one edit. +- A session that has only ever worked in the main checkout is never touched. Background Bash calls inherit the session cwd like foreground ones. Per-session state lives under the OS temp dir and entries older than a day are pruned on SessionStart. +- The hook only denies; it never rewrites a command, because PreToolUse hooks run in parallel and the user-level RTK hook already rewrites Bash commands via `updatedInput`. ## When you're done with a worktree @@ -102,22 +110,22 @@ The deny message names the worktree and prints the `cd && …` form. bun run worktree:destroy ``` -This reads `WORKTREE_SLOT` from the worktree's `.env.local`, resolves `docker` the same way create does, drops both Postgres databases (`WITH (FORCE)`, so open connections don't block it), removes the MinIO buckets, flushes Redis DB `` on whichever container serves `localhost:6379` (that is what the slot's `REDIS_URL` points at, whether it is `_redis` or another stack's Redis holding the port), kills any processes on the four slot ports, removes the git worktree (falls back to `rm -rf` + `git worktree prune` if needed), and frees the slot. +`` must be a worktree git knows about (or a half-created directory that still has its `.git` file); `.locks`, `.slots`, and arbitrary directories are refused. It reads `WORKTREE_SLOT` from the worktree's `.env.local` (CRLF, quotes, and a leading `export` are tolerated; anything that is not a digit 1–9 is a hard error), refuses if the worktree's files name more than one slot, and — unless another registered worktree also references the slot, in which case it says so and leaves the databases alone — resolves `docker` the same way create does, drops both Postgres databases (`WITH (FORCE)`, so open connections don't block it), removes the MinIO buckets, flushes Redis DB `` on whichever container serves `localhost:6379` (that is what the slot's `REDIS_URL` points at, whether it is `_redis` or another stack's Redis holding the port). Then it kills every listener on the four slot ports (each pid is reported; one that survives is a warning, not a "killed"), removes the registry entry and the git worktree (falls back to `rm -rf` + `git worktree prune` if needed), and frees the slot. If Docker is unreachable at destroy time it says so; the next `worktree:create` that reclaims the slot drops the leftover databases (after confirming no registered worktree still references them). -**Always destroy worktrees you're done with.** If you don't, you'll run out of slots (max 9). `bun run worktree:list` shows what's allocated; `bun run worktree:create` warns about merged-but-not-destroyed worktrees on every run. If the branch is already merged into the main branch, the destroy is safe — your work is in main already. +**Always destroy worktrees you're done with.** If you don't, you'll run out of slots (max 9). `bun run worktree:list` shows what's allocated; `bun run worktree:create` warns about merged-but-not-destroyed worktrees on every run. If the branch is already merged into the default branch, the destroy is safe — your work is in main already. ## Reference files | File | Why read | |---|---| -| `scripts/worktree/create.sh` | Full script — Docker pre-flight, slot lock + allocation, env generation, DB/bucket setup, install + resolution check, generated artifacts, schema push, gap summary | -| `scripts/worktree/destroy.sh` | Cleanup logic — DB drop, bucket removal, Redis flush, port kill, worktree remove | -| `scripts/worktree/lib.sh` | Shared helpers — `main_checkout_root`, `resolve_docker`, `slot_resources`, `worktree_claims_slot`, `ensure_env_var`, `run_step`, `require_artifact` | +| `scripts/worktree/create.sh` | Full script — Docker pre-flight, slot lock + registry + allocation, env generation, DB/bucket setup, install + resolution check, generated artifacts, schema push, gap summary | +| `scripts/worktree/destroy.sh` | Cleanup logic — registration check, slot validation, shared-slot skip, DB drop, bucket removal, Redis flush, port kill, worktree remove | +| `scripts/worktree/lib.sh` | Shared helpers — `main_checkout_root`, `default_branch`, `resolve_docker`, `slot_resources`, `worktree_slots`, `slot_claimant`, `claim_slot_lock`, `register_slot`, `ensure_env_var`, `rewrite_database_url`, `kill_port_listeners`, `run_step`, `require_artifact` | | `scripts/worktree/list.sh` | Output format and how the slot is read from `.env.local` | | `.claude/hooks/bashMainCheckoutGuard.ts` | The PreToolUse / SessionStart hook described above; `.claude/hooks/tests/` has its tests (`__TEST_REDIRECT__=1 bun test ./.claude/hooks/tests/bashMainCheckoutGuard.test.ts`) | -| `scripts/setup/sync-env.sh` | What the env sync appends (keys the branch's examples have that the copied file lacks; never overwrites a value) | +| `scripts/setup/sync-env.sh` | What the env sync appends (keys the branch's examples have that the copied file lacks, whole quoted values included; never overwrites a value) | | `scripts/db/push-dev.sh` | What `db:push:dev` does (worktree-aware, refuses non-local DATABASE_URLs) | ## Common mistakes @@ -125,12 +133,13 @@ If Docker is unreachable at destroy time it says so; the next `worktree:create` | Mistake | Fix | |---|---| | Using `git worktree add` directly | Use `bun run worktree:create` (`--skip-db` for docs-only) — the hook denies the raw command from a drifted main cwd anyway | -| Trusting a green "ready" from the older create | Current script: green means every artifact and push landed; yellow WITH GAPS lists what to rerun | +| Trusting a green "ready" from the older create | Current script: green means every env file sources, every artifact exists, and the push landed; yellow WITH GAPS lists what to rerun | +| `worktree:create main ` for a branch that already exists | That is attach mode: `bun run worktree:create ` | | Editing files in the main repo when you meant to work in a worktree | `cd .worktrees/` first; verify with `git rev-parse --show-toplevel` | | Dispatching a subagent without the worktree path in the prompt | Always include `Work entirely in . Do NOT touch the main repo path.` | | Leaving merged worktrees around | Run `bun run worktree:destroy ` after merge; the script also warns you on every create | | Hardcoding slot ports | Don't — read them from the worktree's `.env.local` | -| Editing `WORKTREE_SLOT=` in `.env.local` | Slot is auto-allocated; don't manually shuffle | +| Editing `WORKTREE_SLOT=` in `.env.local` or `.worktrees/.slots/` | Slot is auto-allocated; don't manually shuffle — a mismatch between the two is a conflict destroy refuses to resolve for you | ## Anti-patterns diff --git a/AI/ENTRYPOINT.md b/AI/ENTRYPOINT.md index eadbb3e88..3998e9800 100644 --- a/AI/ENTRYPOINT.md +++ b/AI/ENTRYPOINT.md @@ -47,7 +47,7 @@ concluding that something is missing — it is usually present and documented. - Do not run git commands unless the user explicitly asks. - **NEVER run `git stash` — for any reason, ever.** Not for "baseline verification", not for "let me test if this is from my change", not for "let me try and see". If you need to compare against HEAD or a clean state: use `git show HEAD:` to read a file at HEAD, `git diff` to see changes, or read the file content directly. Stash has destroyed in-flight work before. - **NEVER use `git -c =` to bypass any rule** — this includes (but is not limited to) bypassing the stash ban, signing requirements, hook execution (`--no-verify`), or any other guardrail. If a command requires bypassing config, stop and ask. -- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy `, `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every artifact and push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`) denies a bare mutating command (`bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate`) from the main checkout once this session has worked in a worktree — name the directory with `cd && …`. See `.claude/skills/worktrees/SKILL.md`. +- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy `, `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` whose effective directory (after any earlier `cd`, or its own `git -C` / `bun --cwd`) is the main checkout or cannot be resolved. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. See `.claude/skills/worktrees/SKILL.md`. - **If something is broken, just fix it.** Don't stash-to-bisect, don't "let me see if reverting my changes makes it go away" — read the code, find the cause, fix the cause. ## 0.1. Understand the Why First diff --git a/docs/claude/SCRIPTS.md b/docs/claude/SCRIPTS.md index c9c8c49d3..8b560c016 100644 --- a/docs/claude/SCRIPTS.md +++ b/docs/claude/SCRIPTS.md @@ -323,7 +323,7 @@ bun run worktree:list bun run worktree:destroy ``` -Each worktree owns a slot (1–9): ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific `.env.local` / `.env.test` synced against the branch's `.env.*.example`, its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. Full guide: `.claude/skills/worktrees/SKILL.md`. +Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s), and drops a leftover slot database only when no registered worktree references that slot. Destroy requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` whose effective directory (after any earlier `cd`, or its own `git -C` / `bun --cwd`) is the main checkout or cannot be resolved. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. --- diff --git a/scripts/setup/sync-env.sh b/scripts/setup/sync-env.sh index 21caf6f70..0140e71d5 100755 --- a/scripts/setup/sync-env.sh +++ b/scripts/setup/sync-env.sh @@ -1,10 +1,19 @@ #!/bin/bash set -e -# Sync env file with example - creates if missing, adds missing keys +example_entry() { + local example_file="$1" key="$2" + awk -v key="$key" ' + function unbalanced(s) { n = gsub(/"/, "\"", s); return n % 2 == 1 } + !printing && index($0, key "=") == 1 { printing = 1; print; if (unbalanced($0)) open = 1; else exit; next } + printing && open { print; if (unbalanced($0)) exit } + ' "$example_file" +} + sync_env() { local env_file="$1" local example_file="$2" + local line key in_quoted=0 [ ! -f "$example_file" ] && return 0 @@ -14,24 +23,26 @@ sync_env() { return 0 fi - # Add missing keys while IFS= read -r line || [ -n "$line" ]; do + if [ "$in_quoted" -eq 1 ]; then + [ "$(tr -cd '"' <<< "$line" | wc -c)" -eq 1 ] && in_quoted=0 + continue + fi [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)= ]]; then key="${BASH_REMATCH[1]}" + [ $(( $(tr -cd '"' <<< "$line" | wc -c) % 2 )) -eq 1 ] && in_quoted=1 if ! grep -q "^${key}=" "$env_file"; then - grep "^${key}=" "$example_file" >> "$env_file" + example_entry "$example_file" "$key" >> "$env_file" echo "Added $key to $env_file" fi fi done < "$example_file" } -# Root env files sync_env ".env.local" ".env.local.example" sync_env ".env.test" ".env.test.example" -# App env files for app in api web admin superadmin; do app_dir="apps/$app" [ -d "$app_dir" ] || continue diff --git a/scripts/worktree/create.sh b/scripts/worktree/create.sh index 9af2c7477..529a9cc64 100755 --- a/scripts/worktree/create.sh +++ b/scripts/worktree/create.sh @@ -65,8 +65,12 @@ if [ "$ATTACH_ONLY" -eq 1 ]; then echo -e "${RED}Error: Branch '$NEW_BRANCH' not found (local or remote).${NC}" die "To create a new branch, pass a base too: $0 $NEW_BRANCH" fi -elif ! branch_exists "$BASE_BRANCH"; then - die "Error: Base branch '$BASE_BRANCH' not found (local or remote)" +else + branch_exists "$BASE_BRANCH" || die "Error: Base branch '$BASE_BRANCH' not found (local or remote)" + if branch_exists "$NEW_BRANCH"; then + echo -e "${RED}Error: Branch '$NEW_BRANCH' already exists, so it cannot be forked from '$BASE_BRANCH'.${NC}" + die "Attach it as it is with: $0 $NEW_BRANCH — or pick a new branch name." + fi fi MAIN_ENV="$ROOT_DIR/.env.local" @@ -107,8 +111,11 @@ fi info "Scanning worktrees for allocated slots..." -MAIN_BRANCH="$(git -C "$ROOT_DIR" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' || echo main)" -MERGED_BRANCHES="$(git -C "$ROOT_DIR" branch --merged "$MAIN_BRANCH" 2>/dev/null | sed 's/^[* ]*//' || true)" +MAIN_BRANCH="$(default_branch "$ROOT_DIR")" +MAIN_BRANCH="${MAIN_BRANCH:-main}" +MERGED_BRANCHES="$(git -C "$ROOT_DIR" branch --merged "$MAIN_BRANCH" 2>/dev/null | sed 's/^[*+ ]*//' || true)" + +prune_slot_registry USED_SLOTS=" " MERGED_WARNINGS="" @@ -117,8 +124,11 @@ MERGED_COUNT=0 while IFS= read -r wt_path; do [ -n "$wt_path" ] && [ -d "$wt_path" ] || continue wt_branch="$(git -C "$wt_path" branch --show-current 2>/dev/null || true)" - for i in $(seq 1 9); do - worktree_claims_slot "$wt_path" "$i" || continue + wt_slots="$(worktree_slots "$wt_path" | tr '\n' ' ')" + if [ "$(echo "$wt_slots" | wc -w | tr -d ' ')" -gt 1 ]; then + warn "$(basename "$wt_path") references more than one slot (${wt_slots}) — reserving all of them. Fix its env files so they agree." + fi + for i in $wt_slots; do USED_SLOTS="${USED_SLOTS}${i} " if [ -n "$wt_branch" ] && echo "$MERGED_BRANCHES" | grep -qx "$wt_branch"; then MERGED_WARNINGS="${MERGED_WARNINGS} Slot ${i}: $(basename "$wt_path") (${wt_branch}) — bun run worktree:destroy $(basename "$wt_path")\n" @@ -132,23 +142,10 @@ if [ "$MERGED_COUNT" -gt 0 ]; then warn "$MERGED_WARNINGS" fi -LOCK_ROOT="$ROOT_DIR/.worktrees/.locks" -mkdir -p "$LOCK_ROOT" SLOT="" SLOT_LOCK="" -release_slot_lock() { [ -n "$SLOT_LOCK" ] && rm -rf "$SLOT_LOCK"; } trap release_slot_lock EXIT -claim_slot_lock() { - local lock="$LOCK_ROOT/slot-$1" - if [ -d "$lock" ] && [ -f "$lock/pid" ] && ! kill -0 "$(cat "$lock/pid")" 2>/dev/null; then - rm -rf "$lock" - fi - mkdir "$lock" 2>/dev/null || return 1 - echo $$ > "$lock/pid" - SLOT_LOCK="$lock" -} - for i in $(seq 1 9); do echo "$USED_SLOTS" | grep -q " ${i} " && continue if claim_slot_lock "$i"; then @@ -179,16 +176,15 @@ elif [ "$ATTACH_ONLY" -eq 1 ]; then else git -C "$ROOT_DIR" worktree add -b "$NEW_BRANCH" "$WORKTREE_DIR" "$BASE_BRANCH" fi +register_slot "$SLOT" "$WORKTREE_DIR" info "Generating env files (root values, the branch's keys, slot $SLOT ports/DBs/buckets)..." WT_ENV="$WORKTREE_DIR/.env.local" WT_TEST_ENV="$WORKTREE_DIR/.env.test" -{ echo "WORKTREE_SLOT=$SLOT"; cat "$MAIN_ENV"; } > "$WT_ENV" -if [ -f "$ROOT_DIR/.env.test" ]; then - { echo "WORKTREE_SLOT=$SLOT"; cat "$ROOT_DIR/.env.test"; } > "$WT_TEST_ENV" -fi +cp "$MAIN_ENV" "$WT_ENV" +[ -f "$ROOT_DIR/.env.test" ] && cp "$ROOT_DIR/.env.test" "$WT_TEST_ENV" for src in "$ROOT_DIR"/apps/*/.env.local "$ROOT_DIR"/apps/*/.env.test; do [ -f "$src" ] || continue dst="$WORKTREE_DIR/${src#"$ROOT_DIR"/}" @@ -204,7 +200,9 @@ if ! SYNC_OUT="$(cd "$WORKTREE_DIR" && bash "$SYNC_ENV" 2>&1)"; then fi [ -n "$SYNC_OUT" ] && echo "$SYNC_OUT" | sed 's/^/ /' -ensure_env_var "$WT_ENV" WORKTREE_SLOT "$SLOT" +prepend_slot_marker "$WT_ENV" "$SLOT" +prepend_slot_marker "$WT_TEST_ENV" "$SLOT" + ensure_env_var "$WT_ENV" PORT "$API_PORT" ensure_env_var "$WT_ENV" API_URL "http://localhost:${API_PORT}" ensure_env_var "$WT_ENV" WEB_URL "http://localhost:${WEB_PORT}" @@ -213,38 +211,46 @@ ensure_env_var "$WT_ENV" SUPERADMIN_URL "http://localhost:${SUPERADMIN_PORT}" replace_env_var_if_present "$WT_ENV" BETTER_AUTH_BASE_URL "http://localhost:${API_PORT}" ensure_env_var "$WT_ENV" STORAGE_BUCKET_SYSTEM "$STORAGE_BUCKET_SYSTEM" ensure_env_var "$WT_ENV" STORAGE_BUCKET_USER "$STORAGE_BUCKET_USER" -rewrite_database_url "$WT_ENV" "$DB_LOCAL" +rewrite_database_url "$WT_ENV" "$DB_LOCAL" || die "Error: DATABASE_URL in $WT_ENV could not be pointed at $DB_LOCAL." rewrite_redis_db "$WT_ENV" "$REDIS_DB" if [ -f "$WT_TEST_ENV" ]; then - ensure_env_var "$WT_TEST_ENV" WORKTREE_SLOT "$SLOT" ensure_env_var "$WT_TEST_ENV" STORAGE_BUCKET_SYSTEM "$STORAGE_BUCKET_SYSTEM_TEST" ensure_env_var "$WT_TEST_ENV" STORAGE_BUCKET_USER "$STORAGE_BUCKET_USER_TEST" replace_env_var_if_present "$WT_TEST_ENV" API_URL "http://localhost:${API_PORT}" replace_env_var_if_present "$WT_TEST_ENV" WEB_URL "http://localhost:${WEB_PORT}" replace_env_var_if_present "$WT_TEST_ENV" ADMIN_URL "http://localhost:${ADMIN_PORT}" replace_env_var_if_present "$WT_TEST_ENV" SUPERADMIN_URL "http://localhost:${SUPERADMIN_PORT}" - rewrite_database_url "$WT_TEST_ENV" "$DB_TEST" + rewrite_database_url "$WT_TEST_ENV" "$DB_TEST" || die "Error: DATABASE_URL in $WT_TEST_ENV could not be pointed at $DB_TEST." rewrite_redis_db "$WT_TEST_ENV" "$REDIS_DB" else warn "Warning: neither root .env.test nor a .env.test.example on this branch — .env.test not generated." fi for f in "$WORKTREE_DIR"/apps/*/.env.local; do + [ -f "$f" ] || continue replace_env_var_if_present "$f" PORT "$API_PORT" replace_env_var_if_present "$f" API_URL "http://localhost:${API_PORT}" replace_env_var_if_present "$f" WEB_URL "http://localhost:${WEB_PORT}" replace_env_var_if_present "$f" ADMIN_URL "http://localhost:${ADMIN_PORT}" replace_env_var_if_present "$f" SUPERADMIN_URL "http://localhost:${SUPERADMIN_PORT}" - rewrite_database_url "$f" "$DB_LOCAL" + rewrite_database_url "$f" "$DB_LOCAL" || die "Error: DATABASE_URL in $f could not be pointed at $DB_LOCAL." rewrite_redis_db "$f" "$REDIS_DB" done for f in "$WORKTREE_DIR"/apps/*/.env.test; do + [ -f "$f" ] || continue replace_env_var_if_present "$f" API_URL "http://localhost:${API_PORT}" - rewrite_database_url "$f" "$DB_TEST" + rewrite_database_url "$f" "$DB_TEST" || die "Error: DATABASE_URL in $f could not be pointed at $DB_TEST." rewrite_redis_db "$f" "$REDIS_DB" done +GAPS="" + +while IFS= read -r f; do + [ -n "$f" ] || continue + env_file_sources "$f" || GAPS="${GAPS} - ${f#"$WORKTREE_DIR"/} does not source cleanly (unterminated quote or bad line) — fix it by hand, then compare with its .example\n" +done < <(worktree_env_files "$WORKTREE_DIR") + if [ "$DB_AVAILABLE" -eq 1 ]; then info "Creating Postgres databases on $PG_CONTAINER..." for DB in "$DB_LOCAL" "$DB_TEST"; do @@ -280,8 +286,6 @@ case "$DB_PKG_REAL" in Run 'bun install --force' in $WORKTREE_DIR and check 'readlink apps/api/node_modules/@template/db'." ;; esac -GAPS="" - run_step "Generating route trees" bun run generate:routes || true run_step "Generating Prisma client, prismaMap, and zod schemas" bun run db:generate || true run_step "Generating OpenAPI spec, SDK, and MSW handlers" bun run generate:sdk || true diff --git a/scripts/worktree/destroy.sh b/scripts/worktree/destroy.sh index 156188b3f..7369cece1 100755 --- a/scripts/worktree/destroy.sh +++ b/scripts/worktree/destroy.sh @@ -18,8 +18,8 @@ if [ -z "$NAME" ]; then exit 1 fi -if echo "$NAME" | grep -qE '(\.\.|/)'; then - die "Error: Name cannot contain '..' or path separators" +if echo "$NAME" | grep -qE '(^\.|\.\.|/)'; then + die "Error: Name cannot start with '.' or contain '..' or path separators" fi WORKTREE_DIR="$ROOT_DIR/.worktrees/$NAME" @@ -27,17 +27,42 @@ if [ ! -d "$WORKTREE_DIR" ]; then die "Error: Worktree '$NAME' not found at $WORKTREE_DIR" fi -SLOT="$(env_value "$WORKTREE_DIR/.env.local" WORKTREE_SLOT)" -[ -n "$SLOT" ] || SLOT="$(env_value "$WORKTREE_DIR/.env.test" WORKTREE_SLOT)" +if is_registered_worktree "$WORKTREE_DIR"; then + REGISTERED=1 +elif [ -f "$WORKTREE_DIR/.git" ]; then + REGISTERED=0 + warn "Warning: $WORKTREE_DIR is not registered with git (half-created?) — removing the directory and pruning." +else + die "Error: $WORKTREE_DIR is not a git worktree (no .git file, not registered). Refusing to delete it." +fi + +SLOT="$(worktree_marker_slot "$WORKTREE_DIR")" +CLAIMED_SLOTS="$(worktree_slots "$WORKTREE_DIR" | tr '\n' ' ')" +CLAIMED_COUNT="$(echo "$CLAIMED_SLOTS" | wc -w | tr -d ' ')" +if [ "$CLAIMED_COUNT" -gt 1 ]; then + die "Error: $NAME references more than one slot (marker WORKTREE_SLOT='${SLOT:-none}', env files/registry name slots: ${CLAIMED_SLOTS}). +Refusing to guess which databases are its own. Fix the env files so they agree on one slot, then re-run." +fi +[ -n "$SLOT" ] || SLOT="$(echo "$CLAIMED_SLOTS" | tr -d ' ')" if [ -z "$SLOT" ]; then warn "Warning: No WORKTREE_SLOT found in $WORKTREE_DIR/.env.local" warn "Proceeding with worktree removal only (no DB / bucket / Redis / port cleanup)" else + valid_slot "$SLOT" || die "Error: WORKTREE_SLOT='$SLOT' in $WORKTREE_DIR/.env.local is not a slot number (1-9). Fix the file, then re-run." slot_resources "$SLOT" + if CLAIMANT="$(slot_claimant "$SLOT" "$WORKTREE_DIR")"; then + warn "Warning: $(basename "$CLAIMANT") also references slot $SLOT — leaving $DB_LOCAL / $DB_TEST, the buckets, and Redis DB $SLOT in place." + SHARED_SLOT=1 + else + SHARED_SLOT=0 + fi + DOCKER="$(resolve_docker || true)" - if [ -z "$DOCKER" ] || ! "$DOCKER" info >/dev/null 2>&1; then + if [ "$SHARED_SLOT" -eq 1 ]; then + : + elif [ -z "$DOCKER" ] || ! "$DOCKER" info >/dev/null 2>&1; then warn "Warning: Docker is not reachable — slot $SLOT databases, buckets, and Redis DB are NOT cleaned up." warn "Start Docker and drop $DB_LOCAL / $DB_TEST by hand, or worktree:create will drop them when it reclaims slot $SLOT." else @@ -80,22 +105,25 @@ else PORTS="$(slot_ports "$SLOT")" info "Killing processes on ports: ${PORTS}..." for PORT in $PORTS; do - PID=$(lsof -ti:"$PORT" 2>/dev/null || true) - if [ -n "$PID" ]; then - kill -9 "$PID" 2>/dev/null || true - echo " Killed PID $PID on port $PORT" - fi + kill_port_listeners "$PORT" done + unregister_slot "$SLOT" "$WORKTREE_DIR" fi info "Removing git worktree..." -git -C "$ROOT_DIR" worktree remove "$WORKTREE_DIR" --force 2>/dev/null || { - warn "git worktree remove failed, cleaning up manually..." +if [ "$REGISTERED" -eq 1 ] && git -C "$ROOT_DIR" worktree remove "$WORKTREE_DIR" --force 2>/dev/null; then + : +else + [ "$REGISTERED" -eq 1 ] && warn "git worktree remove failed, cleaning up manually..." rm -rf "$WORKTREE_DIR" git -C "$ROOT_DIR" worktree prune -} +fi echo ok "Worktree '$NAME' destroyed" -[ -n "$SLOT" ] && ok "Slot $SLOT freed" +if [ -n "$SLOT" ] && [ "${SHARED_SLOT:-0}" -eq 1 ]; then + warn "Slot $SLOT stays with $(basename "$CLAIMANT")" +elif [ -n "$SLOT" ]; then + ok "Slot $SLOT freed" +fi echo diff --git a/scripts/worktree/lib.sh b/scripts/worktree/lib.sh index 25965eb96..49efa9a68 100755 --- a/scripts/worktree/lib.sh +++ b/scripts/worktree/lib.sh @@ -22,14 +22,28 @@ main_checkout_root() { dirname "$common" } +default_branch() { + git -C "$1" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' || true +} + env_value() { - grep -m1 "^${2}=" "$1" 2>/dev/null | cut -d= -f2- || true + local line value + line="$(grep -m1 -E "^(export[[:space:]]+)?${2}=" "$1" 2>/dev/null | tr -d '\r' || true)" + [ -n "$line" ] || return 0 + value="${line#*=}" + value="${value#\"}"; value="${value%\"}" + value="${value#\'}"; value="${value%\'}" + printf '%s\n' "$value" } project_name() { local name="" [ -f "$1/.env" ] && name="$(env_value "$1/.env" PROJECT_NAME)" - echo "${name:-$(basename "$1")}" + echo "${name:-template}" +} + +valid_slot() { + [[ "$1" =~ ^[1-9]$ ]] } resolve_docker() { @@ -85,10 +99,19 @@ replace_env_var_if_present() { sedi "s|^${key}=.*|${key}=${value}|" "$file" } +prepend_slot_marker() { + local file="$1" slot="$2" tmp + [ -f "$file" ] || return 0 + tmp="$(mktemp)" + { echo "WORKTREE_SLOT=${slot}"; grep -vE '^(export[[:space:]]+)?WORKTREE_SLOT=' "$file" || true; } > "$tmp" + mv "$tmp" "$file" +} + rewrite_database_url() { local file="$1" db="$2" [ -f "$file" ] && grep -q '^DATABASE_URL=' "$file" || return 0 - sedi -E "s|^(DATABASE_URL=[a-z]+://[^@]+@[^/]+/)[^?[:space:]]*|\1${db}|" "$file" + sedi -E "s|^(DATABASE_URL=[a-z]+://([^@/[:space:]]+@)?[^/[:space:]]+/)[^?[:space:]]*|\1${db}|" "$file" + grep -Eq "^DATABASE_URL=[a-z]+://([^@/[:space:]]+@)?[^/[:space:]]+/${db}([?[:space:]]|\$)" "$file" } rewrite_redis_db() { @@ -97,10 +120,18 @@ rewrite_redis_db() { sedi -E "s#^(REDIS(_QUEUE)?_URL=redis://([^@/]*@)?(localhost|127\.0\.0\.1):[0-9]+)(/[0-9]+)?[[:space:]]*\$#\1/${n}#" "$file" } +env_file_sources() { + bash -c 'set -a; . "$1"' _ "$1" >/dev/null 2>&1 +} + linked_worktrees() { git -C "$ROOT_DIR" worktree list --porcelain | sed -n 's/^worktree //p' | grep -vx "$ROOT_DIR" || true } +is_registered_worktree() { + linked_worktrees | grep -qx "$1" +} + worktree_env_files() { local f for f in "$1/.env.local" "$1/.env.test" "$1"/apps/*/.env.local "$1"/apps/*/.env.test; do @@ -109,11 +140,60 @@ worktree_env_files() { return 0 } +slots_dir() { + echo "$ROOT_DIR/.worktrees/.slots" +} + +registry_owner() { + local f + f="$(slots_dir)/$1" + [ -f "$f" ] && head -1 "$f" | tr -d '\r' || true +} + +register_slot() { + mkdir -p "$(slots_dir)" + printf '%s\n' "$2" > "$(slots_dir)/$1" +} + +unregister_slot() { + [ "$(registry_owner "$1")" = "$2" ] && rm -f "$(slots_dir)/$1" + return 0 +} + +prune_slot_registry() { + local f slot owner + for f in "$(slots_dir)"/*; do + [ -f "$f" ] || continue + slot="$(basename "$f")" + owner="$(registry_owner "$slot")" + if ! valid_slot "$slot" || [ -z "$owner" ] || ! is_registered_worktree "$owner"; then + warn "Slot registry entry $slot (${owner:-empty}) points at no registered worktree — removing it." + rm -f "$f" + fi + done +} + +worktree_marker_slot() { + local slot + slot="$(env_value "$1/.env.local" WORKTREE_SLOT)" + [ -n "$slot" ] || slot="$(env_value "$1/.env.test" WORKTREE_SLOT)" + echo "$slot" +} + +worktree_slots() { + local wt="$1" i + for i in $(seq 1 9); do + worktree_claims_slot "$wt" "$i" && echo "$i" + done + return 0 +} + worktree_claims_slot() { local wt="$1" slot="$2" f + [ "$(registry_owner "$slot")" = "$wt" ] && return 0 while IFS= read -r f; do [ -n "$f" ] || continue - grep -q "^WORKTREE_SLOT=${slot}\$" "$f" && return 0 + [ "$(env_value "$f" WORKTREE_SLOT)" = "$slot" ] && return 0 grep -Eq "${PROJECT_NAME}_(test_)?wt_${slot}([^0-9]|\$)" "$f" && return 0 done < <(worktree_env_files "$wt") return 1 @@ -131,6 +211,61 @@ slot_claimant() { return 1 } +kill_port_listeners() { + local port="$1" pid killed=0 survivors="" + while IFS= read -r pid; do + [ -n "$pid" ] || continue + kill -9 "$pid" 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + kill -0 "$pid" 2>/dev/null || break + sleep 0.1 + done + if kill -0 "$pid" 2>/dev/null; then + survivors="${survivors} ${pid}" + else + echo " Killed PID $pid on port $port" + killed=$((killed + 1)) + fi + done < <(lsof -ti:"$port" 2>/dev/null || true) + [ -z "$survivors" ] || warn " Warning: could not kill PID(s)${survivors} on port $port" +} + +STALE_LOCK_SECONDS="${STALE_LOCK_SECONDS:-60}" + +lock_root() { + echo "$ROOT_DIR/.worktrees/.locks" +} + +lock_is_stale() { + local lock="$1" pid age + if [ -f "$lock/pid" ]; then + pid="$(tr -d '\r\n' < "$lock/pid")" + [[ "$pid" =~ ^[0-9]+$ ]] || return 0 + ! kill -0 "$pid" 2>/dev/null + return + fi + age=$(( $(date +%s) - $(stat -f %m "$lock" 2>/dev/null || stat -c %Y "$lock" 2>/dev/null || date +%s) )) + [ "$age" -gt "$STALE_LOCK_SECONDS" ] +} + +claim_slot_lock() { + local lock + lock="$(lock_root)/slot-$1" + mkdir -p "$(lock_root)" + if [ -d "$lock" ] && lock_is_stale "$lock"; then + warn "Slot $1 has a lock left by a create that is no longer running — reclaiming it." + rm -rf "$lock" + fi + mkdir "$lock" 2>/dev/null || return 1 + echo $$ > "$lock/pid" + SLOT_LOCK="$lock" +} + +release_slot_lock() { + [ -n "${SLOT_LOCK:-}" ] && rm -rf "$SLOT_LOCK" + return 0 +} + run_step() { local label="$1" shift From bc0bc7b982e1d55a4ac5d9c83939ea041eeb1f3d Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Wed, 23 Sep 2026 01:14:38 -0300 Subject: [PATCH 3/8] =?UTF-8?q?chore(worktrees):=20adversarial=20round=20o?= =?UTF-8?q?ne=20addendum=20=E2=80=94=20realpath=20lift=20rule,=20bun=20ali?= =?UTF-8?q?ases,=20hardened=20env=20copy=20and=20destroy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook: bun a/rm/install /i and every --cwd shape of db:migrate are mutating; every cd / -C / --cwd target goes through realpath; a mutation is lifted only inside a worktree that git worktree list registers or in this main checkout named by its own absolute path (cd and -C alike); symlinks to main, relative paths into main, unregistered or foreign checkouts, and non-checkout directories never lift; glued separators tokenize. Test sandbox is a real git repo. Scripts: WORKTREE_SLOT stripped from copied root env files; main excluded from the claimant scan by realpath; sed replacements escaped and DATABASE_URL rewrite tolerant of & | / in the password; database inventory failure aborts; --skip-db needs neither credentials nor Docker; required env keys die by name; destroy prints merge status and refuses a dirty worktree unless --force. Docs carry the identical deny sentence and the destroy flag. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/bashMainCheckoutGuard.ts | 146 ++++++++---- .../hooks/tests/bashMainCheckoutGuard.test.ts | 209 ++++++++++++++---- .claude/skills/worktrees/SKILL.md | 16 +- AI/ENTRYPOINT.md | 2 +- docs/claude/SCRIPTS.md | 4 +- scripts/worktree/create.sh | 15 +- scripts/worktree/destroy.sh | 30 ++- scripts/worktree/lib.sh | 60 ++++- 8 files changed, 372 insertions(+), 110 deletions(-) diff --git a/.claude/hooks/bashMainCheckoutGuard.ts b/.claude/hooks/bashMainCheckoutGuard.ts index 81937e608..ab33d6916 100644 --- a/.claude/hooks/bashMainCheckoutGuard.ts +++ b/.claude/hooks/bashMainCheckoutGuard.ts @@ -1,4 +1,14 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; @@ -12,7 +22,7 @@ type HookInput = { export const REQUIRE_MAIN_BRANCH = true; -const MASK = '\u0001'; +const MASK = '�'; const SEPARATOR_AT = /^(?:\r?\n|;|&&|\|\||\||&|\(|\)|\{|\})/; const HEREDOC_AT = /^<<(-?)\s*(['"]?)(\w+)\2/; const LEADING_KEYWORD = /^(?:if|then|else|elif|fi|do|done|while|until|!)(?=\s|$)\s*/; @@ -43,7 +53,8 @@ const GIT_WORKTREE_MUTATING = new Set(['add', 'remove', 'move', 'prune']); const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']); const GIT_RESUMED = /\s--(?:abort|continue|quit|skip)(?=\s|$)/; const DRY_RUN = /\s--dry-run(?=\s|$)/; -const BUN_MUTATING = new Set(['add', 'remove', 'update']); +const BUN_MUTATING = new Set(['add', 'a', 'remove', 'rm', 'update']); +const BUN_INSTALL = new Set(['install', 'i']); const BUN_SCRIPTS = new Set(['db:migrate']); const BUN_OPTIONS_WITH_VALUE = new Set([ '--cwd', @@ -175,13 +186,23 @@ const findCheckout = (from: string): Checkout | undefined => { } }; -const resolveDirectory = (rawToken: string, from: string | undefined): string | undefined => { +const isDirectory = (path: string) => existsSync(path) && statSync(path).isDirectory(); + +const isWithin = (path: string, root: string) => path === root || path.startsWith(`${root}/`); + +type Target = { directory: string; named?: string }; + +const resolveTarget = (rawToken: string, from: Target | undefined): Target | undefined => { const token = unquote(rawToken); if (!token || /[$`*?]/.test(token) || token === '-') return undefined; const expanded = token === '~' || token.startsWith('~/') ? join(homedir(), token.slice(1)) : token; - if (!isAbsolute(expanded) && !from) return undefined; - const path = isAbsolute(expanded) ? expanded : resolve(from as string, expanded); - return existsSync(path) && statSync(path).isDirectory() ? path : undefined; + if (isAbsolute(expanded)) { + const named = resolve(expanded); + return isDirectory(named) ? { directory: realpathSync(named), named } : undefined; + } + if (!from) return undefined; + const path = resolve(from.directory, expanded); + return isDirectory(path) ? { directory: realpathSync(path) } : undefined; }; type Mutation = { command: string; target?: string }; @@ -204,6 +225,28 @@ const parseInvocation = ( return { subcommand: words[i], rest: words.slice(i + 1), target }; }; +const isPrismaMigrateDev = (subcommand: string | undefined, rest: string[]) => + subcommand === 'prisma' && PRISMA_MIGRATE_DEV.test(['prisma', ...rest].join(' ')); + +const bunMutation = (segment: Segment, words: string[]): Mutation | undefined => { + const { masked } = segment; + const outer = parseInvocation(words, BUN_OPTIONS_WITH_VALUE, '--cwd'); + if (!outer.subcommand) return undefined; + const outerTarget = rawWord(segment, outer.target); + if (BUN_MUTATING.has(outer.subcommand)) return { command: masked, target: outerTarget }; + if (BUN_INSTALL.has(outer.subcommand) && outer.rest.some((word) => !word.startsWith('-'))) + return { command: masked, target: outerTarget }; + if (BUN_SCRIPTS.has(outer.subcommand) || isPrismaMigrateDev(outer.subcommand, outer.rest)) + return { command: masked, target: outerTarget }; + if (outer.subcommand !== 'run') return undefined; + const inner = parseInvocation(['run', ...outer.rest], BUN_OPTIONS_WITH_VALUE, '--cwd'); + const target = rawWord(segment, inner.target ?? outer.target); + if (!inner.subcommand) return undefined; + if (BUN_SCRIPTS.has(inner.subcommand) || isPrismaMigrateDev(inner.subcommand, inner.rest)) + return { command: masked, target }; + return undefined; +}; + const mutationIn = (segment: Segment): Mutation | undefined => { const { masked } = segment; const padded = ` ${masked}`; @@ -220,27 +263,15 @@ const mutationIn = (segment: Segment): Mutation | undefined => { if (subcommand === 'worktree' && GIT_WORKTREE_MUTATING.has(rest[0])) return { command: masked, target }; return undefined; } - if (words[0] === 'bun') { - const outer = parseInvocation(words, BUN_OPTIONS_WITH_VALUE, '--cwd'); - if (!outer.subcommand) return undefined; - if (BUN_MUTATING.has(outer.subcommand)) return { command: masked, target: rawWord(segment, outer.target) }; - if (outer.subcommand !== 'run') return undefined; - const inner = parseInvocation(['run', ...outer.rest], BUN_OPTIONS_WITH_VALUE, '--cwd'); - const target = rawWord(segment, inner.target ?? outer.target); - if (!inner.subcommand) return undefined; - if (BUN_SCRIPTS.has(inner.subcommand)) return { command: masked, target }; - if (inner.subcommand === 'prisma' && PRISMA_MIGRATE_DEV.test(['prisma', ...inner.rest].join(' '))) - return { command: masked, target }; - return undefined; - } + if (words[0] === 'bun') return bunMutation(segment, words); if (PRISMA_MIGRATE_DEV.test(masked)) return { command: masked }; return undefined; }; -export type Landing = { command: string; directory: string | undefined; namedTarget?: string }; +export type Landing = { command: string; directory?: string; named?: string; namedTarget?: string }; -const collectMutations = (command: string, cwd: string | undefined, landings: Landing[]) => { - let cursor: string | undefined = cwd; +const collectMutations = (command: string, cwd: Target | undefined, landings: Landing[]) => { + let cursor: Target | undefined = cwd; for (const segment of segmentCommand(command)) { stripPrefixes(segment); if (!segment.masked) continue; @@ -251,7 +282,7 @@ const collectMutations = (command: string, cwd: string | undefined, landings: La continue; } const token = segment.masked.match(/^\S+/)?.[0]; - cursor = resolveDirectory(token ? (rawWord(segment, token) ?? token) : '~', cursor); + cursor = resolveTarget(token ? (rawWord(segment, token) ?? token) : '~', cursor); continue; } if (take(segment, SHELL_C)) { @@ -265,14 +296,19 @@ const collectMutations = (command: string, cwd: string | undefined, landings: La } const mutation = mutationIn(segment); if (!mutation) continue; - const directory = mutation.target ? resolveDirectory(mutation.target, cursor) : cursor; - landings.push({ command: mutation.command, directory, namedTarget: mutation.target }); + const target = mutation.target ? resolveTarget(mutation.target, cursor) : cursor; + landings.push({ + command: mutation.command, + directory: target?.directory, + named: target?.named, + namedTarget: mutation.target, + }); } }; export const mutationsIn = (command: string, cwd?: string): Landing[] => { const landings: Landing[] = []; - collectMutations(command, cwd, landings); + collectMutations(command, cwd && isDirectory(cwd) ? { directory: realpathSync(cwd) } : undefined, landings); return landings; }; @@ -291,6 +327,25 @@ export const defaultBranch = (root: string): string => { return ref.startsWith(prefix) ? ref.slice(prefix.length) : 'main'; }; +export const registeredWorktrees = (mainRoot: string): string[] => { + try { + const listing = execFileSync('git', ['-C', mainRoot, 'worktree', 'list', '--porcelain'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const mainReal = realpathSync(mainRoot); + return listing + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => line.slice('worktree '.length).trim()) + .filter((path) => isDirectory(path)) + .map((path) => realpathSync(path)) + .filter((path) => path !== mainReal); + } catch { + return []; + } +}; + export type SessionState = { lastWorktree: (sessionId: string) => string | undefined; rememberWorktree: (sessionId: string, worktreeRoot: string) => void; @@ -319,16 +374,25 @@ export const fileSessionState = (dir = join(tmpdir(), 'template-worktree-guard') export type GuardOptions = { requireMainBranch?: boolean }; -const landsOnMain = ({ directory }: Landing) => { - if (!directory) return true; - const target = findCheckout(directory); - return !target || target.isMain; -}; +type MainCheckout = { root: string; real: string }; -const describe = ({ command, directory, namedTarget }: Landing) => { - if (directory) return ` \`${command}\` would run in ${directory}, which is the main checkout.`; - if (namedTarget) return ` \`${command}\` names ${namedTarget}, which does not exist or cannot be resolved.`; - return ` \`${command}\` runs in a directory this hook cannot resolve.`; +const denialFor = (landing: Landing, main: MainCheckout, registered: string[]): string | undefined => { + const { command, directory, named, namedTarget } = landing; + if (!directory) { + return namedTarget + ? ` \`${command}\` names ${namedTarget}, which does not exist or cannot be resolved.` + : ` \`${command}\` runs in a directory this hook cannot resolve.`; + } + if (registered.some((root) => isWithin(directory, root))) return undefined; + const checkout = findCheckout(directory); + if (!checkout) return ` \`${command}\` would run in ${directory}, which is not a git checkout.`; + if (checkout.isMain && isWithin(directory, main.real)) { + if (named && (isWithin(named, main.root) || isWithin(named, main.real))) return undefined; + return named + ? ` \`${command}\` would run in the main checkout through ${named}, which is not its own absolute path.` + : ` \`${command}\` would run in ${directory}, the main checkout, reached by a relative path.`; + } + return ` \`${command}\` would run in ${directory}, which is neither this repository's main checkout nor one of its registered worktrees.`; }; export const guard = (input: HookInput, state: SessionState, options: GuardOptions = {}): string | undefined => { @@ -349,15 +413,19 @@ export const guard = (input: HookInput, state: SessionState, options: GuardOptio const branch = currentBranch(checkout.root); if (requireMainBranch && branch !== defaultBranch(checkout.root)) return undefined; - const landings = mutationsIn(command, input.cwd).filter(landsOnMain); + const landings = mutationsIn(command, input.cwd); if (landings.length === 0) return undefined; + const main = { root: checkout.root, real: realpathSync(checkout.root) }; + const registered = registeredWorktrees(checkout.root); + const denials = landings.map((landing) => denialFor(landing, main, registered)).filter((line) => line); + if (denials.length === 0) return undefined; return [ `This session has been working in the worktree ${worktree}, but the Bash cwd is now the MAIN checkout (${checkout.root}, branch ${branch}) — the session cwd resets to main between calls.`, - ...landings.map(describe), + ...denials, 'Dependency changes, commits, pushes, merges, worktree registry changes, and new migrations would land on main. Run it from the worktree:', ` cd ${worktree} && ${command}`, - `If the main checkout really is the target, check out a working branch there first (it is on ${branch}) or run the command outside this session.`, + `If the main checkout really is the target, name it by its own absolute path: cd ${checkout.root} && ${command}`, ].join('\n'); }; diff --git a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts index 3f9198969..f1df5495c 100644 --- a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts +++ b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; -import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { @@ -9,6 +9,7 @@ import { isMutating, mutationsIn, REQUIRE_MAIN_BRANCH, + registeredWorktrees, type SessionState, } from '../bashMainCheckoutGuard'; @@ -18,10 +19,31 @@ let sandbox: string; let mainRoot: string; let worktreeRoot: string; let plainDir: string; +let foreignRoot: string; +let unregisteredRoot: string; +let linkToMain: string; +let linkToWorktree: string; let state: SessionState; const SESSION = 'session-a'; +const git = (...args: string[]) => { + const result = Bun.spawnSync(['git', ...args], { + stdout: 'pipe', + stderr: 'pipe', + env: { + ...process.env, + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_SYSTEM: '/dev/null', + GIT_AUTHOR_NAME: 'guard-test', + GIT_AUTHOR_EMAIL: 'guard@test', + GIT_COMMITTER_NAME: 'guard-test', + GIT_COMMITTER_EMAIL: 'guard@test', + }, + }); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); +}; + const bash = (command: string, cwd: string, sessionId = SESSION) => ({ hook_event_name: 'PreToolUse', session_id: sessionId, @@ -34,6 +56,8 @@ const visitWorktree = (sessionId = SESSION) => guard(bash('ls', worktreeRoot, se const fromMain = (command: string) => guard(bash(command, mainRoot), state); +const directories = (command: string) => mutationsIn(command, mainRoot).map((landing) => landing.directory); + const withRefs = (head: string, originHead: string, run: () => void) => { writeFileSync(join(mainRoot, '.git/HEAD'), `ref: refs/heads/${head}\n`); writeFileSync(join(mainRoot, '.git/refs/remotes/origin/HEAD'), `ref: refs/remotes/origin/${originHead}\n`); @@ -46,23 +70,34 @@ const withRefs = (head: string, originHead: string, run: () => void) => { }; beforeAll(() => { - sandbox = mkdtempSync(join(tmpdir(), 'main-checkout-guard-')); + sandbox = realpathSync(mkdtempSync(join(tmpdir(), 'main-checkout-guard-'))); mainRoot = join(sandbox, 'repo'); - mkdirSync(join(mainRoot, '.git/worktrees/FEAT-1234'), { recursive: true }); + git('init', '-q', '-b', 'main', mainRoot); + git('-C', mainRoot, 'commit', '-q', '--allow-empty', '-m', 'init'); mkdirSync(join(mainRoot, '.git/refs/remotes/origin'), { recursive: true }); - writeFileSync(join(mainRoot, '.git/HEAD'), 'ref: refs/heads/main\n'); writeFileSync(join(mainRoot, '.git/refs/remotes/origin/HEAD'), 'ref: refs/remotes/origin/main\n'); mkdirSync(join(mainRoot, 'apps/api'), { recursive: true }); mkdirSync(join(mainRoot, 'packages/db'), { recursive: true }); worktreeRoot = join(mainRoot, '.worktrees/FEAT-1234'); + git('-C', mainRoot, 'worktree', 'add', '-q', worktreeRoot, '-b', 'FEAT-1234'); mkdirSync(join(worktreeRoot, 'apps/api'), { recursive: true }); mkdirSync(join(worktreeRoot, 'packages/db'), { recursive: true }); - writeFileSync(join(worktreeRoot, '.git'), `gitdir: ${mainRoot}/.git/worktrees/FEAT-1234\n`); plainDir = join(sandbox, 'not-a-checkout'); mkdirSync(plainDir, { recursive: true }); + + foreignRoot = join(sandbox, 'other-repo'); + git('init', '-q', '-b', 'main', foreignRoot); + + unregisteredRoot = join(sandbox, 'unregistered-copy'); + cpSync(worktreeRoot, unregisteredRoot, { recursive: true }); + + linkToMain = join(sandbox, 'link-to-main'); + symlinkSync(mainRoot, linkToMain); + linkToWorktree = join(sandbox, 'link-to-worktree'); + symlinkSync(worktreeRoot, linkToWorktree); }); beforeEach(() => { @@ -88,6 +123,7 @@ describe('isMutating', () => { 'git merge feature', 'git worktree add ../x', 'git worktree remove x', + 'git worktree move x ../y', 'git worktree prune', 'bunx prisma migrate dev', 'bun run db:migrate', @@ -102,6 +138,42 @@ describe('isMutating', () => { } }); + test('addendum C: bun aliases and install with a package argument', () => { + for (const command of [ + 'bun a left-pad', + 'bun rm left-pad', + 'bun install left-pad', + 'bun i left-pad', + 'bun i -d left-pad', + 'bun install --force left-pad', + ]) { + expect(isMutating(command), command).toBe(true); + } + for (const command of ['bun install', 'bun i', 'bun install --force', 'bun i --frozen-lockfile']) { + expect(isMutating(command), command).toBe(false); + } + }); + + test('addendum C: every bun --cwd / run form of the migrate script', () => { + for (const command of [ + 'bun --cwd packages/db db:migrate', + 'bun run --cwd packages/db db:migrate', + 'bun --cwd packages/db run db:migrate', + 'bun --cwd packages/db prisma migrate dev', + 'bun run --cwd packages/db prisma migrate dev', + ]) { + expect(isMutating(command), command).toBe(true); + } + for (const command of [ + 'bun --cwd packages/db db:push:dev', + 'bun --cwd packages/db db:generate', + 'bun --cwd packages/db run db:deploy', + 'bun --cwd packages/db prisma generate', + ]) { + expect(isMutating(command), command).toBe(false); + } + }); + test('finding 1: newlines, indentation, comments, and line continuations do not hide a command', () => { for (const command of [ 'echo hi\ngit push', @@ -221,40 +293,41 @@ describe('isMutating', () => { }); }); -describe('mutationsIn: effective directory per segment (finding 3)', () => { +describe('mutationsIn: effective directory per segment (finding 3, addendum C)', () => { test('a cd applies to the segments after it, in order', () => { - expect(mutationsIn(`cd ${worktreeRoot} && git push`, mainRoot).map((m) => m.directory)).toEqual([worktreeRoot]); - expect(mutationsIn(`cd ${worktreeRoot} && cd .. && git push`, mainRoot).map((m) => m.directory)).toEqual([ - join(mainRoot, '.worktrees'), - ]); - expect(mutationsIn(`cd ${worktreeRoot}; cd ${mainRoot}; git push`, mainRoot).map((m) => m.directory)).toEqual([ - mainRoot, - ]); - expect(mutationsIn('cd .worktrees/FEAT-1234 && git push', mainRoot).map((m) => m.directory)).toEqual([ - worktreeRoot, - ]); - expect(mutationsIn(`cd ${worktreeRoot}\ngit push`, mainRoot).map((m) => m.directory)).toEqual([worktreeRoot]); + expect(directories(`cd ${worktreeRoot} && git push`)).toEqual([worktreeRoot]); + expect(directories(`cd ${worktreeRoot} && cd .. && git push`)).toEqual([join(mainRoot, '.worktrees')]); + expect(directories(`cd ${worktreeRoot}; cd ${mainRoot}; git push`)).toEqual([mainRoot]); + expect(directories('cd .worktrees/FEAT-1234 && git push')).toEqual([worktreeRoot]); + expect(directories(`cd ${worktreeRoot}\ngit push`)).toEqual([worktreeRoot]); + }); + + test('a separator glued to the path still ends the cd token', () => { + expect(directories(`cd ${worktreeRoot};git push`)).toEqual([worktreeRoot]); + expect(directories(`cd ${worktreeRoot}&&git push`)).toEqual([worktreeRoot]); + expect(directories(`cd ${worktreeRoot}||git push`)).toEqual([worktreeRoot]); + expect(directories(`cd ${worktreeRoot}|git push`)).toEqual([worktreeRoot]); }); test('git -C and bun --cwd apply only to their own segment', () => { - expect(mutationsIn(`git -C ${worktreeRoot} status && git push`, mainRoot).map((m) => m.directory)).toEqual([ - mainRoot, - ]); - expect(mutationsIn(`git -C ${worktreeRoot} push && git status`, mainRoot).map((m) => m.directory)).toEqual([ - worktreeRoot, - ]); - expect(mutationsIn(`cd ${worktreeRoot} && git -C . push`, mainRoot).map((m) => m.directory)).toEqual([ - worktreeRoot, - ]); - expect(mutationsIn(`cd ${worktreeRoot} && bun --cwd . add x`, mainRoot).map((m) => m.directory)).toEqual([ - worktreeRoot, - ]); - expect(mutationsIn(`bun --cwd ${worktreeRoot}/apps/api add x`, mainRoot).map((m) => m.directory)).toEqual([ - join(worktreeRoot, 'apps/api'), - ]); - expect(mutationsIn('bun run --cwd packages/db db:migrate', mainRoot).map((m) => m.directory)).toEqual([ - join(mainRoot, 'packages/db'), - ]); + expect(directories(`git -C ${worktreeRoot} status && git push`)).toEqual([mainRoot]); + expect(directories(`git -C ${worktreeRoot} push && git status`)).toEqual([worktreeRoot]); + expect(directories(`cd ${worktreeRoot} && git -C . push`)).toEqual([worktreeRoot]); + expect(directories(`cd ${worktreeRoot} && bun --cwd . add x`)).toEqual([worktreeRoot]); + expect(directories(`bun --cwd ${worktreeRoot}/apps/api add x`)).toEqual([join(worktreeRoot, 'apps/api')]); + expect(directories('bun run --cwd packages/db db:migrate')).toEqual([join(mainRoot, 'packages/db')]); + expect(directories('bun --cwd packages/db db:migrate')).toEqual([join(mainRoot, 'packages/db')]); + expect(directories('bun --cwd packages/db run db:migrate')).toEqual([join(mainRoot, 'packages/db')]); + }); + + test('every target is resolved through realpath', () => { + expect(directories(`cd ${linkToWorktree} && git push`)).toEqual([worktreeRoot]); + expect(directories(`cd ${linkToMain} && git push`)).toEqual([mainRoot]); + expect(directories(`git -C ${linkToMain} push`)).toEqual([mainRoot]); + expect(directories(`bun --cwd ${linkToMain}/apps/api add x`)).toEqual([join(mainRoot, 'apps/api')]); + expect(mutationsIn(`cd ${linkToMain} && git push`, mainRoot)[0].named).toBe(linkToMain); + expect(mutationsIn(`cd ${mainRoot} && git push`, mainRoot)[0].named).toBe(mainRoot); + expect(mutationsIn('cd apps/api && git push', mainRoot)[0].named).toBeUndefined(); }); test('unknown targets leave the directory unresolved', () => { @@ -284,6 +357,14 @@ describe('mutationsIn: effective directory per segment (finding 3)', () => { }); }); +describe('registeredWorktrees', () => { + test('lists the linked worktrees git knows about, realpath-compared, without the main checkout', () => { + expect(registeredWorktrees(mainRoot)).toEqual([worktreeRoot]); + expect(registeredWorktrees(linkToMain)).toEqual([worktreeRoot]); + expect(registeredWorktrees(plainDir)).toEqual([]); + }); +}); + describe('bashMainCheckoutGuard', () => { test('a session that has only ever been in the main checkout is never guarded', () => { expect(fromMain('bun add left-pad')).toBeUndefined(); @@ -296,7 +377,8 @@ describe('bashMainCheckoutGuard', () => { const reason = fromMain('bun add @inixiative/json-rules@2.22.0'); expect(reason).toContain('MAIN checkout'); expect(reason).toContain(`cd ${worktreeRoot} && bun add @inixiative/json-rules@2.22.0`); - expect(reason).toContain(`would run in ${mainRoot}, which is the main checkout`); + expect(reason).toContain(`cd ${mainRoot} && bun add @inixiative/json-rules@2.22.0`); + expect(reason).toContain('reached by a relative path'); }); test('finding 1: a hidden second line from main is denied', () => { @@ -319,23 +401,33 @@ describe('bashMainCheckoutGuard', () => { } }); + test('addendum C: aliases and install-with-package from main are denied', () => { + visitWorktree(); + for (const command of ['bun a x', 'bun rm x', 'bun i x', 'bun install x', 'bun --cwd packages/db db:migrate']) { + expect(fromMain(command), command).toBeDefined(); + } + expect(fromMain('bun install')).toBeUndefined(); + expect(fromMain('bun i')).toBeUndefined(); + }); + test('finding 3: naming a worktree somewhere in the command does not whitelist the whole command', () => { visitWorktree(); expect(fromMain(`git -C ${worktreeRoot} status && git push`)).toBeDefined(); expect(fromMain(`cd ${worktreeRoot} && cd .. && git push`)).toBeDefined(); - expect(fromMain(`cd ${worktreeRoot}; cd ${mainRoot}; git push`)).toBeDefined(); - expect(fromMain(`cd ${mainRoot} && bun add left-pad`)).toBeDefined(); - expect(fromMain(`git -C "${mainRoot}" push`)).toBeDefined(); + expect(fromMain(`cd ${worktreeRoot}; cd ../..; git push`)).toBeDefined(); expect(fromMain('cd . && git push')).toBeDefined(); expect(fromMain('cd apps/api && bun add left-pad')).toBeDefined(); + expect(fromMain('git -C . commit -m x')).toBeDefined(); }); - test('finding 3: a mutation whose effective directory is a worktree passes', () => { + test('finding 3: a mutation whose effective directory is a registered worktree passes', () => { visitWorktree(); expect(fromMain(`cd ${worktreeRoot} && git commit -m x`)).toBeUndefined(); expect(fromMain(`cd "${worktreeRoot}" && git commit -m x`)).toBeUndefined(); expect(fromMain(`cd '${worktreeRoot}/apps/api'; git push`)).toBeUndefined(); expect(fromMain('cd .worktrees/FEAT-1234 && git push')).toBeUndefined(); + expect(fromMain(`cd ${worktreeRoot};git push`)).toBeUndefined(); + expect(fromMain(`cd ${worktreeRoot}&&git push`)).toBeUndefined(); expect(fromMain(`git -C ${worktreeRoot} commit -m x`)).toBeUndefined(); expect(fromMain('git -C .worktrees/FEAT-1234 commit -m x')).toBeUndefined(); expect(fromMain(`git -C ${worktreeRoot} push && git status`)).toBeUndefined(); @@ -344,16 +436,42 @@ describe('bashMainCheckoutGuard', () => { expect(fromMain(`bash -c 'cd ${worktreeRoot} && git push'`)).toBeUndefined(); }); - test('a cd whose target does not exist or is not a checkout does not bypass the guard', () => { + test('addendum C: the main checkout is lifted only by its own absolute path, for cd and -C alike', () => { + visitWorktree(); + expect(fromMain(`cd ${mainRoot} && bun add left-pad`)).toBeUndefined(); + expect(fromMain(`cd ${mainRoot}/apps/api && git push`)).toBeUndefined(); + expect(fromMain(`git -C ${mainRoot} push`)).toBeUndefined(); + expect(fromMain(`git -C "${mainRoot}" push`)).toBeUndefined(); + expect(fromMain(`bun --cwd ${mainRoot} add left-pad`)).toBeUndefined(); + expect(fromMain(`cd ${mainRoot} && cd apps/api && git push`)).toBeDefined(); + }); + + test('addendum C: a symlink to the main checkout does not lift, a symlink to a worktree does', () => { + visitWorktree(); + const throughLink = fromMain(`cd ${linkToMain} && git push`); + expect(throughLink).toContain(`through ${linkToMain}, which is not its own absolute path`); + expect(fromMain(`git -C ${linkToMain} push`)).toBeDefined(); + expect(fromMain(`bun --cwd ${linkToMain} add x`)).toBeDefined(); + expect(fromMain(`cd ${linkToWorktree} && git push`)).toBeUndefined(); + expect(fromMain(`git -C ${linkToWorktree}/apps/api commit -m x`)).toBeUndefined(); + }); + + test('addendum C: an arbitrary directory, an unregistered checkout, or another repository never lifts', () => { + visitWorktree(); + expect(fromMain(`cd ${plainDir} && git push`)).toContain('not a git checkout'); + expect(fromMain(`git -C ${plainDir} push`)).toBeDefined(); + expect(fromMain('cd /tmp && bun add x')).toBeDefined(); + expect(fromMain(`cd ${unregisteredRoot} && git push`)).toContain('registered worktrees'); + expect(fromMain(`cd ${foreignRoot} && git push`)).toContain('registered worktrees'); + }); + + test('a cd whose target does not exist does not bypass the guard', () => { visitWorktree(); const missing = fromMain('cd /does/not/exist; git push'); expect(missing).toContain('cannot resolve'); expect(fromMain('cd /bad || git commit -m x')).toBeDefined(); - expect(fromMain(`cd ${plainDir} && git push`)).toBeDefined(); expect(fromMain('cd "$SOMEWHERE" && git push')).toBeDefined(); - const named = fromMain('git -C /does/not/exist commit -m x'); - expect(named).toContain('names /does/not/exist'); - expect(fromMain(`git -C ${plainDir} push`)).toBeDefined(); + expect(fromMain('git -C /does/not/exist commit -m x')).toContain('names /does/not/exist'); expect(fromMain(`cd ${worktreeRoot} && git -C /does/not/exist push`)).toBeDefined(); }); @@ -477,6 +595,7 @@ describe('bashMainCheckoutGuard', () => { expect(denied.hookSpecificOutput.hookEventName).toBe('PreToolUse'); expect(denied.hookSpecificOutput.permissionDecision).toBe('deny'); expect(denied.hookSpecificOutput.permissionDecisionReason).toContain(worktreeRoot); + expect(run(bash(`cd ${worktreeRoot} && git push`, mainRoot))).toBe(''); expect(run(bash('git status', mainRoot))).toBe(''); expect(run({ session_id: SESSION, tool_name: 'Read', cwd: mainRoot, tool_input: { file_path: 'x' } })).toBe(''); }); diff --git a/.claude/skills/worktrees/SKILL.md b/.claude/skills/worktrees/SKILL.md index bd0062dc2..71fcb2458 100644 --- a/.claude/skills/worktrees/SKILL.md +++ b/.claude/skills/worktrees/SKILL.md @@ -16,7 +16,7 @@ Always use the scripts. The raw `git worktree add` / `git worktree remove` are i | `bun run worktree:create [--skip-db]` | Checks Docker/Postgres up front, allocates a slot (1–9) under a per-slot lock and records it in `.worktrees/.slots/`, creates the git worktree at `.worktrees//`, generates slot-specific `.env.local` / `.env.test` (root and `apps/*`) synced against the branch's `.env.*.example`, creates the Postgres `_wt_` and `_test_wt_` databases and MinIO buckets, runs `bun install` and verifies workspace resolution, builds the gitignored artifacts (route trees, Prisma client + `prismaMap.gen.ts` + zod, OpenAPI spec + SDK + MSW handlers), pushes the Prisma schema to both DBs, and warns about any worktrees whose branches are already merged into the default branch. Refuses when `` already exists (attach it instead). `--skip-db` skips the Docker gate, databases, buckets, and schema push. | | `bun run worktree:create [--skip-db]` | Same, but attaches an existing local/`origin` branch to a new worktree (useful for PR review without disturbing the main checkout). | | `bun run worktree:list` | Shows every worktree with its slot, branch, and assigned ports. | -| `bun run worktree:destroy ` | Drops the Postgres databases, removes the MinIO buckets, `FLUSHDB` on Redis DB ``, kills every listener on the worktree's four ports, removes the git worktree, frees the slot. | +| `bun run worktree:destroy [--force]` | Prints whether the branch is merged into the default branch, refuses a worktree with uncommitted or untracked changes unless `--force`, drops the Postgres databases, removes the MinIO buckets, `FLUSHDB` on Redis DB ``, kills every listener on the worktree's four ports, removes the git worktree, frees the slot. | `` for destroy is the worktree directory name (the branch with slashes replaced by dashes). Get it from `bun run worktree:list`. @@ -64,8 +64,8 @@ The main checkout uses slot 0 / default ports (`3000`, `3001`, `3002`, `8000`). 2. Prunes stale registry entries, scans registered worktrees for allocated slots (registry, marker, or DB-name reference), and surfaces worktrees whose branches are already merged into the default branch — including branches checked out in a worktree, which `git branch --merged` prefixes with `+`. 3. Claims the lowest free slot 1–9 under a `mkdir` lock in `.worktrees/.locks/slot-` (pid-stamped, held until the script exits). A lock whose pid is dead, or that has no pid file and is older than 60 seconds, is reclaimed with a notice. Exits if all 9 are taken. 4. Creates the git worktree off the base branch (creates the new branch if needed; attaches an existing one in attach mode) and writes the registry entry. -5. Copies root's `.env.local` / `.env.test` and `apps/*/.env.*` in, runs `scripts/setup/sync-env.sh` **against the branch's own `.env.*.example` files** (whole multi-line quoted values, so the webhook PEM keys arrive intact), then puts `WORKTREE_SLOT=` on the first line of `.env.local` / `.env.test` and rewrites the slot ports / URLs / DB names / Redis index / buckets — in the root files and in `apps/api/.env.local`, whose `PORT` and `API_URL` override root's under `with-env`. A `DATABASE_URL` that cannot be pointed at the slot database (credential-less URLs included) is a hard error. Every generated env file must source cleanly in a subshell; one that does not is a gap. -6. Creates both Postgres databases. A database already on the slot is dropped only when no registered worktree references that slot (a leftover from a worktree removed without `worktree:destroy`); if one does, the script refuses and names it. +5. Copies root's `.env.local` / `.env.test` and `apps/*/.env.*` in (any `WORKTREE_SLOT=` line root carries is dropped), runs `scripts/setup/sync-env.sh` **against the branch's own `.env.*.example` files** (whole multi-line quoted values, so the webhook PEM keys arrive intact), then puts `WORKTREE_SLOT=` on the first line of `.env.local` / `.env.test` and rewrites the slot ports / URLs / DB names / Redis index / buckets — in the root files and in `apps/api/.env.local`, whose `PORT` and `API_URL` override root's under `with-env`. A `DATABASE_URL` that cannot be pointed at the slot database (credential-less URLs and passwords containing `& | /` included) is a hard error; unless `--skip-db`, a missing `DATABASE_URL` for the local or test side is a hard error naming the key and the files searched. Every generated env file must source cleanly in a subshell; one that does not is a gap. +6. Creates both Postgres databases; a failed inventory query (`pg_database`) aborts rather than being read as "absent". A database already on the slot is dropped only when no registered worktree references that slot (a leftover from a worktree removed without `worktree:destroy`); if one does, the script refuses and names it. 7. Provisions the slot's MinIO buckets. 8. `bun install --force --ignore-scripts` in the worktree and verifies `apps/api/node_modules/@template/db` resolves *inside* the worktree. 9. Builds the gitignored artifacts: `bun run generate:routes` (`apps/*/app/routeTree.gen.ts`), `bun run db:generate` (Prisma client, `prismaMap.gen.ts`, zod), `bun run generate:sdk` (OpenAPI spec, `packages/sdk/src/generated`, `packages/ui/src/test/mocks/handlers.gen.ts`). @@ -94,12 +94,13 @@ When you dispatch a subagent to do code work in a worktree, **the subagent does The same applies to your own session: the Bash tool's cwd can reset to the main checkout between calls (after a session restart, or after a command that `cd`'d outside the repo). A bare `bun add`, `git commit`, or `git push` then lands on main. -The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` whose effective directory (after any earlier `cd`, or its own `git -C` / `bun --cwd`) is the main checkout or cannot be resolved. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. +The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i` is allowed), `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command, or the segment's own `git -C` / `bun --cwd`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, or an unresolvable target is never lifted. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. Consequences worth knowing: - A `cd` earlier in the command moves the effective directory for everything after it; `git -C ` and `bun --cwd ` apply only to their own segment. So `cd && git push` passes, `git -C status && git push` is denied, and `cd && cd .. && git push` is denied. -- A `cd` to a path that does not exist, is not a git checkout, or contains a variable makes the directory unresolvable, and unresolvable means denied. +- A `cd` to a path that does not exist, is not a git checkout, or contains a variable makes the directory unresolvable, and unresolvable means denied. `cd ;git push` and `cd &&git push` (no spaces) tokenize like the spaced forms. +- Symlinks are resolved before the decision: a symlink to a registered worktree lifts, a symlink to the main checkout does not (main lifts only by its own absolute path). - `REQUIRE_MAIN_BRANCH` is an architect decision: while the main checkout sits on a feature branch the hook is inert, so a drifted commit lands on that branch. It is one constant in the hook, with a test either way, so it can be flipped in one edit. - A session that has only ever worked in the main checkout is never touched. Background Bash calls inherit the session cwd like foreground ones. Per-session state lives under the OS temp dir and entries older than a day are pruned on SessionStart. - The hook only denies; it never rewrites a command, because PreToolUse hooks run in parallel and the user-level RTK hook already rewrites Bash commands via `updatedInput`. @@ -107,10 +108,10 @@ Consequences worth knowing: ## When you're done with a worktree ```bash -bun run worktree:destroy +bun run worktree:destroy [--force] ``` -`` must be a worktree git knows about (or a half-created directory that still has its `.git` file); `.locks`, `.slots`, and arbitrary directories are refused. It reads `WORKTREE_SLOT` from the worktree's `.env.local` (CRLF, quotes, and a leading `export` are tolerated; anything that is not a digit 1–9 is a hard error), refuses if the worktree's files name more than one slot, and — unless another registered worktree also references the slot, in which case it says so and leaves the databases alone — resolves `docker` the same way create does, drops both Postgres databases (`WITH (FORCE)`, so open connections don't block it), removes the MinIO buckets, flushes Redis DB `` on whichever container serves `localhost:6379` (that is what the slot's `REDIS_URL` points at, whether it is `_redis` or another stack's Redis holding the port). Then it kills every listener on the four slot ports (each pid is reported; one that survives is a warning, not a "killed"), removes the registry entry and the git worktree (falls back to `rm -rf` + `git worktree prune` if needed), and frees the slot. +It first prints whether the worktree's branch is merged into the default branch, and refuses when the worktree has uncommitted or untracked changes unless `--force` is passed (the offending paths are listed either way). `` must be a worktree git knows about (or a half-created directory that still has its `.git` file); `.locks`, `.slots`, and arbitrary directories are refused. It reads `WORKTREE_SLOT` from the worktree's `.env.local` (CRLF, quotes, and a leading `export` are tolerated; anything that is not a digit 1–9 is a hard error), refuses if the worktree's files name more than one slot, and — unless another registered worktree also references the slot, in which case it says so and leaves the databases alone — resolves `docker` the same way create does, drops both Postgres databases (`WITH (FORCE)`, so open connections don't block it), removes the MinIO buckets, flushes Redis DB `` on whichever container serves `localhost:6379` (that is what the slot's `REDIS_URL` points at, whether it is `_redis` or another stack's Redis holding the port). Then it kills every listener on the four slot ports (each pid is reported; one that survives is a warning, not a "killed"), removes the registry entry and the git worktree (falls back to `rm -rf` + `git worktree prune` if needed), and frees the slot. If Docker is unreachable at destroy time it says so; the next `worktree:create` that reclaims the slot drops the leftover databases (after confirming no registered worktree still references them). @@ -133,6 +134,7 @@ If Docker is unreachable at destroy time it says so; the next `worktree:create` | Mistake | Fix | |---|---| | Using `git worktree add` directly | Use `bun run worktree:create` (`--skip-db` for docs-only) — the hook denies the raw command from a drifted main cwd anyway | +| `worktree:destroy` refused because the worktree is dirty | Commit or discard in the worktree, or `bun run worktree:destroy --force` when the changes are disposable | | Trusting a green "ready" from the older create | Current script: green means every env file sources, every artifact exists, and the push landed; yellow WITH GAPS lists what to rerun | | `worktree:create main ` for a branch that already exists | That is attach mode: `bun run worktree:create ` | | Editing files in the main repo when you meant to work in a worktree | `cd .worktrees/` first; verify with `git rev-parse --show-toplevel` | diff --git a/AI/ENTRYPOINT.md b/AI/ENTRYPOINT.md index 3998e9800..c524e627a 100644 --- a/AI/ENTRYPOINT.md +++ b/AI/ENTRYPOINT.md @@ -47,7 +47,7 @@ concluding that something is missing — it is usually present and documented. - Do not run git commands unless the user explicitly asks. - **NEVER run `git stash` — for any reason, ever.** Not for "baseline verification", not for "let me test if this is from my change", not for "let me try and see". If you need to compare against HEAD or a clean state: use `git show HEAD:` to read a file at HEAD, `git diff` to see changes, or read the file content directly. Stash has destroyed in-flight work before. - **NEVER use `git -c =` to bypass any rule** — this includes (but is not limited to) bypassing the stash ban, signing requirements, hook execution (`--no-verify`), or any other guardrail. If a command requires bypassing config, stop and ask. -- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy `, `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` whose effective directory (after any earlier `cd`, or its own `git -C` / `bun --cwd`) is the main checkout or cannot be resolved. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. See `.claude/skills/worktrees/SKILL.md`. +- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy [--force]` (refuses a dirty worktree without `--force`), `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i` is allowed), `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command, or the segment's own `git -C` / `bun --cwd`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, or an unresolvable target is never lifted. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. See `.claude/skills/worktrees/SKILL.md`. - **If something is broken, just fix it.** Don't stash-to-bisect, don't "let me see if reverting my changes makes it go away" — read the code, find the cause, fix the cause. ## 0.1. Understand the Why First diff --git a/docs/claude/SCRIPTS.md b/docs/claude/SCRIPTS.md index 8b560c016..61e5ed5ff 100644 --- a/docs/claude/SCRIPTS.md +++ b/docs/claude/SCRIPTS.md @@ -320,10 +320,10 @@ bun run worktree:create # Fork a new bran bun run worktree:create # Attach an existing branch bun run worktree:create --skip-db # Docs/skills only: no databases, buckets, or schema push; Docker not required bun run worktree:list -bun run worktree:destroy +bun run worktree:destroy [--force] # --force: destroy despite uncommitted or untracked changes ``` -Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s), and drops a leftover slot database only when no registered worktree references that slot. Destroy requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/remove/update`, `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` whose effective directory (after any earlier `cd`, or its own `git -C` / `bun --cwd`) is the main checkout or cannot be resolved. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. +Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s), aborts when the database inventory query fails, requires `DATABASE_URL` for both sides unless `--skip-db`, and drops a leftover slot database only when no registered worktree references that slot. Destroy prints whether the branch is merged into the default branch, refuses a dirty worktree unless `--force`, requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i` is allowed), `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command, or the segment's own `git -C` / `bun --cwd`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, or an unresolvable target is never lifted. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. --- diff --git a/scripts/worktree/create.sh b/scripts/worktree/create.sh index 529a9cc64..8a5407b40 100755 --- a/scripts/worktree/create.sh +++ b/scripts/worktree/create.sh @@ -183,13 +183,11 @@ info "Generating env files (root values, the branch's keys, slot $SLOT ports/DBs WT_ENV="$WORKTREE_DIR/.env.local" WT_TEST_ENV="$WORKTREE_DIR/.env.test" -cp "$MAIN_ENV" "$WT_ENV" -[ -f "$ROOT_DIR/.env.test" ] && cp "$ROOT_DIR/.env.test" "$WT_TEST_ENV" +copy_env_without_marker "$MAIN_ENV" "$WT_ENV" +[ -f "$ROOT_DIR/.env.test" ] && copy_env_without_marker "$ROOT_DIR/.env.test" "$WT_TEST_ENV" for src in "$ROOT_DIR"/apps/*/.env.local "$ROOT_DIR"/apps/*/.env.test; do [ -f "$src" ] || continue - dst="$WORKTREE_DIR/${src#"$ROOT_DIR"/}" - mkdir -p "$(dirname "$dst")" - cp "$src" "$dst" + copy_env_without_marker "$src" "$WORKTREE_DIR/${src#"$ROOT_DIR"/}" done SYNC_ENV="$WORKTREE_DIR/scripts/setup/sync-env.sh" @@ -252,9 +250,14 @@ while IFS= read -r f; do done < <(worktree_env_files "$WORKTREE_DIR") if [ "$DB_AVAILABLE" -eq 1 ]; then + require_env_key DATABASE_URL "$WORKTREE_DIR/apps/api/.env.local" "$WT_ENV" + require_env_key DATABASE_URL "$WT_TEST_ENV" "$WORKTREE_DIR/apps/api/.env.test" + info "Creating Postgres databases on $PG_CONTAINER..." for DB in "$DB_LOCAL" "$DB_TEST"; do - if [ "$("${PSQL[@]}" -c "SELECT 1 FROM pg_database WHERE datname='${DB}';")" = "1" ]; then + DB_EXISTS="$("${PSQL[@]}" -c "SELECT 1 FROM pg_database WHERE datname='${DB}';" 2>&1)" \ + || die "Error: could not list databases on $PG_CONTAINER (psql failed: ${DB_EXISTS}) — not assuming $DB is absent." + if [ "$DB_EXISTS" = "1" ]; then if CLAIMANT="$(slot_claimant "$SLOT" "$WORKTREE_DIR")"; then die "Error: database $DB already exists and the worktree at $CLAIMANT references slot $SLOT — refusing to drop it. Run 'bun run worktree:destroy $(basename "$CLAIMANT")' if that worktree is finished, then re-run this command." diff --git a/scripts/worktree/destroy.sh b/scripts/worktree/destroy.sh index 7369cece1..be36b7a47 100755 --- a/scripts/worktree/destroy.sh +++ b/scripts/worktree/destroy.sh @@ -8,11 +8,20 @@ PROJECT_NAME="$(project_name "$ROOT_DIR")" PG_CONTAINER="${PROJECT_NAME}_postgres" MINIO_CONTAINER="${PROJECT_NAME}_minio" -NAME="${1:-}" +NAME="" +FORCE=0 +for arg in "$@"; do + case "$arg" in + --force) FORCE=1 ;; + -*) die "Unknown flag: $arg" ;; + *) [ -z "$NAME" ] && NAME="$arg" || die "Error: expected one worktree name, got '$NAME' and '$arg'" ;; + esac +done if [ -z "$NAME" ]; then - echo -e "${RED}Usage: $0 ${NC}" - echo " Worktree directory name under .worktrees/ (branch with slashes → dashes)" + echo -e "${RED}Usage: $0 [--force]${NC}" + echo " Worktree directory name under .worktrees/ (branch with slashes → dashes)" + echo " --force Destroy even when the worktree has uncommitted or untracked changes" echo echo "Run 'bun run worktree:list' to see available worktrees." exit 1 @@ -36,6 +45,21 @@ else die "Error: $WORKTREE_DIR is not a git worktree (no .git file, not registered). Refusing to delete it." fi +if [ "$REGISTERED" -eq 1 ]; then + info "$(branch_merge_status "$WORKTREE_DIR")" + DIRTY="$(git -C "$WORKTREE_DIR" status --porcelain --untracked-files=all 2>/dev/null || true)" + if [ -n "$DIRTY" ]; then + if [ "$FORCE" -eq 1 ]; then + warn "Warning: destroying with uncommitted or untracked changes (--force):" + echo "$DIRTY" | head -10 | sed 's/^/ /' + else + echo -e "${RED}Error: $NAME has uncommitted or untracked changes:${NC}" >&2 + echo "$DIRTY" | head -10 | sed 's/^/ /' >&2 + die "Commit or discard them, or pass --force to destroy anyway." + fi + fi +fi + SLOT="$(worktree_marker_slot "$WORKTREE_DIR")" CLAIMED_SLOTS="$(worktree_slots "$WORKTREE_DIR" | tr '\n' ' ')" CLAIMED_COUNT="$(echo "$CLAIMED_SLOTS" | wc -w | tr -d ' ')" diff --git a/scripts/worktree/lib.sh b/scripts/worktree/lib.sh index 49efa9a68..55be12ec1 100755 --- a/scripts/worktree/lib.sh +++ b/scripts/worktree/lib.sh @@ -48,7 +48,7 @@ valid_slot() { resolve_docker() { local candidate - for candidate in "$(command -v docker 2>/dev/null || true)" "$HOME/.docker/bin/docker" /Applications/Docker.app/Contents/Resources/bin/docker; do + for candidate in "$(command -v docker 2>/dev/null || true)" "${HOME:-/nonexistent}/.docker/bin/docker" /Applications/Docker.app/Contents/Resources/bin/docker; do if [ -n "$candidate" ] && [ -x "$candidate" ]; then echo "$candidate" return 0 @@ -84,10 +84,14 @@ slot_resources() { STORAGE_BUCKET_USER_TEST="${PROJECT_NAME}-user-test-wt-${slot}" } +sed_replacement() { + printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' +} + ensure_env_var() { local file="$1" key="$2" value="$3" if grep -q "^${key}=" "$file"; then - sedi "s|^${key}=.*|${key}=${value}|" "$file" + sedi "s|^${key}=.*|${key}=$(sed_replacement "$value")|" "$file" else echo "${key}=${value}" >> "$file" fi @@ -96,7 +100,23 @@ ensure_env_var() { replace_env_var_if_present() { local file="$1" key="$2" value="$3" [ -f "$file" ] && grep -q "^${key}=" "$file" || return 0 - sedi "s|^${key}=.*|${key}=${value}|" "$file" + sedi "s|^${key}=.*|${key}=$(sed_replacement "$value")|" "$file" +} + +require_env_key() { + local key="$1" f + shift + for f in "$@"; do + [ -f "$f" ] && grep -qE "^(export[[:space:]]+)?${key}=" "$f" && return 0 + done + die "Error: $key is not set in any of: $* — the slot databases cannot be provisioned without it. +Add it to the root env file (bun run sync-env) or pass --skip-db for an edit-only worktree." +} + +copy_env_without_marker() { + local src="$1" dst="$2" + mkdir -p "$(dirname "$dst")" + grep -vE '^(export[[:space:]]+)?WORKTREE_SLOT=' "$src" > "$dst" || true } prepend_slot_marker() { @@ -110,8 +130,8 @@ prepend_slot_marker() { rewrite_database_url() { local file="$1" db="$2" [ -f "$file" ] && grep -q '^DATABASE_URL=' "$file" || return 0 - sedi -E "s|^(DATABASE_URL=[a-z]+://([^@/[:space:]]+@)?[^/[:space:]]+/)[^?[:space:]]*|\1${db}|" "$file" - grep -Eq "^DATABASE_URL=[a-z]+://([^@/[:space:]]+@)?[^/[:space:]]+/${db}([?[:space:]]|\$)" "$file" + sedi -E "s#^(DATABASE_URL=[a-z]+://(.*@)?[^/@[:space:]]+/)[^?[:space:]]*#\1$(sed_replacement "$db")#" "$file" + grep -Eq "^DATABASE_URL=[a-z]+://(.*@)?[^/@[:space:]]+/${db}([?[:space:]]|\$)" "$file" } rewrite_redis_db() { @@ -124,12 +144,23 @@ env_file_sources() { bash -c 'set -a; . "$1"' _ "$1" >/dev/null 2>&1 } +real_dir() { + [ -d "$1" ] && (cd "$1" && pwd -P) || printf '%s\n' "$1" +} + linked_worktrees() { - git -C "$ROOT_DIR" worktree list --porcelain | sed -n 's/^worktree //p' | grep -vx "$ROOT_DIR" || true + local p + while IFS= read -r p; do + [ -n "$p" ] || continue + p="$(real_dir "$p")" + [ "$p" = "$(real_dir "$ROOT_DIR")" ] && continue + printf '%s\n' "$p" + done < <(git -C "$ROOT_DIR" worktree list --porcelain | sed -n 's/^worktree //p') + return 0 } is_registered_worktree() { - linked_worktrees | grep -qx "$1" + linked_worktrees | grep -qx "$(real_dir "$1")" } worktree_env_files() { @@ -201,6 +232,7 @@ worktree_claims_slot() { slot_claimant() { local slot="$1" exclude="${2:-}" wt + [ -z "$exclude" ] || exclude="$(real_dir "$exclude")" while IFS= read -r wt; do [ -n "$wt" ] && [ -d "$wt" ] && [ "$wt" != "$exclude" ] || continue if worktree_claims_slot "$wt" "$slot"; then @@ -285,3 +317,17 @@ require_artifact() { GAPS="${GAPS} - $rel missing — rerun in the worktree: $hint\n" fi } + +branch_merge_status() { + local wt="$1" branch base + branch="$(git -C "$wt" branch --show-current 2>/dev/null || true)" + base="$(default_branch "$ROOT_DIR")" + base="${base:-main}" + if [ -z "$branch" ]; then + echo "detached HEAD — merge status unknown" + elif git -C "$ROOT_DIR" branch --merged "$base" 2>/dev/null | sed 's/^[*+ ]*//' | grep -qx "$branch"; then + echo "branch '$branch' is merged into $base" + else + echo "branch '$branch' is NOT merged into $base ($(git -C "$ROOT_DIR" rev-list --count "$base..$branch" 2>/dev/null || echo '?') commit(s) not in $base)" + fi +} From c7d1bad8c9e2b62f8826c7dcf57e0b9d14aee4f9 Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Wed, 23 Sep 2026 02:05:13 -0300 Subject: [PATCH 4/8] =?UTF-8?q?chore(worktrees):=20adversarial=20round=20t?= =?UTF-8?q?wo=20=E2=80=94=20pipefail-safe=20registry=20checks,=20subshell-?= =?UTF-8?q?aware=20guard=20cursor,=20honest=20bucket=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripts: registered-worktree check no longer greps a multi-line producer under pipefail; GNU/BSD stat branch for lock age; env reads and rewrites share one export/quotes/comment-aware parser and verify themselves (no password through sed); list.sh parses --porcelain; destroy requires this repo's git common dir and reports each bucket via minio-remove.sh over the provisioning endpoint; create registers the slot before the worktree is added, keeps the lock until exit, counts registry entries as claims, prunes only missing+unlocked+old entries, warns on duplicate registry owners, and prints the destroy --force hint on any failure after the worktree exists; sync-env handles escaped quotes, trailing comments, export on both sides, and dies on an unterminated quote; realpath comparisons everywhere; mv-before-rm lock reclaim; left boundary on DB-name claims. Hook: subshell/pipe/background cursor rules; bun option values consumed anywhere; word-0 normalisation and runner options; --git-dir/--work-tree/GIT_DIR/GIT_WORK_TREE redirects; comments stripped; cd options; original segment text in the deny message; here-strings; substitutions in cd targets unknown; arm only for this repo's worktrees; balanced quotes in $( ); depth cap; hashed state files; single stderr report on failure; revert/am guarded. Docs follow every change. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/bashMainCheckoutGuard.ts | 693 ++++++++++++++---- .../hooks/tests/bashMainCheckoutGuard.test.ts | 287 +++++++- .claude/skills/worktrees/SKILL.md | 12 +- AI/ENTRYPOINT.md | 2 +- docs/claude/SCRIPTS.md | 2 +- scripts/db/minio-remove.sh | 44 ++ scripts/setup/sync-env.sh | 82 ++- scripts/worktree/create.sh | 45 +- scripts/worktree/destroy.sh | 27 +- scripts/worktree/lib.sh | 202 +++-- scripts/worktree/list.sh | 30 +- 11 files changed, 1126 insertions(+), 300 deletions(-) create mode 100755 scripts/db/minio-remove.sh diff --git a/.claude/hooks/bashMainCheckoutGuard.ts b/.claude/hooks/bashMainCheckoutGuard.ts index ab33d6916..f90b2e572 100644 --- a/.claude/hooks/bashMainCheckoutGuard.ts +++ b/.claude/hooks/bashMainCheckoutGuard.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, @@ -10,7 +11,7 @@ import { writeFileSync, } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; -import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; type HookInput = { hook_event_name?: string; @@ -21,38 +22,33 @@ type HookInput = { }; export const REQUIRE_MAIN_BRANCH = true; +const MAX_DEPTH = 6; -const MASK = '�'; -const SEPARATOR_AT = /^(?:\r?\n|;|&&|\|\||\||&|\(|\)|\{|\})/; +const MASK = '\u0001'; const HEREDOC_AT = /^<<(-?)\s*(['"]?)(\w+)\2/; -const LEADING_KEYWORD = /^(?:if|then|else|elif|fi|do|done|while|until|!)(?=\s|$)\s*/; -const LEADING_RUNNER = new RegExp( - String.raw`^(?:${[ - String.raw`[A-Za-z_]\w*=\S*`, - String.raw`env(?:\s+-i)?`, - 'command', - 'exec', - 'nohup', - String.raw`sudo(?:\s+-\S+)*`, - String.raw`timeout(?:\s+-\S+(?:\s+\S+)?)*\s+\d+\S*`, - String.raw`nice(?:\s+-n\s+\S+|\s+-\d+)?`, - 'time', - String.raw`xargs(?:\s+-\S+)*`, - String.raw`rtk(?:\s+proxy)?`, - 'bunx', - String.raw`bun\s+x`, - 'npx', - ].join('|')})(?=\s|$)\s*`, -); -const SHELL_C = /^(?:sh|bash|zsh|dash)(?:\s+-[A-Za-z]+)*\s+-[A-Za-z]*c[A-Za-z]*\s+/; -const EVAL = /^eval\s+/; -const CHANGE_DIR = /^(cd|pushd|popd)(?=\s|$)\s*/; -const GIT_MUTATING = new Set(['commit', 'push', 'merge', 'rebase', 'cherry-pick']); -const GIT_RESUMABLE = new Set(['merge', 'rebase', 'cherry-pick']); +const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', '!']); +const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash']); +const RUNNERS: Record; positionals?: number; stopOn?: Set }> = { + env: { values: new Set(['-u', '--unset', '-S', '--split-string']) }, + command: { values: new Set(), stopOn: new Set(['-v', '-V']) }, + exec: { values: new Set(['-a']) }, + nohup: { values: new Set() }, + sudo: { values: new Set(['-u', '-g', '-C', '-h', '-p', '-U', '-r', '-t', '-D', '--user', '--group']) }, + doas: { values: new Set(['-u']) }, + timeout: { values: new Set(['-k', '-s', '--kill-after', '--signal']), positionals: 1 }, + nice: { values: new Set(['-n', '--adjustment']) }, + time: { values: new Set(['-o', '-f']) }, + xargs: { values: new Set(['-n', '-I', '-P', '-L', '-s', '-d', '-E', '-a', '-i']) }, + rtk: { values: new Set() }, + bunx: { values: new Set(['-p', '--package']) }, + npx: { values: new Set(['-p', '--package', '-c', '--call']) }, + pnpx: { values: new Set() }, +}; +const GIT_MUTATING = new Set(['commit', 'push', 'merge', 'rebase', 'cherry-pick', 'revert', 'am']); +const GIT_RESUMABLE = new Set(['merge', 'rebase', 'cherry-pick', 'revert', 'am']); const GIT_WORKTREE_MUTATING = new Set(['add', 'remove', 'move', 'prune']); const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']); -const GIT_RESUMED = /\s--(?:abort|continue|quit|skip)(?=\s|$)/; -const DRY_RUN = /\s--dry-run(?=\s|$)/; +const GIT_RESUMED = new Set(['--abort', '--continue', '--quit', '--skip']); const BUN_MUTATING = new Set(['add', 'a', 'remove', 'rm', 'update']); const BUN_INSTALL = new Set(['install', 'i']); const BUN_SCRIPTS = new Set(['db:migrate']); @@ -60,32 +56,112 @@ const BUN_OPTIONS_WITH_VALUE = new Set([ '--cwd', '--filter', '-F', - '--env-file', + '--backend', + '--registry', '--config', '-c', + '--target', + '--env-file', '--port', '--shell', + '--linker', + '--omit', + '--concurrent-scripts', + '--network-concurrency', ]); -const PRISMA_MIGRATE_DEV = /^prisma\s+migrate\s+dev(?=\s|$)/; -const QUOTED = /^(["'])([\s\S]*?)\1/; -const ANY_QUOTED = /(["'])([\s\S]*?)\1/g; +const CD_OPTIONS = new Set(['-P', '-L', '-e', '-@', '--']); +const SHELL_OPTIONS_WITH_VALUE = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file']); -const joinContinuations = (command: string) => command.replace(/\\\r?\n/g, ' '); +type Piece = + | { kind: 'open' | 'close' } + | { kind: 'command'; raw: string; masked: string; terminator: string; substitutions: string[] }; -type Segment = { raw: string; masked: string }; +const isWordBoundary = (ch: string | undefined) => ch === undefined || /[\s;&|(){}]/.test(ch); -export const segmentCommand = (command: string): Segment[] => { - const text = joinContinuations(command); - const segments: Segment[] = []; +export const segmentCommand = (command: string): Piece[] => { + const text = command.replace(/\\\r?\n/g, ''); + const pieces: Piece[] = []; let raw = ''; let masked = ''; + let substitutions: string[] = []; let pendingHeredocs: { delimiter: string; stripTabs: boolean }[] = []; - const flush = () => { + + const flush = (terminator: string) => { const lead = masked.length - masked.trimStart().length; const trail = masked.length - masked.trimEnd().length; - if (masked.trim()) segments.push({ raw: raw.slice(lead, raw.length - trail), masked: masked.trim() }); + if (masked.trim()) { + pieces.push({ + kind: 'command', + raw: raw.slice(lead, raw.length - trail), + masked: masked.trim(), + terminator, + substitutions, + }); + } raw = ''; masked = ''; + substitutions = []; + }; + const add = (r: string, m = r) => { + raw += r; + masked += m; + }; + const scanSingle = (from: number) => { + const end = text.indexOf("'", from + 1); + return end === -1 ? text.length : end; + }; + const scanBacktick = (from: number) => { + let j = from + 1; + while (j < text.length && text[j] !== '`') j += text[j] === '\\' ? 2 : 1; + return Math.min(j, text.length); + }; + const scanParen = (from: number): number => { + let depth = 1; + let j = from + 1; + while (j < text.length) { + const ch = text[j]; + if (ch === '\\') j += 2; + else if (ch === "'") j = scanSingle(j) + 1; + else if (ch === '"') j = scanDouble(j) + 1; + else if (ch === '`') j = scanBacktick(j) + 1; + else if (ch === '(') { + depth += 1; + j += 1; + } else if (ch === ')') { + depth -= 1; + if (depth === 0) return j; + j += 1; + } else j += 1; + } + return text.length; + }; + const scanDouble = (from: number): number => { + let j = from + 1; + while (j < text.length) { + const ch = text[j]; + if (ch === '\\') j += 2; + else if (ch === '"') return j; + else if (ch === '$' && text[j + 1] === '(') j = scanParen(j + 1) + 1; + else if (ch === '`') j = scanBacktick(j) + 1; + else j += 1; + } + return text.length; + }; + const collectSubstitutions = (from: number, to: number) => { + let j = from; + while (j < to) { + const ch = text[j]; + if (ch === '\\') j += 2; + else if (ch === '$' && text[j + 1] === '(') { + const end = scanParen(j + 1); + substitutions.push(text.slice(j + 2, end)); + j = end + 1; + } else if (ch === '`') { + const end = scanBacktick(j); + substitutions.push(text.slice(j + 1, end)); + j = end + 1; + } else j += 1; + } }; const skipHeredocBodies = (from: number): number => { let i = from; @@ -100,66 +176,234 @@ export const segmentCommand = (command: string): Segment[] => { pendingHeredocs = []; return i; }; + const masking = (from: number, to: number) => text.slice(from, to).replace(/[^\n]/g, MASK); + let i = 0; while (i < text.length) { - const rest = text.slice(i); const ch = text[i]; - if (ch === "'" || ch === '"') { - let end = i + 1; - while (end < text.length && text[end] !== ch) end += ch === '"' && text[end] === '\\' ? 2 : 1; - const inner = text.slice(i + 1, Math.min(end, text.length)); - const close = end < text.length ? ch : ''; - raw += ch + inner + close; - masked += ch + MASK.repeat(inner.length) + close; + const prev = i === 0 ? undefined : text[i - 1]; + const next = text[i + 1]; + if (ch === '\\') { + add(text.slice(i, i + 2)); + i += 2; + continue; + } + if (ch === "'") { + const end = scanSingle(i); + const inner = text.slice(i + 1, end); + const close = end < text.length ? "'" : ''; + add(`'${inner}${close}`, `'${MASK.repeat(inner.length)}${close}`); + i = end + 1; + continue; + } + if (ch === '"') { + const end = scanDouble(i); + collectSubstitutions(i + 1, end); + const inner = text.slice(i + 1, end); + const close = end < text.length ? '"' : ''; + add(`"${inner}${close}`, `"${MASK.repeat(inner.length)}${close}`); i = end + 1; continue; } - const heredoc = rest.match(HEREDOC_AT); - if (heredoc && !rest.startsWith('<<<')) { + if (ch === '`') { + const end = scanBacktick(i); + substitutions.push(text.slice(i + 1, end)); + const stop = Math.min(end + 1, text.length); + add(text.slice(i, stop), masking(i, stop)); + i = stop; + continue; + } + if (ch === '$' && next === '(') { + const end = scanParen(i + 1); + substitutions.push(text.slice(i + 2, end)); + const stop = Math.min(end + 1, text.length); + add(text.slice(i, stop), masking(i, stop)); + i = stop; + continue; + } + if (ch === '$' && next === '{') { + let depth = 0; + let j = i + 1; + while (j < text.length) { + if (text[j] === '{') depth += 1; + else if (text[j] === '}') { + depth -= 1; + if (depth === 0) break; + } + j += 1; + } + const stop = Math.min(j + 1, text.length); + add(text.slice(i, stop), `$${MASK.repeat(stop - i - 1)}`); + i = stop; + continue; + } + if (ch === '#' && isWordBoundary(prev)) { + const lineEnd = text.indexOf('\n', i); + i = lineEnd === -1 ? text.length : lineEnd; + continue; + } + if (text.startsWith('<<<', i)) { + add('<<<'); + i += 3; + continue; + } + const heredoc = text.slice(i).match(HEREDOC_AT); + if (heredoc) { pendingHeredocs.push({ delimiter: heredoc[3], stripTabs: heredoc[1] === '-' }); - raw += heredoc[0]; - masked += heredoc[0]; + add(heredoc[0]); i += heredoc[0].length; continue; } - const separator = rest.match(SEPARATOR_AT); - if (separator) { - flush(); - i += separator[0].length; - if (separator[0].endsWith('\n') && pendingHeredocs.length > 0) i = skipHeredocBodies(i); + if (ch === '\n' || (ch === '\r' && next === '\n')) { + flush('\n'); + i += ch === '\r' ? 2 : 1; + if (pendingHeredocs.length > 0) i = skipHeredocBodies(i); + continue; + } + if (ch === ';') { + flush(';'); + i += next === ';' ? 2 : 1; + continue; + } + if (ch === '&' && next === '&') { + flush('&&'); + i += 2; + continue; + } + if (ch === '|' && next === '|') { + flush('||'); + i += 2; + continue; + } + if (ch === '|') { + flush('|'); + i += next === '&' ? 2 : 1; continue; } - raw += ch; - masked += ch; + if (ch === '&' && prev !== '>' && prev !== '<' && next !== '>') { + flush('&'); + i += 1; + continue; + } + if (ch === '(') { + flush('('); + pieces.push({ kind: 'open' }); + i += 1; + continue; + } + if (ch === ')') { + flush(')'); + pieces.push({ kind: 'close' }); + i += 1; + continue; + } + if ((ch === '{' || ch === '}') && isWordBoundary(prev) && (next === undefined || /[\s;&|)]/.test(next))) { + flush(ch); + i += 1; + continue; + } + add(ch); i += 1; } - flush(); - return segments; + flush(''); + return pieces; }; -const take = (segment: Segment, pattern: RegExp): RegExpMatchArray | undefined => { - const match = segment.masked.match(pattern); - if (!match) return undefined; - segment.raw = segment.raw.slice(match[0].length); - segment.masked = segment.masked.slice(match[0].length); - return match; -}; +type Word = { raw: string; masked: string }; -const stripPrefixes = (segment: Segment) => { - while (take(segment, LEADING_KEYWORD) || take(segment, LEADING_RUNNER)) { - if (!segment.masked) return; +const tokenize = (raw: string, masked: string): Word[] => { + const words: Word[] = []; + const pattern = /\S+/g; + for (const match of masked.matchAll(pattern)) { + const start = match.index ?? 0; + words.push({ raw: raw.slice(start, start + match[0].length), masked: match[0] }); } + return words; }; const unquote = (token: string) => { - const quoted = token.match(QUOTED); - return quoted && quoted[0].length === token.length ? quoted[2] : token; + const trimmed = token.startsWith('$') && /^\$'/.test(token) ? token.slice(1) : token; + const match = trimmed.match(/^(["'])([\s\S]*)\1$/); + return match ? match[2] : trimmed; }; -const rawWord = (segment: Segment, maskedWord: string | undefined): string | undefined => { - if (!maskedWord) return undefined; - const index = segment.masked.indexOf(maskedWord); - return index === -1 ? undefined : segment.raw.slice(index, index + maskedWord.length); +const headOf = (word: Word): string | undefined => { + if (/\$\(|`|\$\{/.test(word.raw)) return undefined; + let head = word.raw.replace(/^\\/, '').replace(/\$'/g, "'").replace(/["']/g, ''); + if (head.includes('/')) head = basename(head); + return head; +}; + +const isAssignment = (word: Word) => /^[A-Za-z_]\w*=/.test(word.raw); + +type Prefix = { words: Word[]; bare: string; gitDir?: string; gitWorkTree?: string }; + +const stripPrefixes = (words: Word[], raw: string): Prefix => { + let rest = words; + let bareStart = 0; + let gitDir: string | undefined; + let gitWorkTree: string | undefined; + const consumeKeywords = () => { + while (rest.length > 0 && KEYWORDS.has(rest[0].raw)) { + bareStart += 1; + rest = rest.slice(1); + } + }; + consumeKeywords(); + const bare = tokenize(raw, raw) + .slice(bareStart) + .map((w) => w.raw) + .join(' '); + for (let guard = 0; guard < 32 && rest.length > 0; guard += 1) { + const word = rest[0]; + if (isAssignment(word)) { + const [name, value] = word.raw.split(/=(.*)/s); + if (name === 'GIT_DIR') gitDir = value; + if (name === 'GIT_WORK_TREE') gitWorkTree = value; + rest = rest.slice(1); + continue; + } + const head = headOf(word); + if (!head) break; + if (head === 'bun' && rest[1]?.raw === 'x') { + rest = rest.slice(2); + continue; + } + if (head === 'rtk') { + rest = rest.slice(rest[1]?.raw === 'proxy' ? 2 : 1); + continue; + } + const runner = RUNNERS[head]; + if (!runner) break; + let j = 1; + let positionals = runner.positionals ?? 0; + let stopped = false; + while (j < rest.length) { + const current = rest[j].raw; + if (current === '--') { + j += 1; + break; + } + if (runner.stopOn?.has(current)) { + stopped = true; + break; + } + if (current.startsWith('-') && current.length > 1) { + const [name, inline] = current.split(/=(.*)/s); + j += inline === undefined && runner.values.has(name) ? 2 : 1; + continue; + } + if (positionals > 0) { + positionals -= 1; + j += 1; + continue; + } + break; + } + if (stopped) return { words: [], bare, gitDir, gitWorkTree }; + rest = rest.slice(j); + } + return { words: rest, bare, gitDir, gitWorkTree }; }; type Checkout = { root: string; isMain: boolean }; @@ -192,10 +436,15 @@ const isWithin = (path: string, root: string) => path === root || path.startsWit type Target = { directory: string; named?: string }; -const resolveTarget = (rawToken: string, from: Target | undefined): Target | undefined => { +const expandToken = (rawToken: string): string | undefined => { const token = unquote(rawToken); if (!token || /[$`*?]/.test(token) || token === '-') return undefined; - const expanded = token === '~' || token.startsWith('~/') ? join(homedir(), token.slice(1)) : token; + return token === '~' || token.startsWith('~/') ? join(homedir(), token.slice(1)) : token; +}; + +const resolveTarget = (rawToken: string, from: Target | undefined): Target | undefined => { + const expanded = expandToken(rawToken); + if (!expanded) return undefined; if (isAbsolute(expanded)) { const named = resolve(expanded); return isDirectory(named) ? { directory: realpathSync(named), named } : undefined; @@ -205,100 +454,191 @@ const resolveTarget = (rawToken: string, from: Target | undefined): Target | und return isDirectory(path) ? { directory: realpathSync(path) } : undefined; }; -type Mutation = { command: string; target?: string }; +const resolveRedirect = (rawToken: string, from: Target | undefined, gitDir: boolean): string | undefined => { + const expanded = expandToken(rawToken); + if (!expanded) return undefined; + if (!isAbsolute(expanded) && !from) return undefined; + const path = isAbsolute(expanded) ? resolve(expanded) : resolve(from?.directory as string, expanded); + if (!existsSync(path)) return undefined; + const real = realpathSync(path); + if (!gitDir) return statSync(real).isDirectory() ? real : dirname(real); + return statSync(real).isDirectory() && basename(real) !== '.git' ? real : dirname(real); +}; + +type Mutation = { target?: string; redirect?: { via: string; path: string } }; -type Invocation = { subcommand?: string; rest: string[]; target?: string }; +type Parsed = { positionals: Word[]; target?: string; options: Set; gitDir?: string; gitWorkTree?: string }; -const parseInvocation = ( - words: string[], - optionsWithValue: Set, - targetOption: string, - from = 1, -): Invocation => { +const parseGit = (words: Word[]): Parsed => { + const options = new Set(); let target: string | undefined; - let i = from; - while (i < words.length && words[i].startsWith('-')) { - const [name, inlineValue] = words[i].split(/=(.*)/s); - if (name === targetOption) target = inlineValue ?? words[i + 1]; - i += inlineValue === undefined && optionsWithValue.has(name) ? 2 : 1; + let gitDir: string | undefined; + let gitWorkTree: string | undefined; + let i = 1; + while (i < words.length && words[i].masked.startsWith('-')) { + const [name, inline] = words[i].masked.split(/=(.*)/s); + const value = + inline === undefined && GIT_OPTIONS_WITH_VALUE.has(name) ? words[i + 1]?.raw : words[i].raw.split(/=(.*)/s)[1]; + if (name === '-C') target = value; + if (name === '--git-dir') gitDir = value; + if (name === '--work-tree') gitWorkTree = value; + i += inline === undefined && GIT_OPTIONS_WITH_VALUE.has(name) ? 2 : 1; } - return { subcommand: words[i], rest: words.slice(i + 1), target }; + const positionals = words.slice(i); + for (const word of positionals) if (word.masked.startsWith('-')) options.add(word.masked); + return { positionals: positionals.filter((w) => !w.masked.startsWith('-')), target, options, gitDir, gitWorkTree }; }; -const isPrismaMigrateDev = (subcommand: string | undefined, rest: string[]) => - subcommand === 'prisma' && PRISMA_MIGRATE_DEV.test(['prisma', ...rest].join(' ')); - -const bunMutation = (segment: Segment, words: string[]): Mutation | undefined => { - const { masked } = segment; - const outer = parseInvocation(words, BUN_OPTIONS_WITH_VALUE, '--cwd'); - if (!outer.subcommand) return undefined; - const outerTarget = rawWord(segment, outer.target); - if (BUN_MUTATING.has(outer.subcommand)) return { command: masked, target: outerTarget }; - if (BUN_INSTALL.has(outer.subcommand) && outer.rest.some((word) => !word.startsWith('-'))) - return { command: masked, target: outerTarget }; - if (BUN_SCRIPTS.has(outer.subcommand) || isPrismaMigrateDev(outer.subcommand, outer.rest)) - return { command: masked, target: outerTarget }; - if (outer.subcommand !== 'run') return undefined; - const inner = parseInvocation(['run', ...outer.rest], BUN_OPTIONS_WITH_VALUE, '--cwd'); - const target = rawWord(segment, inner.target ?? outer.target); - if (!inner.subcommand) return undefined; - if (BUN_SCRIPTS.has(inner.subcommand) || isPrismaMigrateDev(inner.subcommand, inner.rest)) - return { command: masked, target }; - return undefined; +const parseBun = (words: Word[]): Parsed => { + const options = new Set(); + const positionals: Word[] = []; + let target: string | undefined; + let i = 1; + while (i < words.length) { + const word = words[i]; + if (word.masked.startsWith('-') && word.masked.length > 1) { + const [name, inline] = word.masked.split(/=(.*)/s); + const takesValue = inline === undefined && BUN_OPTIONS_WITH_VALUE.has(name); + options.add(name); + if (name === '--cwd') target = takesValue ? words[i + 1]?.raw : word.raw.split(/=(.*)/s)[1]; + i += takesValue ? 2 : 1; + continue; + } + positionals.push(word); + i += 1; + } + return { positionals, target, options }; }; -const mutationIn = (segment: Segment): Mutation | undefined => { - const { masked } = segment; - const padded = ` ${masked}`; - if (DRY_RUN.test(padded)) return undefined; - const words = masked.split(/\s+/); - if (words[0] === 'git') { - const { subcommand, rest, target: maskedTarget } = parseInvocation(words, GIT_OPTIONS_WITH_VALUE, '-C'); - const target = rawWord(segment, maskedTarget); +const isPrismaMigrateDev = (words: Word[]) => + words[0]?.masked === 'prisma' && words[1]?.masked === 'migrate' && words[2]?.masked === 'dev'; + +const mutationIn = (head: string, words: Word[], prefix: Prefix): Mutation | undefined => { + if (head === 'git') { + const parsed = parseGit(words); + if (parsed.options.has('--dry-run')) return undefined; + const subcommand = parsed.positionals[0]?.masked; if (!subcommand) return undefined; + const gitDir = parsed.gitDir ?? prefix.gitDir; + const gitWorkTree = parsed.gitWorkTree ?? prefix.gitWorkTree; + const redirect = gitWorkTree + ? { via: parsed.gitWorkTree ? '--work-tree' : 'GIT_WORK_TREE', path: gitWorkTree } + : gitDir + ? { via: parsed.gitDir ? '--git-dir' : 'GIT_DIR', path: gitDir } + : undefined; if (GIT_MUTATING.has(subcommand)) { - if (GIT_RESUMABLE.has(subcommand) && GIT_RESUMED.test(padded)) return undefined; - return { command: masked, target }; + if (GIT_RESUMABLE.has(subcommand) && [...parsed.options].some((o) => GIT_RESUMED.has(o))) return undefined; + return { target: parsed.target, redirect }; } - if (subcommand === 'worktree' && GIT_WORKTREE_MUTATING.has(rest[0])) return { command: masked, target }; + if (subcommand === 'worktree' && GIT_WORKTREE_MUTATING.has(parsed.positionals[1]?.masked ?? '')) + return { target: parsed.target, redirect }; return undefined; } - if (words[0] === 'bun') return bunMutation(segment, words); - if (PRISMA_MIGRATE_DEV.test(masked)) return { command: masked }; + if (head === 'bun') { + const parsed = parseBun(words); + if (parsed.options.has('--dry-run')) return undefined; + const subcommand = parsed.positionals[0]?.masked; + if (!subcommand) return undefined; + const rest = parsed.positionals.slice(1); + if (BUN_MUTATING.has(subcommand)) return { target: parsed.target }; + if (BUN_INSTALL.has(subcommand)) return rest.length > 0 ? { target: parsed.target } : undefined; + if (BUN_SCRIPTS.has(subcommand) || isPrismaMigrateDev(parsed.positionals)) return { target: parsed.target }; + if (subcommand !== 'run') return undefined; + const script = rest[0]?.masked; + if (script && (BUN_SCRIPTS.has(script) || isPrismaMigrateDev(rest))) return { target: parsed.target }; + return undefined; + } + if (head === 'prisma') + return isPrismaMigrateDev(words) && !words.some((w) => w.masked === '--dry-run') ? {} : undefined; return undefined; }; -export type Landing = { command: string; directory?: string; named?: string; namedTarget?: string }; +export type Landing = { + command: string; + directory?: string; + named?: string; + namedTarget?: string; + redirect?: string; + reason?: string; +}; + +const shellCommandString = (words: Word[]): string | undefined => { + let dashC = false; + for (let i = 1; i < words.length; i += 1) { + const word = words[i]; + if (word.masked === '--') return dashC ? unquote(words[i + 1]?.raw ?? '') : undefined; + if (word.masked.startsWith('-') || word.masked.startsWith('+')) { + if (/^-[A-Za-z]*c[A-Za-z]*$/.test(word.masked)) dashC = true; + if (SHELL_OPTIONS_WITH_VALUE.has(word.masked) || /^[-+][A-Za-z]*[oO]$/.test(word.masked)) i += 1; + continue; + } + return dashC ? unquote(word.raw) : undefined; + } + return undefined; +}; -const collectMutations = (command: string, cwd: Target | undefined, landings: Landing[]) => { +const collectMutations = (command: string, cwd: Target | undefined, landings: Landing[], depth: number) => { + if (depth > MAX_DEPTH) { + landings.push({ command, reason: 'nested more deeply than this hook analyses' }); + return; + } let cursor: Target | undefined = cwd; - for (const segment of segmentCommand(command)) { - stripPrefixes(segment); - if (!segment.masked) continue; - const changeDir = take(segment, CHANGE_DIR); - if (changeDir) { - if (changeDir[1] === 'popd') { + const stack: (Target | undefined)[] = []; + for (const piece of segmentCommand(command)) { + if (piece.kind !== 'command') { + if (piece.kind === 'open') stack.push(cursor); + else if (stack.length > 0) cursor = stack.pop(); + continue; + } + for (const substitution of piece.substitutions) collectMutations(substitution, cursor, landings, depth + 1); + const prefix = stripPrefixes(tokenize(piece.raw, piece.masked), piece.raw); + const words = prefix.words; + if (words.length === 0) continue; + const head = headOf(words[0]); + if (!head) continue; + if (head === 'cd' || head === 'pushd' || head === 'popd') { + const backgrounded = piece.terminator === '|' || piece.terminator === '&'; + if (backgrounded) continue; + if (head === 'popd') { cursor = undefined; continue; } - const token = segment.masked.match(/^\S+/)?.[0]; - cursor = resolveTarget(token ? (rawWord(segment, token) ?? token) : '~', cursor); + const target = words.slice(1).find((w) => !CD_OPTIONS.has(w.masked) && !/^-[PLe@]+$/.test(w.masked)); + cursor = resolveTarget(target?.raw ?? '~', cursor); continue; } - if (take(segment, SHELL_C)) { - collectMutations(segment.raw.match(QUOTED)?.[2] ?? segment.raw, cursor, landings); + if (SHELLS.has(head)) { + const inner = shellCommandString(words); + if (inner) collectMutations(inner, cursor, landings, depth + 1); continue; } - if (take(segment, EVAL)) { - const quoted = [...segment.raw.matchAll(ANY_QUOTED)].map((match) => match[2]); - collectMutations(quoted.length > 0 ? quoted.join(' ') : segment.raw, cursor, landings); + if (head === 'eval') { + const inner = words + .slice(1) + .map((w) => unquote(w.raw)) + .join(' '); + collectMutations(inner, cursor, landings, depth + 1); continue; } - const mutation = mutationIn(segment); + const mutation = mutationIn(head, words, prefix); if (!mutation) continue; + if (mutation.redirect) { + const directory = resolveRedirect( + mutation.redirect.path, + cursor, + mutation.redirect.via.endsWith('DIR') || mutation.redirect.via === '--git-dir', + ); + landings.push({ + command: prefix.bare, + directory, + redirect: mutation.redirect.via, + namedTarget: mutation.redirect.path, + }); + continue; + } const target = mutation.target ? resolveTarget(mutation.target, cursor) : cursor; landings.push({ - command: mutation.command, + command: prefix.bare, directory: target?.directory, named: target?.named, namedTarget: mutation.target, @@ -308,7 +648,7 @@ const collectMutations = (command: string, cwd: Target | undefined, landings: La export const mutationsIn = (command: string, cwd?: string): Landing[] => { const landings: Landing[] = []; - collectMutations(command, cwd && isDirectory(cwd) ? { directory: realpathSync(cwd) } : undefined, landings); + collectMutations(command, cwd && isDirectory(cwd) ? { directory: realpathSync(cwd) } : undefined, landings, 0); return landings; }; @@ -352,21 +692,44 @@ export type SessionState = { prune: (maxAgeMs: number) => void; }; +const reportOnce = (() => { + let reported = false; + return (message: string) => { + if (reported) return; + reported = true; + console.error(`bashMainCheckoutGuard: ${message}`); + }; +})(); + export const fileSessionState = (dir = join(tmpdir(), 'template-worktree-guard')): SessionState => { - const file = (sessionId: string) => join(dir, sessionId.replace(/[^\w.-]/g, '_')); + const file = (sessionId: string) => join(dir, createHash('sha256').update(sessionId).digest('hex').slice(0, 32)); return { - lastWorktree: (sessionId) => - existsSync(file(sessionId)) ? readFileSync(file(sessionId), 'utf8').trim() : undefined, + lastWorktree: (sessionId) => { + try { + return existsSync(file(sessionId)) ? readFileSync(file(sessionId), 'utf8').trim() : undefined; + } catch (error) { + reportOnce(`could not read session state: ${(error as Error).message}`); + return undefined; + } + }, rememberWorktree: (sessionId, worktreeRoot) => { - mkdirSync(dir, { recursive: true }); - writeFileSync(file(sessionId), `${worktreeRoot}\n`); + try { + mkdirSync(dir, { recursive: true }); + writeFileSync(file(sessionId), `${worktreeRoot}\n`); + } catch (error) { + reportOnce(`could not write session state: ${(error as Error).message}`); + } }, prune: (maxAgeMs) => { - if (!existsSync(dir)) return; - const cutoff = Date.now() - maxAgeMs; - for (const name of readdirSync(dir)) { - const path = join(dir, name); - if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true }); + try { + if (!existsSync(dir)) return; + const cutoff = Date.now() - maxAgeMs; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true }); + } + } catch (error) { + reportOnce(`could not prune session state: ${(error as Error).message}`); } }, }; @@ -377,13 +740,15 @@ export type GuardOptions = { requireMainBranch?: boolean }; type MainCheckout = { root: string; real: string }; const denialFor = (landing: Landing, main: MainCheckout, registered: string[]): string | undefined => { - const { command, directory, named, namedTarget } = landing; + const { command, directory, named, namedTarget, redirect, reason } = landing; + if (reason) return ` \`${command}\` is ${reason}.`; if (!directory) { return namedTarget ? ` \`${command}\` names ${namedTarget}, which does not exist or cannot be resolved.` : ` \`${command}\` runs in a directory this hook cannot resolve.`; } if (registered.some((root) => isWithin(directory, root))) return undefined; + if (redirect) return ` \`${command}\` targets ${directory} through ${redirect}, which is the main checkout.`; const checkout = findCheckout(directory); if (!checkout) return ` \`${command}\` would run in ${directory}, which is not a git checkout.`; if (checkout.isMain && isWithin(directory, main.real)) { @@ -409,23 +774,25 @@ export const guard = (input: HookInput, state: SessionState, options: GuardOptio if (input.tool_name !== 'Bash' || !command) return undefined; const worktree = state.lastWorktree(input.session_id); - if (!worktree || findCheckout(worktree)?.isMain !== false) return undefined; + if (!worktree || !isDirectory(worktree)) return undefined; + const registered = registeredWorktrees(checkout.root); + const worktreeReal = realpathSync(worktree); + if (!registered.some((root) => isWithin(worktreeReal, root))) return undefined; const branch = currentBranch(checkout.root); if (requireMainBranch && branch !== defaultBranch(checkout.root)) return undefined; const landings = mutationsIn(command, input.cwd); if (landings.length === 0) return undefined; const main = { root: checkout.root, real: realpathSync(checkout.root) }; - const registered = registeredWorktrees(checkout.root); - const denials = landings.map((landing) => denialFor(landing, main, registered)).filter((line) => line); - if (denials.length === 0) return undefined; + const denied = landings.filter((landing) => denialFor(landing, main, registered)); + if (denied.length === 0) return undefined; return [ `This session has been working in the worktree ${worktree}, but the Bash cwd is now the MAIN checkout (${checkout.root}, branch ${branch}) — the session cwd resets to main between calls.`, - ...denials, + ...denied.map((landing) => denialFor(landing, main, registered)), 'Dependency changes, commits, pushes, merges, worktree registry changes, and new migrations would land on main. Run it from the worktree:', - ` cd ${worktree} && ${command}`, - `If the main checkout really is the target, name it by its own absolute path: cd ${checkout.root} && ${command}`, + ...denied.map((landing) => ` cd ${worktree} && ${landing.command}`), + `If the main checkout really is the target, name it by its own absolute path: cd ${checkout.root} && ${denied[0].command}`, ].join('\n'); }; @@ -450,6 +817,6 @@ if (import.meta.main) { try { main(); } catch (error) { - if (process.env.WORKTREE_GUARD_DEBUG) console.error(error); + reportOnce(`failed open: ${(error as Error).message}`); } } diff --git a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts index f1df5495c..c749512db 100644 --- a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts +++ b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts @@ -1,5 +1,16 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; -import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { @@ -20,6 +31,7 @@ let mainRoot: string; let worktreeRoot: string; let plainDir: string; let foreignRoot: string; +let foreignWorktree: string; let unregisteredRoot: string; let linkToMain: string; let linkToWorktree: string; @@ -90,6 +102,9 @@ beforeAll(() => { foreignRoot = join(sandbox, 'other-repo'); git('init', '-q', '-b', 'main', foreignRoot); + git('-C', foreignRoot, 'commit', '-q', '--allow-empty', '-m', 'init'); + foreignWorktree = join(foreignRoot, '.worktrees/OTHER-1'); + git('-C', foreignRoot, 'worktree', 'add', '-q', foreignWorktree, '-b', 'OTHER-1'); unregisteredRoot = join(sandbox, 'unregistered-copy'); cpSync(worktreeRoot, unregisteredRoot, { recursive: true }); @@ -121,6 +136,8 @@ describe('isMutating', () => { 'git -c core.editor=true rebase origin/main', 'git --git-dir=.git cherry-pick abc', 'git merge feature', + 'git revert abc', + 'git am patch.mbox', 'git worktree add ../x', 'git worktree remove x', 'git worktree move x ../y', @@ -154,11 +171,28 @@ describe('isMutating', () => { } }); + test('C3: option values after the subcommand are consumed, not read as packages', () => { + for (const command of [ + `bun install --cwd ${'/tmp'}`, + 'bun install --filter web', + 'bun install --backend hardlink', + 'bun install --registry https://r.example', + 'bun install --config bunfig.toml', + 'bun i --cwd=apps/api', + ]) { + expect(isMutating(command), command).toBe(false); + } + expect(isMutating('bun add x --cwd apps/api')).toBe(true); + expect(isMutating('bun add --cwd apps/api x')).toBe(true); + expect(isMutating('bun install --filter web left-pad')).toBe(true); + }); + test('addendum C: every bun --cwd / run form of the migrate script', () => { for (const command of [ 'bun --cwd packages/db db:migrate', 'bun run --cwd packages/db db:migrate', 'bun --cwd packages/db run db:migrate', + 'bun run db:migrate --cwd packages/db', 'bun --cwd packages/db prisma migrate dev', 'bun run --cwd packages/db prisma migrate dev', ]) { @@ -189,6 +223,11 @@ describe('isMutating', () => { } }); + test('Z7: a backslash-newline inside a word is removed, not turned into a space', () => { + expect(isMutating('git pu\\\nsh')).toBe(true); + expect(isMutating('bun a\\\ndd x')).toBe(true); + }); + test('finding 2: wrappers, env assignments, shell -c, eval, and compound bodies are seen through', () => { for (const command of [ 'FOO=1 git push', @@ -228,6 +267,54 @@ describe('isMutating', () => { } }); + test('C8: word-0 spellings and runner options are normalised', () => { + for (const command of [ + '\\git push', + '"git" push', + 'gi""t push', + "'git' push", + '/usr/bin/git push', + "$'git' push", + 'command -p git push', + 'exec -a x git push', + 'env -u FOO git push', + 'time -p git push', + 'sudo -u me git push', + 'nohup -- git push', + 'xargs -n 1 git push', + 'xargs -I {} git push {}', + "bash -o pipefail -c 'git push'", + 'bash -eo pipefail -c "git push"', + 'echo "$(git push)"', + 'echo "`git push`"', + 'x=$(git push)', + 'echo `git push`', + './node_modules/.bin/bun add x', + ]) { + expect(isMutating(command), command).toBe(true); + } + expect(isMutating('command -v git')).toBe(false); + expect(isMutating('command -V git push')).toBe(false); + }); + + test('C9: a comment does not split the line, and the text before it is analysed as usual', () => { + expect(isMutating('echo hi # ; git push')).toBe(false); + expect(isMutating('echo hi # git push\nls')).toBe(false); + expect(isMutating('git push # done')).toBe(true); + expect(isMutating('echo $# && git push')).toBe(true); + }); + + test('Z3: a here-string is not a heredoc', () => { + expect(isMutating('cat <<< "x"\ngit push')).toBe(true); + expect(isMutating('cat <<< "git push"')).toBe(false); + }); + + test('Z7: double quotes inside $( ) are balanced', () => { + expect(isMutating('echo "$(echo "a"; git push)"')).toBe(true); + expect(isMutating('echo "$(echo "a")" && git status')).toBe(false); + expect(isMutating('echo "$(echo ")")"; git push')).toBe(true); + }); + test('finding 4: quoted strings and heredoc bodies are never matched', () => { for (const command of [ 'gh pr comment 1 --body "fix; git push later"', @@ -255,6 +342,8 @@ describe('isMutating', () => { 'git rebase --continue', 'git rebase --quit', 'git cherry-pick --skip', + 'git revert --continue', + 'git am --abort', 'git worktree list', 'git worktree prune --dry-run', 'git commit --dry-run', @@ -264,6 +353,12 @@ describe('isMutating', () => { } }); + test('by design outside the set: pull, reset, checkout', () => { + for (const command of ['git pull', 'git pull --rebase', 'git reset --hard HEAD~1', 'git checkout -- .']) { + expect(isMutating(command), command).toBe(false); + } + }); + test('finding 14: only the real migrate script names count', () => { expect(isMutating('bun run db:migrate')).toBe(true); expect(isMutating('bun run db:migrate:anything')).toBe(false); @@ -291,9 +386,16 @@ describe('isMutating', () => { test('a command after a heredoc body is still analysed', () => { expect(isMutating('cat < { + const nested = 'echo $(echo $(echo $(echo $(echo $(echo $(echo $(git push)))))))'; + const landings = mutationsIn(nested, mainRoot); + expect(landings.length).toBe(1); + expect(landings[0].reason).toContain('nested more deeply'); + }); }); -describe('mutationsIn: effective directory per segment (finding 3, addendum C)', () => { +describe('mutationsIn: effective directory per segment', () => { test('a cd applies to the segments after it, in order', () => { expect(directories(`cd ${worktreeRoot} && git push`)).toEqual([worktreeRoot]); expect(directories(`cd ${worktreeRoot} && cd .. && git push`)).toEqual([join(mainRoot, '.worktrees')]); @@ -306,15 +408,45 @@ describe('mutationsIn: effective directory per segment (finding 3, addendum C)', expect(directories(`cd ${worktreeRoot};git push`)).toEqual([worktreeRoot]); expect(directories(`cd ${worktreeRoot}&&git push`)).toEqual([worktreeRoot]); expect(directories(`cd ${worktreeRoot}||git push`)).toEqual([worktreeRoot]); - expect(directories(`cd ${worktreeRoot}|git push`)).toEqual([worktreeRoot]); }); - test('git -C and bun --cwd apply only to their own segment', () => { + test('C2: a cd inside a subshell, a pipe, or a background job does not move the outer cursor', () => { + expect(directories(`(cd ${worktreeRoot}) && git push`)).toEqual([mainRoot]); + expect(directories(`(cd ${worktreeRoot} && bun run check) && git commit -m x`)).toEqual([mainRoot]); + expect(directories(`(cd ${worktreeRoot}); git push`)).toEqual([mainRoot]); + expect(directories(`cd ${worktreeRoot} | cat; git push`)).toEqual([mainRoot]); + expect(directories(`cd ${worktreeRoot} & git push`)).toEqual([mainRoot]); + expect(directories(`cd ${worktreeRoot} |& cat; git push`)).toEqual([mainRoot]); + expect(directories(`{ cd ${worktreeRoot}; } && git push`)).toEqual([worktreeRoot]); + expect(directories(`(cd ${worktreeRoot} && git push)`)).toEqual([worktreeRoot]); + expect(directories(`(cd ${worktreeRoot}; (cd ..); git push)`)).toEqual([worktreeRoot]); + expect(directories('echo done 2>&1 && git push')).toEqual([mainRoot]); + }); + + test('C9: cd options are not mistaken for the target', () => { + expect(directories(`cd -P ${worktreeRoot} && git push`)).toEqual([worktreeRoot]); + expect(directories(`cd -L -- ${worktreeRoot} && git push`)).toEqual([worktreeRoot]); + expect(mutationsIn(`cd -P && git push`, mainRoot)[0].directory).not.toBe(mainRoot); + }); + + test('Z4: a substitution or unexpanded variable in a cd target makes the cursor unknown', () => { + expect(mutationsIn(`cd ${worktreeRoot} && cd $(pwd) && git push`, mainRoot)[0].directory).toBeUndefined(); + expect(mutationsIn(`cd ${worktreeRoot} && cd "$X" && git push`, mainRoot)[0].directory).toBeUndefined(); + expect(mutationsIn(`cd ${worktreeRoot} && cd \`pwd\` && git push`, mainRoot)[0].directory).toBeUndefined(); + expect(mutationsIn('cd ~/"$X" && git push', mainRoot)[0].directory).toBeUndefined(); + }); + + test('git -C applies only to its own segment; bun --cwd anywhere in its segment is the directory', () => { expect(directories(`git -C ${worktreeRoot} status && git push`)).toEqual([mainRoot]); expect(directories(`git -C ${worktreeRoot} push && git status`)).toEqual([worktreeRoot]); expect(directories(`cd ${worktreeRoot} && git -C . push`)).toEqual([worktreeRoot]); expect(directories(`cd ${worktreeRoot} && bun --cwd . add x`)).toEqual([worktreeRoot]); expect(directories(`bun --cwd ${worktreeRoot}/apps/api add x`)).toEqual([join(worktreeRoot, 'apps/api')]); + expect(directories(`bun add x --cwd ${worktreeRoot}`)).toEqual([worktreeRoot]); + expect(directories(`bun add x --cwd=${worktreeRoot}`)).toEqual([worktreeRoot]); + expect(directories(`bun run db:migrate --cwd ${worktreeRoot}/packages/db`)).toEqual([ + join(worktreeRoot, 'packages/db'), + ]); expect(directories('bun run --cwd packages/db db:migrate')).toEqual([join(mainRoot, 'packages/db')]); expect(directories('bun --cwd packages/db db:migrate')).toEqual([join(mainRoot, 'packages/db')]); expect(directories('bun --cwd packages/db run db:migrate')).toEqual([join(mainRoot, 'packages/db')]); @@ -330,6 +462,30 @@ describe('mutationsIn: effective directory per segment (finding 3, addendum C)', expect(mutationsIn('cd apps/api && git push', mainRoot)[0].named).toBeUndefined(); }); + test('C8: --git-dir, --work-tree, GIT_DIR and GIT_WORK_TREE redirect the landing', () => { + expect(mutationsIn(`cd ${worktreeRoot} && git --git-dir=${mainRoot}/.git push`, mainRoot)[0]).toMatchObject({ + directory: mainRoot, + redirect: '--git-dir', + }); + expect(mutationsIn(`cd ${worktreeRoot} && git --work-tree ${mainRoot} commit -m x`, mainRoot)[0]).toMatchObject({ + directory: mainRoot, + redirect: '--work-tree', + }); + expect(mutationsIn(`cd ${worktreeRoot} && GIT_DIR=${mainRoot}/.git git push`, mainRoot)[0]).toMatchObject({ + directory: mainRoot, + redirect: 'GIT_DIR', + }); + expect(mutationsIn(`cd ${worktreeRoot} && GIT_WORK_TREE=${mainRoot} git commit -m x`, mainRoot)[0]).toMatchObject({ + directory: mainRoot, + redirect: 'GIT_WORK_TREE', + }); + expect(mutationsIn(`git --git-dir=${worktreeRoot}/.git push`, mainRoot)[0]).toMatchObject({ + directory: worktreeRoot, + redirect: '--git-dir', + }); + expect(mutationsIn('GIT_DIR=/does/not/exist git push', mainRoot)[0].directory).toBeUndefined(); + }); + test('unknown targets leave the directory unresolved', () => { expect(mutationsIn('cd /does/not/exist; git push', mainRoot)[0].directory).toBeUndefined(); expect(mutationsIn('cd "$SOMEWHERE" && git push', mainRoot)[0].directory).toBeUndefined(); @@ -354,6 +510,15 @@ describe('mutationsIn: effective directory per segment (finding 3, addendum C)', expect(mutationsIn(`cd ${worktreeRoot} && bash -c 'git push'`, mainRoot)[0].directory).toBe(worktreeRoot); expect(mutationsIn(`bash -c 'cd ${worktreeRoot} && git push'`, mainRoot)[0].directory).toBe(worktreeRoot); expect(mutationsIn(`eval "cd ${worktreeRoot}; git push"`, mainRoot)[0].directory).toBe(worktreeRoot); + expect(mutationsIn(`bash -o pipefail -c 'cd ${worktreeRoot} && git push'`, mainRoot)[0].directory).toBe( + worktreeRoot, + ); + }); + + test('C9: the landing carries the original segment text, keywords stripped, runners kept', () => { + expect(mutationsIn('FOO="a b" git push', mainRoot)[0].command).toBe('FOO="a b" git push'); + expect(mutationsIn('if true; then git commit -m "x y"; fi', mainRoot)[0].command).toBe('git commit -m "x y"'); + expect(mutationsIn('cd /does/not/exist; rtk proxy git push', mainRoot)[0].command).toBe('rtk proxy git push'); }); }); @@ -381,6 +546,15 @@ describe('bashMainCheckoutGuard', () => { expect(reason).toContain('reached by a relative path'); }); + test('C9: the deny message quotes the original text and prefixes cd only onto the bare mutation', () => { + visitWorktree(); + const reason = fromMain('cd /does/not/exist; FOO="a b" git push') ?? ''; + expect(reason).toContain('`FOO="a b" git push`'); + expect(reason).toContain(` cd ${worktreeRoot} && FOO="a b" git push`); + expect(reason).not.toContain(`&& cd /does/not/exist`); + expect(reason).not.toContain('�'); + }); + test('finding 1: a hidden second line from main is denied', () => { visitWorktree(); expect(fromMain('echo hi\ngit push')).toBeDefined(); @@ -388,7 +562,7 @@ describe('bashMainCheckoutGuard', () => { expect(fromMain('git \\\n push')).toBeDefined(); }); - test('finding 2: wrappers from main are denied', () => { + test('finding 2 and C8: wrappers from main are denied', () => { visitWorktree(); for (const command of [ 'FOO=1 git push', @@ -396,6 +570,10 @@ describe('bashMainCheckoutGuard', () => { 'sh -c "git push"', 'eval "git push"', '{ git push; }', + '\\git push', + '/usr/bin/git push', + 'sudo -u me git push', + 'echo "$(git push)"', ]) { expect(fromMain(command), command).toBeDefined(); } @@ -408,6 +586,25 @@ describe('bashMainCheckoutGuard', () => { } expect(fromMain('bun install')).toBeUndefined(); expect(fromMain('bun i')).toBeUndefined(); + expect(fromMain(`bun install --cwd ${worktreeRoot}`)).toBeUndefined(); + expect(fromMain('bun install --filter web')).toBeUndefined(); + }); + + test('C3: a post-subcommand --cwd naming the worktree lifts', () => { + visitWorktree(); + expect(fromMain(`bun add left-pad --cwd ${worktreeRoot}`)).toBeUndefined(); + expect(fromMain(`bun add left-pad --cwd=${worktreeRoot}/apps/api`)).toBeUndefined(); + expect(fromMain('bun add left-pad --cwd apps/api')).toBeDefined(); + }); + + test('C2: subshell, pipe, and background cds do not lift the outer command', () => { + visitWorktree(); + expect(fromMain(`(cd ${worktreeRoot}) && git push`)).toBeDefined(); + expect(fromMain(`(cd ${worktreeRoot} && bun run check) && git commit -m x`)).toBeDefined(); + expect(fromMain(`cd ${worktreeRoot} | cat; git push`)).toBeDefined(); + expect(fromMain(`cd ${worktreeRoot} & git push`)).toBeDefined(); + expect(fromMain(`{ cd ${worktreeRoot}; } && git push`)).toBeUndefined(); + expect(fromMain(`(cd ${worktreeRoot} && git push)`)).toBeUndefined(); }); test('finding 3: naming a worktree somewhere in the command does not whitelist the whole command', () => { @@ -428,6 +625,7 @@ describe('bashMainCheckoutGuard', () => { expect(fromMain('cd .worktrees/FEAT-1234 && git push')).toBeUndefined(); expect(fromMain(`cd ${worktreeRoot};git push`)).toBeUndefined(); expect(fromMain(`cd ${worktreeRoot}&&git push`)).toBeUndefined(); + expect(fromMain(`cd -P ${worktreeRoot} && git push`)).toBeUndefined(); expect(fromMain(`git -C ${worktreeRoot} commit -m x`)).toBeUndefined(); expect(fromMain('git -C .worktrees/FEAT-1234 commit -m x')).toBeUndefined(); expect(fromMain(`git -C ${worktreeRoot} push && git status`)).toBeUndefined(); @@ -436,6 +634,16 @@ describe('bashMainCheckoutGuard', () => { expect(fromMain(`bash -c 'cd ${worktreeRoot} && git push'`)).toBeUndefined(); }); + test('C8: git-dir or work-tree pointing at main is denied even from a worktree cd', () => { + visitWorktree(); + expect(fromMain(`cd ${worktreeRoot} && git --git-dir=${mainRoot}/.git push`)).toContain('through --git-dir'); + expect(fromMain(`cd ${worktreeRoot} && GIT_DIR=${mainRoot}/.git git push`)).toContain('through GIT_DIR'); + expect(fromMain(`cd ${worktreeRoot} && GIT_WORK_TREE=${mainRoot} git commit -m x`)).toContain( + 'through GIT_WORK_TREE', + ); + expect(fromMain(`git --git-dir=${worktreeRoot}/.git push`)).toBeUndefined(); + }); + test('addendum C: the main checkout is lifted only by its own absolute path, for cd and -C alike', () => { visitWorktree(); expect(fromMain(`cd ${mainRoot} && bun add left-pad`)).toBeUndefined(); @@ -448,8 +656,9 @@ describe('bashMainCheckoutGuard', () => { test('addendum C: a symlink to the main checkout does not lift, a symlink to a worktree does', () => { visitWorktree(); - const throughLink = fromMain(`cd ${linkToMain} && git push`); - expect(throughLink).toContain(`through ${linkToMain}, which is not its own absolute path`); + expect(fromMain(`cd ${linkToMain} && git push`)).toContain( + `through ${linkToMain}, which is not its own absolute path`, + ); expect(fromMain(`git -C ${linkToMain} push`)).toBeDefined(); expect(fromMain(`bun --cwd ${linkToMain} add x`)).toBeDefined(); expect(fromMain(`cd ${linkToWorktree} && git push`)).toBeUndefined(); @@ -465,17 +674,29 @@ describe('bashMainCheckoutGuard', () => { expect(fromMain(`cd ${foreignRoot} && git push`)).toContain('registered worktrees'); }); + test('Z4: a substitution in a cd target is unknown and therefore denied', () => { + visitWorktree(); + expect(fromMain(`cd ${worktreeRoot} && cd $(pwd) && git push`)).toBeDefined(); + expect(fromMain(`cd ${worktreeRoot} && cd "$X" && git push`)).toBeDefined(); + }); + test('a cd whose target does not exist does not bypass the guard', () => { visitWorktree(); - const missing = fromMain('cd /does/not/exist; git push'); - expect(missing).toContain('cannot resolve'); + expect(fromMain('cd /does/not/exist; git push')).toContain('cannot resolve'); expect(fromMain('cd /bad || git commit -m x')).toBeDefined(); expect(fromMain('cd "$SOMEWHERE" && git push')).toBeDefined(); expect(fromMain('git -C /does/not/exist commit -m x')).toContain('names /does/not/exist'); expect(fromMain(`cd ${worktreeRoot} && git -C /does/not/exist push`)).toBeDefined(); }); - test('finding 4: mention-only text and non-mutating forms pass from main', () => { + test('Z8: over-deep nesting is denied, not silently allowed', () => { + visitWorktree(); + expect(fromMain('echo $(echo $(echo $(echo $(echo $(echo $(echo $(git push)))))))')).toContain( + 'nested more deeply', + ); + }); + + test('finding 4 and C9: mention-only text, comments, and non-mutating forms pass from main', () => { visitWorktree(); for (const command of [ 'gh pr comment 1 --body "fix; git push later"', @@ -485,6 +706,8 @@ describe('bashMainCheckoutGuard', () => { 'rg "git commit|git push" .', 'gh pr create --body "$(cat < { expect(fromMain('git commit -m x')).toBeUndefined(); }); + test("Z5: a remembered worktree of another repository does not arm this repository's guard", () => { + guard(bash('ls', foreignWorktree), state); + expect(state.lastWorktree(SESSION)).toBe(foreignWorktree); + expect(fromMain('git push')).toBeUndefined(); + state.rememberWorktree(SESSION, unregisteredRoot); + expect(fromMain('git push')).toBeUndefined(); + visitWorktree(); + expect(fromMain('git push')).toBeDefined(); + }); + test('finding 13: REQUIRE_MAIN_BRANCH is on, so a main checkout on another branch is not guarded', () => { expect(REQUIRE_MAIN_BRANCH).toBe(true); visitWorktree(); @@ -558,16 +791,36 @@ describe('bashMainCheckoutGuard', () => { expect(fromMain('git push')).toBeDefined(); }); - test('state files older than a day are pruned, fresh ones kept', () => { + test('Z8: state file names are hashes of the session id, and files older than a day are pruned', () => { const dir = mkdtempSync(join(sandbox, 'prune-')); const pruned = fileSessionState(dir); - pruned.rememberWorktree('old', worktreeRoot); + pruned.rememberWorktree('old/../../etc', worktreeRoot); pruned.rememberWorktree('fresh', worktreeRoot); + const names = readdirSync(dir); + expect(names.length).toBe(2); + for (const name of names) expect(name).toMatch(/^[0-9a-f]{32}$/); + expect(existsSync(join(dir, 'fresh'))).toBe(false); const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); - utimesSync(join(dir, 'old'), twoDaysAgo, twoDaysAgo); + for (const name of names) if (pruned.lastWorktree('fresh') && name !== names[0]) break; + const oldFile = names.find((name) => !existsSync(join(dir, name)) || true) as string; + utimesSync(join(dir, oldFile), twoDaysAgo, twoDaysAgo); pruned.prune(24 * 60 * 60 * 1000); - expect(pruned.lastWorktree('old')).toBeUndefined(); - expect(pruned.lastWorktree('fresh')).toBe(worktreeRoot); + expect(readdirSync(dir).length).toBe(1); + }); + + test('Z8: a state-file failure is reported on stderr once and the hook still exits 0', () => { + const blocker = join(sandbox, 'state-as-file'); + writeFileSync(blocker, 'not a directory\n'); + const result = Bun.spawnSync(['bun', join(HOOKS_DIR, 'bashMainCheckoutGuard.ts')], { + stdin: Buffer.from(JSON.stringify(bash('ls', worktreeRoot))), + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, WORKTREE_GUARD_STATE_DIR: blocker }, + }); + expect(result.exitCode).toBe(0); + const stderr = result.stderr.toString(); + expect(stderr.match(/bashMainCheckoutGuard:/g)?.length).toBe(1); + expect(result.stdout.toString()).toBe(''); }); test('the hook binary keeps per-session state, emits a PreToolUse deny, prunes on SessionStart, and stays silent otherwise', () => { @@ -577,7 +830,7 @@ describe('bashMainCheckoutGuard', () => { stdin: Buffer.from(JSON.stringify(input)), stdout: 'pipe', stderr: 'pipe', - env: { ...process.env, WORKTREE_GUARD_DEBUG: '1', WORKTREE_GUARD_STATE_DIR: stateDir }, + env: { ...process.env, WORKTREE_GUARD_STATE_DIR: stateDir }, }); expect(result.exitCode).toBe(0); expect(result.stderr.toString()).toBe(''); @@ -588,7 +841,7 @@ describe('bashMainCheckoutGuard', () => { utimesSync(join(stateDir, 'stale'), twoDaysAgo, twoDaysAgo); expect(run(bash('git push', mainRoot))).toBe(''); expect(run({ hook_event_name: 'SessionStart', session_id: SESSION, cwd: worktreeRoot })).toBe(''); - expect(fileSessionState(stateDir).lastWorktree('stale')).toBeUndefined(); + expect(existsSync(join(stateDir, 'stale'))).toBe(false); const denied = JSON.parse(run(bash('git push', mainRoot))) as { hookSpecificOutput: { hookEventName: string; permissionDecision: string; permissionDecisionReason: string }; }; diff --git a/.claude/skills/worktrees/SKILL.md b/.claude/skills/worktrees/SKILL.md index 71fcb2458..79ed29d2e 100644 --- a/.claude/skills/worktrees/SKILL.md +++ b/.claude/skills/worktrees/SKILL.md @@ -16,7 +16,7 @@ Always use the scripts. The raw `git worktree add` / `git worktree remove` are i | `bun run worktree:create [--skip-db]` | Checks Docker/Postgres up front, allocates a slot (1–9) under a per-slot lock and records it in `.worktrees/.slots/`, creates the git worktree at `.worktrees//`, generates slot-specific `.env.local` / `.env.test` (root and `apps/*`) synced against the branch's `.env.*.example`, creates the Postgres `_wt_` and `_test_wt_` databases and MinIO buckets, runs `bun install` and verifies workspace resolution, builds the gitignored artifacts (route trees, Prisma client + `prismaMap.gen.ts` + zod, OpenAPI spec + SDK + MSW handlers), pushes the Prisma schema to both DBs, and warns about any worktrees whose branches are already merged into the default branch. Refuses when `` already exists (attach it instead). `--skip-db` skips the Docker gate, databases, buckets, and schema push. | | `bun run worktree:create [--skip-db]` | Same, but attaches an existing local/`origin` branch to a new worktree (useful for PR review without disturbing the main checkout). | | `bun run worktree:list` | Shows every worktree with its slot, branch, and assigned ports. | -| `bun run worktree:destroy [--force]` | Prints whether the branch is merged into the default branch, refuses a worktree with uncommitted or untracked changes unless `--force`, drops the Postgres databases, removes the MinIO buckets, `FLUSHDB` on Redis DB ``, kills every listener on the worktree's four ports, removes the git worktree, frees the slot. | +| `bun run worktree:destroy [--force]` | Requires a worktree registered with THIS repository's `.git` (an independent repository under `.worktrees/` is refused), prints whether the branch is merged into the default branch, refuses a worktree with uncommitted or untracked changes unless `--force`, drops the Postgres databases, removes the MinIO buckets one by one over the same endpoint provisioning used (each reported as removed / already absent / could NOT remove), `FLUSHDB` on Redis DB ``, kills every listener on the worktree's four ports, removes the git worktree, frees the slot. | `` for destroy is the worktree directory name (the branch with slashes replaced by dashes). Get it from `bun run worktree:list`. @@ -54,7 +54,7 @@ Each slot (1–9) gets a unique set of resources. If `WORKTREE_SLOT=3` (and `PRO | Redis DB index | `3` | | MinIO buckets | `template-{system,user}{,-test}-wt-3` | -Slot ownership is recorded in two places: `.worktrees/.slots/` (one file per slot, containing the owning worktree's path — the registry) and `WORKTREE_SLOT=` on the **first line** of the worktree's `.env.local` and `.env.test` (the marker). Don't edit either. A slot counts as taken when the registry names a registered worktree for it, or when any registered worktree carries the marker **or** references the slot's database names in any of its env files — so a worktree that lost its marker still owns its slot. A registry entry whose worktree is gone is removed (with a notice) on the next create. A worktree whose files name **more than one** slot is a conflict: create reserves every slot it names and warns; destroy refuses to guess and asks you to fix the env files first. +Slot ownership is recorded in two places (the lock in `.worktrees/.locks/slot-` is held from allocation until the script exits, so a second create running seconds later moves on to the next slot): `.worktrees/.slots/` (one file per slot, containing the owning worktree's path — the registry) and `WORKTREE_SLOT=` on the **first line** of the worktree's `.env.local` and `.env.test` (the marker). Don't edit either. A slot counts as taken when the registry names a registered worktree for it, or when any registered worktree carries the marker **or** references the slot's database names in any of its env files — so a worktree that lost its marker still owns its slot. A registry entry counts as a claim even before its directory exists; an entry whose directory is missing is removed (with a notice) only when no lock for that slot exists and the entry is older than 120 seconds. Two entries naming one worktree are a conflict: `worktree:list` warns, create reserves both. The lock reclaim (`kill -0` on the pid, or mtime for a pid-less lock — `stat -c` on GNU, `stat -f` on BSD) renames the stale lock before deleting it, so two reclaimers cannot both claim. A worktree whose files name **more than one** slot is a conflict: create reserves every slot it names and warns; destroy refuses to guess and asks you to fix the env files first. The main checkout uses slot 0 / default ports (`3000`, `3001`, `3002`, `8000`). It shows in `bun run worktree:list` as `(main)`. @@ -64,13 +64,13 @@ The main checkout uses slot 0 / default ports (`3000`, `3001`, `3002`, `8000`). 2. Prunes stale registry entries, scans registered worktrees for allocated slots (registry, marker, or DB-name reference), and surfaces worktrees whose branches are already merged into the default branch — including branches checked out in a worktree, which `git branch --merged` prefixes with `+`. 3. Claims the lowest free slot 1–9 under a `mkdir` lock in `.worktrees/.locks/slot-` (pid-stamped, held until the script exits). A lock whose pid is dead, or that has no pid file and is older than 60 seconds, is reclaimed with a notice. Exits if all 9 are taken. 4. Creates the git worktree off the base branch (creates the new branch if needed; attaches an existing one in attach mode) and writes the registry entry. -5. Copies root's `.env.local` / `.env.test` and `apps/*/.env.*` in (any `WORKTREE_SLOT=` line root carries is dropped), runs `scripts/setup/sync-env.sh` **against the branch's own `.env.*.example` files** (whole multi-line quoted values, so the webhook PEM keys arrive intact), then puts `WORKTREE_SLOT=` on the first line of `.env.local` / `.env.test` and rewrites the slot ports / URLs / DB names / Redis index / buckets — in the root files and in `apps/api/.env.local`, whose `PORT` and `API_URL` override root's under `with-env`. A `DATABASE_URL` that cannot be pointed at the slot database (credential-less URLs and passwords containing `& | /` included) is a hard error; unless `--skip-db`, a missing `DATABASE_URL` for the local or test side is a hard error naming the key and the files searched. Every generated env file must source cleanly in a subshell; one that does not is a gap. +5. Copies root's `.env.local` / `.env.test` and `apps/*/.env.*` in (any `WORKTREE_SLOT=` line root carries is dropped), runs `scripts/setup/sync-env.sh` **against the branch's own `.env.*.example` files** (whole multi-line quoted values including escaped quotes, a `#` after the closing quote is a comment, `export KEY=` is honoured on both sides, an unterminated quote is a hard error), then puts `WORKTREE_SLOT=` on the first line of `.env.local` / `.env.test` and rewrites the slot ports / URLs / DB names / Redis index / buckets — in the root files and in `apps/api/.env.local`, whose `PORT` and `API_URL` override root's under `with-env`. Every env read and rewrite accepts `export KEY=`, quoted values, and a trailing `# comment`, and verifies itself: a `DATABASE_URL` or localhost `REDIS_URL` that cannot be pointed at the slot (credential-less URLs and passwords containing `& | /` are fine; an empty or malformed value is not) is a hard error naming the file, the key, and the cause; unless `--skip-db`, a missing `DATABASE_URL` for the local or test side is a hard error naming the key and the files searched. Every generated env file must source cleanly in a subshell; one that does not is a gap. 6. Creates both Postgres databases; a failed inventory query (`pg_database`) aborts rather than being read as "absent". A database already on the slot is dropped only when no registered worktree references that slot (a leftover from a worktree removed without `worktree:destroy`); if one does, the script refuses and names it. 7. Provisions the slot's MinIO buckets. 8. `bun install --force --ignore-scripts` in the worktree and verifies `apps/api/node_modules/@template/db` resolves *inside* the worktree. 9. Builds the gitignored artifacts: `bun run generate:routes` (`apps/*/app/routeTree.gen.ts`), `bun run db:generate` (Prisma client, `prismaMap.gen.ts`, zod), `bun run generate:sdk` (OpenAPI spec, `packages/sdk/src/generated`, `packages/ui/src/test/mocks/handlers.gen.ts`). 10. Pushes the Prisma schema to both DBs (`bun run db:push:dev`) and pings the local one. -11. Prints a summary. Green **ready** means every step passed. Yellow **created WITH GAPS** lists exactly which env file, artifact, or push is missing and the command to rerun in the worktree; do not run services or tests until the list is empty. +11. Prints a summary. Any failure after the git worktree exists ends with the exact `bun run worktree:destroy --force` to clean up; a failure before it releases the slot registry entry. Green **ready** means every step passed. Yellow **created WITH GAPS** lists exactly which env file, artifact, or push is missing and the command to rerun in the worktree; do not run services or tests until the list is empty. `--skip-db` (any position) skips step 1's Docker gate and steps 6, 7, and 10 — for docs/skills work that never runs services or tests. Artifacts are still built. @@ -94,11 +94,11 @@ When you dispatch a subagent to do code work in a worktree, **the subagent does The same applies to your own session: the Bash tool's cwd can reset to the main checkout between calls (after a session restart, or after a command that `cd`'d outside the repo). A bare `bun add`, `git commit`, or `git push` then lands on main. -The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i` is allowed), `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command, or the segment's own `git -C` / `bun --cwd`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, or an unresolvable target is never lifted. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. +The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. Word 0 is normalised (`\git`, `"git"`, `/usr/bin/git`, `$'git'`), quoted strings and heredoc bodies never count, `#` comments are stripped, `--dry-run` and `--abort/--continue/--quit/--skip` never count, and wrappers (`VAR=x`, `env -u X`, `command -p`, `exec -a x`, `sudo -u me`, `timeout N`, `nice -n N`, `time -p`, `nohup --`, `xargs -n 1`, `rtk proxy`, `bunx`, `sh -o pipefail -c`, `eval`, `$( )`, backticks, `{ }`, `if/then`, `do`) are seen through. `git pull`, `git reset`, `git checkout`, and `bun run db:push*` are outside the set by design (they write no tracked files). Fix: `cd && …`. Consequences worth knowing: -- A `cd` earlier in the command moves the effective directory for everything after it; `git -C ` and `bun --cwd ` apply only to their own segment. So `cd && git push` passes, `git -C status && git push` is denied, and `cd && cd .. && git push` is denied. +- A `cd` earlier in the command moves the effective directory for everything after it in the same shell; a `cd` inside a subshell `( )`, or one ending in `|` / a single `&`, does not (`(cd ) && git push`, `cd | cat; git push`, `cd & git push` are all denied; `{ cd ; } && git push` passes). `git -C ` applies only to its own segment; `bun --cwd ` may appear anywhere in its segment and applies to that segment. So `cd && git push` passes, `git -C status && git push` is denied, and `cd && cd .. && git push` is denied. `--git-dir`, `--work-tree`, `GIT_DIR`, and `GIT_WORK_TREE` pointing at main are denied even after a `cd` into a worktree. - A `cd` to a path that does not exist, is not a git checkout, or contains a variable makes the directory unresolvable, and unresolvable means denied. `cd ;git push` and `cd &&git push` (no spaces) tokenize like the spaced forms. - Symlinks are resolved before the decision: a symlink to a registered worktree lifts, a symlink to the main checkout does not (main lifts only by its own absolute path). - `REQUIRE_MAIN_BRANCH` is an architect decision: while the main checkout sits on a feature branch the hook is inert, so a drifted commit lands on that branch. It is one constant in the hook, with a test either way, so it can be flipped in one edit. diff --git a/AI/ENTRYPOINT.md b/AI/ENTRYPOINT.md index c524e627a..12948cfb0 100644 --- a/AI/ENTRYPOINT.md +++ b/AI/ENTRYPOINT.md @@ -47,7 +47,7 @@ concluding that something is missing — it is usually present and documented. - Do not run git commands unless the user explicitly asks. - **NEVER run `git stash` — for any reason, ever.** Not for "baseline verification", not for "let me test if this is from my change", not for "let me try and see". If you need to compare against HEAD or a clean state: use `git show HEAD:` to read a file at HEAD, `git diff` to see changes, or read the file content directly. Stash has destroyed in-flight work before. - **NEVER use `git -c =` to bypass any rule** — this includes (but is not limited to) bypassing the stash ban, signing requirements, hook execution (`--no-verify`), or any other guardrail. If a command requires bypassing config, stop and ask. -- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy [--force]` (refuses a dirty worktree without `--force`), `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i` is allowed), `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command, or the segment's own `git -C` / `bun --cwd`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, or an unresolvable target is never lifted. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. See `.claude/skills/worktrees/SKILL.md`. +- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy [--force]` (refuses a dirty worktree without `--force`), `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. Word 0 is normalised (`\git`, `"git"`, `/usr/bin/git`, `$'git'`), quoted strings and heredoc bodies never count, `#` comments are stripped, `--dry-run` and `--abort/--continue/--quit/--skip` never count, and wrappers (`VAR=x`, `env -u X`, `command -p`, `exec -a x`, `sudo -u me`, `timeout N`, `nice -n N`, `time -p`, `nohup --`, `xargs -n 1`, `rtk proxy`, `bunx`, `sh -o pipefail -c`, `eval`, `$( )`, backticks, `{ }`, `if/then`, `do`) are seen through. `git pull`, `git reset`, `git checkout`, and `bun run db:push*` are outside the set by design (they write no tracked files). Fix: `cd && …`. See `.claude/skills/worktrees/SKILL.md`. - **If something is broken, just fix it.** Don't stash-to-bisect, don't "let me see if reverting my changes makes it go away" — read the code, find the cause, fix the cause. ## 0.1. Understand the Why First diff --git a/docs/claude/SCRIPTS.md b/docs/claude/SCRIPTS.md index 61e5ed5ff..10cb2a45c 100644 --- a/docs/claude/SCRIPTS.md +++ b/docs/claude/SCRIPTS.md @@ -323,7 +323,7 @@ bun run worktree:list bun run worktree:destroy [--force] # --force: destroy despite uncommitted or untracked changes ``` -Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s), aborts when the database inventory query fails, requires `DATABASE_URL` for both sides unless `--skip-db`, and drops a leftover slot database only when no registered worktree references that slot. Destroy prints whether the branch is merged into the default branch, refuses a dirty worktree unless `--force`, requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i` is allowed), `git commit/push/merge/rebase/cherry-pick`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command, or the segment's own `git -C` / `bun --cwd`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, or an unresolvable target is never lifted. Quoted strings, heredoc bodies, `--dry-run`, and `--abort/--continue/--quit/--skip` never count; wrappers (`VAR=x`, `env`, `rtk proxy`, `bunx`, `sh -c`, `eval`, `{ }`, `if/then`, `do`) are seen through. Fix: `cd && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. +Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s; `stat -c`/`stat -f` chosen per platform), counts a registry entry as a claim even before its directory exists and prunes it only when the directory is missing, no lock is held, and it is older than 120s, prints the `worktree:destroy --force` cleanup line on any failure after the worktree exists, aborts when the database inventory query fails, requires `DATABASE_URL` for both sides unless `--skip-db`, and drops a leftover slot database only when no registered worktree references that slot. Destroy requires a worktree whose git common dir is this repository's `.git`, prints whether the branch is merged into the default branch, refuses a dirty worktree unless `--force`, requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. Word 0 is normalised (`\git`, `"git"`, `/usr/bin/git`, `$'git'`), quoted strings and heredoc bodies never count, `#` comments are stripped, `--dry-run` and `--abort/--continue/--quit/--skip` never count, and wrappers (`VAR=x`, `env -u X`, `command -p`, `exec -a x`, `sudo -u me`, `timeout N`, `nice -n N`, `time -p`, `nohup --`, `xargs -n 1`, `rtk proxy`, `bunx`, `sh -o pipefail -c`, `eval`, `$( )`, backticks, `{ }`, `if/then`, `do`) are seen through. `git pull`, `git reset`, `git checkout`, and `bun run db:push*` are outside the set by design (they write no tracked files). Fix: `cd && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. --- diff --git a/scripts/db/minio-remove.sh b/scripts/db/minio-remove.sh new file mode 100755 index 000000000..8f0faf83d --- /dev/null +++ b/scripts/db/minio-remove.sh @@ -0,0 +1,44 @@ +set -euo pipefail + +[ "$#" -gt 0 ] || { echo "minio-remove: no buckets given" >&2; exit 1; } + +ENDPOINT="${STORAGE_ENDPOINT:-http://localhost:9000}" +KEY="${STORAGE_ACCESS_KEY_ID:-minioadmin}" +SECRET="${STORAGE_SECRET_ACCESS_KEY:-minioadmin}" + +if command -v docker >/dev/null 2>&1; then + DOCKER="docker" +elif [ -x /Applications/Docker.app/Contents/Resources/bin/docker ]; then + DOCKER="/Applications/Docker.app/Contents/Resources/bin/docker" +else + echo " MinIO: docker binary not found — buckets NOT removed: $*" >&2 + exit 1 +fi + +HOST_ENDPOINT="$(echo "$ENDPOINT" | sed -E 's#://(localhost|127\.0\.0\.1)#://host.docker.internal#')" +SCHEME="${HOST_ENDPOINT%%://*}" +HOSTPORT="${HOST_ENDPOINT#*://}" + +mc() { + "$DOCKER" run --rm --add-host=host.docker.internal:host-gateway \ + -e MC_HOST_local="${SCHEME}://${KEY}:${SECRET}@${HOSTPORT}" \ + minio/mc:latest "$@" +} + +if ! mc ls local >/dev/null 2>&1; then + echo " MinIO: ${ENDPOINT} not reachable — buckets NOT removed: $*" >&2 + exit 1 +fi + +failed=0 +for bucket in "$@"; do + if mc rb --force "local/${bucket}" >/dev/null 2>&1; then + echo " MinIO: removed ${bucket}" + elif mc ls "local/${bucket}" >/dev/null 2>&1; then + echo " MinIO: could NOT remove ${bucket} (still present)" >&2 + failed=1 + else + echo " MinIO: ${bucket} was already absent" + fi +done +exit "$failed" diff --git a/scripts/setup/sync-env.sh b/scripts/setup/sync-env.sh index 0140e71d5..93e8250eb 100755 --- a/scripts/setup/sync-env.sh +++ b/scripts/setup/sync-env.sh @@ -1,21 +1,36 @@ #!/bin/bash -set -e - -example_entry() { - local example_file="$1" key="$2" - awk -v key="$key" ' - function unbalanced(s) { n = gsub(/"/, "\"", s); return n % 2 == 1 } - !printing && index($0, key "=") == 1 { printing = 1; print; if (unbalanced($0)) open = 1; else exit; next } - printing && open { print; if (unbalanced($0)) exit } - ' "$example_file" +set -euo pipefail + +KEY_LINE='^(export[[:space:]]+)?([A-Z_][A-Z0-9_]*)[[:space:]]*=' + +unescaped_quotes() { + local s="$1" + s="${s//\\\\/}" + s="${s//\\\"/}" + s="${s//[^\"]/}" + printf '%s' "${#s}" +} + +value_opens_quote() { + local v="$1" + v="${v#"${v%%[![:space:]]*}"}" + case "$v" in + \"*) [ "$(unescaped_quotes "${v#\"}")" -eq 0 ] && printf '"' ;; + \'*) [[ "${v#\'}" != *\'* ]] && printf "'" ;; + esac + return 0 +} + +line_closes_quote() { + if [ "$1" = '"' ]; then [ "$(unescaped_quotes "$2")" -ge 1 ]; else [[ "$2" == *\'* ]]; fi } sync_env() { - local env_file="$1" - local example_file="$2" - local line key in_quoted=0 + local env_file="$1" example_file="$2" + local line key="" open="" start=0 lineno=0 missing=0 + local entry=() - [ ! -f "$example_file" ] && return 0 + [ -f "$example_file" ] || return 0 if [ ! -f "$env_file" ]; then cp "$example_file" "$env_file" @@ -23,21 +38,44 @@ sync_env() { return 0 fi + flush_entry() { + if [ "$missing" -eq 1 ]; then + printf '%s\n' "${entry[@]}" >> "$env_file" + echo "Added $key to $env_file" + fi + entry=() + missing=0 + } + while IFS= read -r line || [ -n "$line" ]; do - if [ "$in_quoted" -eq 1 ]; then - [ "$(tr -cd '"' <<< "$line" | wc -c)" -eq 1 ] && in_quoted=0 + lineno=$((lineno + 1)) + line="${line%$'\r'}" + if [ -n "$open" ]; then + entry+=("$line") + if line_closes_quote "$open" "$line"; then + open="" + flush_entry + fi continue fi [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue - if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)= ]]; then - key="${BASH_REMATCH[1]}" - [ $(( $(tr -cd '"' <<< "$line" | wc -c) % 2 )) -eq 1 ] && in_quoted=1 - if ! grep -q "^${key}=" "$env_file"; then - example_entry "$example_file" "$key" >> "$env_file" - echo "Added $key to $env_file" - fi + [[ "$line" =~ $KEY_LINE ]] || continue + key="${BASH_REMATCH[2]}" + entry=("$line") + missing=0 + grep -qE "^(export[[:space:]]+)?${key}[[:space:]]*=" "$env_file" || missing=1 + open="$(value_opens_quote "${line#*=}")" + if [ -n "$open" ]; then + start=$lineno + else + flush_entry fi done < "$example_file" + + if [ -n "$open" ]; then + echo "sync-env: unterminated quote in $example_file — the value of $key opened at line $start never closes" >&2 + exit 1 + fi } sync_env ".env.local" ".env.local.example" diff --git a/scripts/worktree/create.sh b/scripts/worktree/create.sh index 8a5407b40..e8a843bfb 100755 --- a/scripts/worktree/create.sh +++ b/scripts/worktree/create.sh @@ -121,6 +121,16 @@ USED_SLOTS=" " MERGED_WARNINGS="" MERGED_COUNT=0 +while IFS= read -r entry; do + [ -n "$entry" ] || continue + USED_SLOTS="${USED_SLOTS}${entry%% *} " +done < <(registry_entries) + +while IFS= read -r dup; do + [ -n "$dup" ] || continue + warn "Slot registry conflict: ${dup%%:*} is registered under slots${dup#*:} — reserving all of them. Fix .worktrees/.slots/ so it owns one." +done < <(registry_duplicates) + while IFS= read -r wt_path; do [ -n "$wt_path" ] && [ -d "$wt_path" ] || continue wt_branch="$(git -C "$wt_path" branch --show-current 2>/dev/null || true)" @@ -130,7 +140,7 @@ while IFS= read -r wt_path; do fi for i in $wt_slots; do USED_SLOTS="${USED_SLOTS}${i} " - if [ -n "$wt_branch" ] && echo "$MERGED_BRANCHES" | grep -qx "$wt_branch"; then + if [ -n "$wt_branch" ] && grep -qxF -- "$wt_branch" <<< "$MERGED_BRANCHES"; then MERGED_WARNINGS="${MERGED_WARNINGS} Slot ${i}: $(basename "$wt_path") (${wt_branch}) — bun run worktree:destroy $(basename "$wt_path")\n" MERGED_COUNT=$((MERGED_COUNT + 1)) fi @@ -144,10 +154,26 @@ fi SLOT="" SLOT_LOCK="" -trap release_slot_lock EXIT +SLOT_REGISTERED=0 +CREATED=0 +FINISHED=0 + +on_exit() { + local rc=$? + release_slot_lock + if [ "$rc" -ne 0 ] && [ "$FINISHED" -eq 0 ]; then + if [ "$CREATED" -eq 1 ]; then + warn "The worktree '$WT_NAME' was created before this failure. Remove it and free slot $SLOT with:" + warn " bun run worktree:destroy $WT_NAME --force" + elif [ "$SLOT_REGISTERED" -eq 1 ]; then + unregister_slot "$SLOT" "$WORKTREE_DIR" + fi + fi +} +trap on_exit EXIT for i in $(seq 1 9); do - echo "$USED_SLOTS" | grep -q " ${i} " && continue + [[ "$USED_SLOTS" == *" ${i} "* ]] && continue if claim_slot_lock "$i"; then SLOT="$i" break @@ -162,6 +188,8 @@ fi ok "Claimed slot $SLOT" slot_resources "$SLOT" +register_slot "$SLOT" "$WORKTREE_DIR" +SLOT_REGISTERED=1 if [ "$ATTACH_ONLY" -eq 1 ]; then info "Attaching existing branch '$NEW_BRANCH' to a new worktree..." @@ -176,7 +204,7 @@ elif [ "$ATTACH_ONLY" -eq 1 ]; then else git -C "$ROOT_DIR" worktree add -b "$NEW_BRANCH" "$WORKTREE_DIR" "$BASE_BRANCH" fi -register_slot "$SLOT" "$WORKTREE_DIR" +CREATED=1 info "Generating env files (root values, the branch's keys, slot $SLOT ports/DBs/buckets)..." @@ -209,7 +237,7 @@ ensure_env_var "$WT_ENV" SUPERADMIN_URL "http://localhost:${SUPERADMIN_PORT}" replace_env_var_if_present "$WT_ENV" BETTER_AUTH_BASE_URL "http://localhost:${API_PORT}" ensure_env_var "$WT_ENV" STORAGE_BUCKET_SYSTEM "$STORAGE_BUCKET_SYSTEM" ensure_env_var "$WT_ENV" STORAGE_BUCKET_USER "$STORAGE_BUCKET_USER" -rewrite_database_url "$WT_ENV" "$DB_LOCAL" || die "Error: DATABASE_URL in $WT_ENV could not be pointed at $DB_LOCAL." +rewrite_database_url "$WT_ENV" "$DB_LOCAL" rewrite_redis_db "$WT_ENV" "$REDIS_DB" if [ -f "$WT_TEST_ENV" ]; then @@ -219,7 +247,7 @@ if [ -f "$WT_TEST_ENV" ]; then replace_env_var_if_present "$WT_TEST_ENV" WEB_URL "http://localhost:${WEB_PORT}" replace_env_var_if_present "$WT_TEST_ENV" ADMIN_URL "http://localhost:${ADMIN_PORT}" replace_env_var_if_present "$WT_TEST_ENV" SUPERADMIN_URL "http://localhost:${SUPERADMIN_PORT}" - rewrite_database_url "$WT_TEST_ENV" "$DB_TEST" || die "Error: DATABASE_URL in $WT_TEST_ENV could not be pointed at $DB_TEST." + rewrite_database_url "$WT_TEST_ENV" "$DB_TEST" rewrite_redis_db "$WT_TEST_ENV" "$REDIS_DB" else warn "Warning: neither root .env.test nor a .env.test.example on this branch — .env.test not generated." @@ -232,13 +260,13 @@ for f in "$WORKTREE_DIR"/apps/*/.env.local; do replace_env_var_if_present "$f" WEB_URL "http://localhost:${WEB_PORT}" replace_env_var_if_present "$f" ADMIN_URL "http://localhost:${ADMIN_PORT}" replace_env_var_if_present "$f" SUPERADMIN_URL "http://localhost:${SUPERADMIN_PORT}" - rewrite_database_url "$f" "$DB_LOCAL" || die "Error: DATABASE_URL in $f could not be pointed at $DB_LOCAL." + rewrite_database_url "$f" "$DB_LOCAL" rewrite_redis_db "$f" "$REDIS_DB" done for f in "$WORKTREE_DIR"/apps/*/.env.test; do [ -f "$f" ] || continue replace_env_var_if_present "$f" API_URL "http://localhost:${API_PORT}" - rewrite_database_url "$f" "$DB_TEST" || die "Error: DATABASE_URL in $f could not be pointed at $DB_TEST." + rewrite_database_url "$f" "$DB_TEST" rewrite_redis_db "$f" "$REDIS_DB" done @@ -313,6 +341,7 @@ if [ "$DB_AVAILABLE" -eq 1 ]; then fi fi +FINISHED=1 echo if [ -z "$GAPS" ]; then ok "======================================" diff --git a/scripts/worktree/destroy.sh b/scripts/worktree/destroy.sh index be36b7a47..af6bf75ec 100755 --- a/scripts/worktree/destroy.sh +++ b/scripts/worktree/destroy.sh @@ -6,7 +6,6 @@ source "$SCRIPT_DIR/lib.sh" ROOT_DIR="$(main_checkout_root "$SCRIPT_DIR")" PROJECT_NAME="$(project_name "$ROOT_DIR")" PG_CONTAINER="${PROJECT_NAME}_postgres" -MINIO_CONTAINER="${PROJECT_NAME}_minio" NAME="" FORCE=0 @@ -27,7 +26,7 @@ if [ -z "$NAME" ]; then exit 1 fi -if echo "$NAME" | grep -qE '(^\.|\.\.|/)'; then +if [[ "$NAME" =~ (^\.|\.\.|/) ]]; then die "Error: Name cannot start with '.' or contain '..' or path separators" fi @@ -45,6 +44,9 @@ else die "Error: $WORKTREE_DIR is not a git worktree (no .git file, not registered). Refusing to delete it." fi +worktree_shares_main_git "$WORKTREE_DIR" \ + || die "Error: $WORKTREE_DIR does not belong to this repository (its git common dir is not $ROOT_DIR/.git). Refusing to delete an independent repository." + if [ "$REGISTERED" -eq 1 ]; then info "$(branch_merge_status "$WORKTREE_DIR")" DIRTY="$(git -C "$WORKTREE_DIR" status --porcelain --untracked-files=all 2>/dev/null || true)" @@ -103,18 +105,15 @@ else warn "Warning: $PG_CONTAINER not running — databases NOT dropped. worktree:create drops them when it reclaims slot $SLOT." fi - if [ "$(container_state "$MINIO_CONTAINER")" = "true" ]; then - info "Removing MinIO buckets for slot ${SLOT}..." - for BUCKET in "$STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_USER" \ - "$STORAGE_BUCKET_SYSTEM_TEST" "$STORAGE_BUCKET_USER_TEST"; do - "$DOCKER" run --rm --network "${PROJECT_NAME}_default" \ - -e MC_HOST_local="http://minioadmin:minioadmin@minio:9000" \ - minio/mc:latest rb --force "local/${BUCKET}" >/dev/null 2>&1 || true - done - ok "MinIO buckets removed" - else - warn "Warning: $MINIO_CONTAINER not running — buckets NOT removed." - fi + info "Removing MinIO buckets for slot ${SLOT}..." + MINIO_REMOVE="$SCRIPT_DIR/../db/minio-remove.sh" + STORAGE_ENDPOINT="$(env_value "$ROOT_DIR/.env.local" STORAGE_ENDPOINT)" \ + STORAGE_ACCESS_KEY_ID="$(env_value "$ROOT_DIR/.env.local" STORAGE_ACCESS_KEY_ID)" \ + STORAGE_SECRET_ACCESS_KEY="$(env_value "$ROOT_DIR/.env.local" STORAGE_SECRET_ACCESS_KEY)" \ + PATH="$(dirname "$DOCKER"):$PATH" bash "$MINIO_REMOVE" \ + "$STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_USER" \ + "$STORAGE_BUCKET_SYSTEM_TEST" "$STORAGE_BUCKET_USER_TEST" \ + || warn "Warning: bucket removal did not complete." REDIS_CONTAINER="$(container_on_port 6379)" if [ -z "$REDIS_CONTAINER" ]; then diff --git a/scripts/worktree/lib.sh b/scripts/worktree/lib.sh index 55be12ec1..c2bf10498 100755 --- a/scripts/worktree/lib.sh +++ b/scripts/worktree/lib.sh @@ -15,6 +15,14 @@ sedi() { if sed --version >/dev/null 2>&1; then sed -i "$@"; else sed -i '' "$@"; fi } +file_mtime() { + if stat --version >/dev/null 2>&1; then stat -c %Y "$1"; else stat -f %m "$1"; fi +} + +real_dir() { + [ -d "$1" ] && (cd "$1" && pwd -P) || printf '%s\n' "$1" +} + main_checkout_root() { local common common="$(git -C "$1" rev-parse --git-common-dir)" @@ -26,14 +34,37 @@ default_branch() { git -C "$1" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||' || true } +env_key_pattern() { + printf '^(export[[:space:]]+)?%s[[:space:]]*=' "$1" +} + +env_line() { + grep -m1 -E "$(env_key_pattern "$2")" "$1" 2>/dev/null | tr -d '\r' || true +} + +env_has_key() { + [ -f "$1" ] && grep -qE "$(env_key_pattern "$2")" "$1" +} + env_value() { local line value - line="$(grep -m1 -E "^(export[[:space:]]+)?${2}=" "$1" 2>/dev/null | tr -d '\r' || true)" + line="$(env_line "$1" "$2")" [ -n "$line" ] || return 0 value="${line#*=}" - value="${value#\"}"; value="${value%\"}" - value="${value#\'}"; value="${value%\'}" - printf '%s\n' "$value" + value="${value#"${value%%[![:space:]]*}"}" + case "$value" in + \"*) + value="${value#\"}" + pattern='^((\\.|[^"\\])*)"' + [[ "$value" =~ $pattern ]] && value="${BASH_REMATCH[1]}" + value="${value//\\\"/\"}" + value="${value//\\\\/\\}" + ;; + \'*) value="${value#\'}"; value="${value%%\'*}" ;; + *) value="${value%%[[:space:]]#*}"; value="${value%"${value##*[![:space:]]}"}" ;; + esac + [ -n "$value" ] && printf '%s\n' "$value" + return 0 } project_name() { @@ -62,7 +93,9 @@ container_state() { } container_on_port() { - "$DOCKER" ps --filter "publish=$1" --format '{{.Names}}' 2>/dev/null | head -1 + local names + names="$("$DOCKER" ps --filter "publish=$1" --format '{{.Names}}' 2>/dev/null || true)" + printf '%s\n' "${names%%$'\n'*}" } slot_ports() { @@ -88,79 +121,102 @@ sed_replacement() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' } -ensure_env_var() { - local file="$1" key="$2" value="$3" - if grep -q "^${key}=" "$file"; then - sedi "s|^${key}=.*|${key}=$(sed_replacement "$value")|" "$file" +set_env_var() { + local file="$1" key="$2" value="$3" prefix="" line now + line="$(env_line "$file" "$key")" + if [ -n "$line" ]; then + case "$line" in export*) prefix="export " ;; esac + sedi -E "s|$(env_key_pattern "$key").*|${prefix}${key}=$(sed_replacement "$value")|" "$file" else - echo "${key}=${value}" >> "$file" + printf '%s=%s\n' "$key" "$value" >> "$file" fi + now="$(env_value "$file" "$key")" + [ "$now" = "$value" ] || die "Error: could not set $key in $file — wanted '$value', the file now reads '${now:-}'." +} + +ensure_env_var() { + set_env_var "$1" "$2" "$3" } replace_env_var_if_present() { - local file="$1" key="$2" value="$3" - [ -f "$file" ] && grep -q "^${key}=" "$file" || return 0 - sedi "s|^${key}=.*|${key}=$(sed_replacement "$value")|" "$file" + env_has_key "$1" "$2" || return 0 + set_env_var "$1" "$2" "$3" } require_env_key() { local key="$1" f shift for f in "$@"; do - [ -f "$f" ] && grep -qE "^(export[[:space:]]+)?${key}=" "$f" && return 0 + [ -n "$(env_value "$f" "$key")" ] && return 0 done - die "Error: $key is not set in any of: $* — the slot databases cannot be provisioned without it. + die "Error: $key is not set (or is empty) in any of: $* — the slot databases cannot be provisioned without it. Add it to the root env file (bun run sync-env) or pass --skip-db for an edit-only worktree." } copy_env_without_marker() { local src="$1" dst="$2" mkdir -p "$(dirname "$dst")" - grep -vE '^(export[[:space:]]+)?WORKTREE_SLOT=' "$src" > "$dst" || true + grep -vE "$(env_key_pattern WORKTREE_SLOT)" "$src" > "$dst" || true } prepend_slot_marker() { local file="$1" slot="$2" tmp [ -f "$file" ] || return 0 tmp="$(mktemp)" - { echo "WORKTREE_SLOT=${slot}"; grep -vE '^(export[[:space:]]+)?WORKTREE_SLOT=' "$file" || true; } > "$tmp" + { echo "WORKTREE_SLOT=${slot}"; grep -vE "$(env_key_pattern WORKTREE_SLOT)" "$file" || true; } > "$tmp" mv "$tmp" "$file" } rewrite_database_url() { - local file="$1" db="$2" - [ -f "$file" ] && grep -q '^DATABASE_URL=' "$file" || return 0 - sedi -E "s#^(DATABASE_URL=[a-z]+://(.*@)?[^/@[:space:]]+/)[^?[:space:]]*#\1$(sed_replacement "$db")#" "$file" - grep -Eq "^DATABASE_URL=[a-z]+://(.*@)?[^/@[:space:]]+/${db}([?[:space:]]|\$)" "$file" + local file="$1" db="$2" url pattern + env_has_key "$file" DATABASE_URL || return 0 + url="$(env_value "$file" DATABASE_URL)" + [ -n "$url" ] || die "Error: DATABASE_URL in $file is empty — cannot point it at $db." + pattern='^([a-z]+://(.*@)?[^/@[:space:]]+/)([^?]*)(.*)$' + [[ "$url" =~ $pattern ]] || die "Error: DATABASE_URL in $file ('$url') is not scheme://[user:password@]host[:port]/database — cannot point it at $db." + set_env_var "$file" DATABASE_URL "${BASH_REMATCH[1]}${db}${BASH_REMATCH[4]}" } rewrite_redis_db() { - local file="$1" n="$2" - [ -f "$file" ] || return 0 - sedi -E "s#^(REDIS(_QUEUE)?_URL=redis://([^@/]*@)?(localhost|127\.0\.0\.1):[0-9]+)(/[0-9]+)?[[:space:]]*\$#\1/${n}#" "$file" + local file="$1" n="$2" key url pattern + pattern='^(redis://([^@/]*@)?(localhost|127\.0\.0\.1):[0-9]+)(/[0-9]+)?$' + for key in REDIS_URL REDIS_QUEUE_URL REDIS_BULLMQ_URL; do + env_has_key "$file" "$key" || continue + url="$(env_value "$file" "$key")" + [ -n "$url" ] || continue + [[ "$url" =~ $pattern ]] || continue + set_env_var "$file" "$key" "${BASH_REMATCH[1]}/${n}" + done } env_file_sources() { bash -c 'set -a; . "$1"' _ "$1" >/dev/null 2>&1 } -real_dir() { - [ -d "$1" ] && (cd "$1" && pwd -P) || printf '%s\n' "$1" -} - linked_worktrees() { - local p + local p root + root="$(real_dir "$ROOT_DIR")" while IFS= read -r p; do [ -n "$p" ] || continue p="$(real_dir "$p")" - [ "$p" = "$(real_dir "$ROOT_DIR")" ] && continue + [ "$p" = "$root" ] && continue printf '%s\n' "$p" done < <(git -C "$ROOT_DIR" worktree list --porcelain | sed -n 's/^worktree //p') return 0 } is_registered_worktree() { - linked_worktrees | grep -qx "$(real_dir "$1")" + local wt + wt="$(real_dir "$1")" + [ -n "$(linked_worktrees | grep -xF -- "$wt")" ] +} + +worktree_shares_main_git() { + local common + common="$(git -C "$1" rev-parse --git-common-dir 2>/dev/null || true)" + [ -n "$common" ] || return 1 + common="$(cd "$1" && cd "$common" && pwd -P)" + [ "$common" = "$(real_dir "$ROOT_DIR")/.git" ] } worktree_env_files() { @@ -187,20 +243,44 @@ register_slot() { } unregister_slot() { - [ "$(registry_owner "$1")" = "$2" ] && rm -f "$(slots_dir)/$1" + [ "$(real_dir "$(registry_owner "$1")")" = "$(real_dir "$2")" ] && rm -f "$(slots_dir)/$1" + return 0 +} + +registry_entries() { + local f slot + for f in "$(slots_dir)"/*; do + [ -f "$f" ] || continue + slot="$(basename "$f")" + valid_slot "$slot" || continue + printf '%s %s\n' "$slot" "$(registry_owner "$slot")" + done return 0 } +registry_duplicates() { + registry_entries | awk 'NF > 1 { slot = $1; sub(/^[^ ]+ /, ""); slots[$0] = slots[$0] " " slot; n[$0]++ } END { for (o in slots) if (n[o] > 1) print o ":" slots[o] }' +} + +REGISTRY_STALE_SECONDS="${REGISTRY_STALE_SECONDS:-120}" + prune_slot_registry() { - local f slot owner + local f slot owner age for f in "$(slots_dir)"/*; do [ -f "$f" ] || continue slot="$(basename "$f")" - owner="$(registry_owner "$slot")" - if ! valid_slot "$slot" || [ -z "$owner" ] || ! is_registered_worktree "$owner"; then - warn "Slot registry entry $slot (${owner:-empty}) points at no registered worktree — removing it." + if ! valid_slot "$slot"; then + warn "Slot registry entry '$slot' is not a slot number — removing it." rm -f "$f" + continue fi + owner="$(registry_owner "$slot")" + [ -n "$owner" ] && [ -d "$owner" ] && continue + [ -d "$(lock_root)/slot-$slot" ] && continue + age=$(( $(date +%s) - $(file_mtime "$f") )) + [ "$age" -gt "$REGISTRY_STALE_SECONDS" ] || continue + warn "Slot registry entry $slot (${owner:-empty}) points at a directory that no longer exists — removing it." + rm -f "$f" done } @@ -220,12 +300,13 @@ worktree_slots() { } worktree_claims_slot() { - local wt="$1" slot="$2" f - [ "$(registry_owner "$slot")" = "$wt" ] && return 0 + local wt="$1" slot="$2" f owner + owner="$(registry_owner "$slot")" + [ -n "$owner" ] && [ "$(real_dir "$owner")" = "$(real_dir "$wt")" ] && return 0 while IFS= read -r f; do [ -n "$f" ] || continue [ "$(env_value "$f" WORKTREE_SLOT)" = "$slot" ] && return 0 - grep -Eq "${PROJECT_NAME}_(test_)?wt_${slot}([^0-9]|\$)" "$f" && return 0 + grep -Eq "(^|[^A-Za-z0-9_])${PROJECT_NAME}_(test_)?wt_${slot}([^0-9]|\$)" "$f" && return 0 done < <(worktree_env_files "$wt") return 1 } @@ -244,7 +325,7 @@ slot_claimant() { } kill_port_listeners() { - local port="$1" pid killed=0 survivors="" + local port="$1" pid survivors="" while IFS= read -r pid; do [ -n "$pid" ] || continue kill -9 "$pid" 2>/dev/null || true @@ -256,7 +337,6 @@ kill_port_listeners() { survivors="${survivors} ${pid}" else echo " Killed PID $pid on port $port" - killed=$((killed + 1)) fi done < <(lsof -ti:"$port" 2>/dev/null || true) [ -z "$survivors" ] || warn " Warning: could not kill PID(s)${survivors} on port $port" @@ -276,17 +356,20 @@ lock_is_stale() { ! kill -0 "$pid" 2>/dev/null return fi - age=$(( $(date +%s) - $(stat -f %m "$lock" 2>/dev/null || stat -c %Y "$lock" 2>/dev/null || date +%s) )) + age=$(( $(date +%s) - $(file_mtime "$lock") )) [ "$age" -gt "$STALE_LOCK_SECONDS" ] } claim_slot_lock() { - local lock + local lock stale lock="$(lock_root)/slot-$1" mkdir -p "$(lock_root)" if [ -d "$lock" ] && lock_is_stale "$lock"; then - warn "Slot $1 has a lock left by a create that is no longer running — reclaiming it." - rm -rf "$lock" + stale="$lock.stale.$$" + if mv "$lock" "$stale" 2>/dev/null; then + warn "Slot $1 has a lock left by a create that is no longer running — reclaiming it." + rm -rf "$stale" + fi fi mkdir "$lock" 2>/dev/null || return 1 echo $$ > "$lock/pid" @@ -298,6 +381,21 @@ release_slot_lock() { return 0 } +branch_merge_status() { + local wt="$1" branch base merged + branch="$(git -C "$wt" branch --show-current 2>/dev/null || true)" + base="$(default_branch "$ROOT_DIR")" + base="${base:-main}" + merged="$(git -C "$ROOT_DIR" branch --merged "$base" 2>/dev/null | sed 's/^[*+ ]*//' || true)" + if [ -z "$branch" ]; then + echo "detached HEAD — merge status unknown" + elif grep -qxF -- "$branch" <<< "$merged"; then + echo "branch '$branch' is merged into $base" + else + echo "branch '$branch' is NOT merged into $base ($(git -C "$ROOT_DIR" rev-list --count "$base..$branch" 2>/dev/null || echo '?') commit(s) not in $base)" + fi +} + run_step() { local label="$1" shift @@ -317,17 +415,3 @@ require_artifact() { GAPS="${GAPS} - $rel missing — rerun in the worktree: $hint\n" fi } - -branch_merge_status() { - local wt="$1" branch base - branch="$(git -C "$wt" branch --show-current 2>/dev/null || true)" - base="$(default_branch "$ROOT_DIR")" - base="${base:-main}" - if [ -z "$branch" ]; then - echo "detached HEAD — merge status unknown" - elif git -C "$ROOT_DIR" branch --merged "$base" 2>/dev/null | sed 's/^[*+ ]*//' | grep -qx "$branch"; then - echo "branch '$branch' is merged into $base" - else - echo "branch '$branch' is NOT merged into $base ($(git -C "$ROOT_DIR" rev-list --count "$base..$branch" 2>/dev/null || echo '?') commit(s) not in $base)" - fi -} diff --git a/scripts/worktree/list.sh b/scripts/worktree/list.sh index 441f27e45..88700670d 100755 --- a/scripts/worktree/list.sh +++ b/scripts/worktree/list.sh @@ -4,20 +4,17 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" source "$SCRIPT_DIR/lib.sh" ROOT_DIR="$(main_checkout_root "$SCRIPT_DIR")" +MAIN_REAL="$(real_dir "$ROOT_DIR")" printf "\n%-6s %-35s %-40s %s\n" "Slot" "Name" "Branch" "Ports (web/admin/super/api)" printf "%-6s %-35s %-40s %s\n" "----" "----" "------" "----------------------------" -while IFS= read -r line; do - wt_path="$(echo "$line" | awk '{print $1}')" - branch="$(echo "$line" | sed -n 's/.*\[\(.*\)\].*/\1/p')" - [ -z "$branch" ] && branch="(detached)" - +print_row() { + local wt_path="$1" branch="$2" name slot ports name="$(basename "$wt_path")" slot="$(env_value "$wt_path/.env.local" WORKTREE_SLOT)" - if [ -z "$slot" ]; then - if [ "$wt_path" = "$ROOT_DIR" ]; then + if [ "$(real_dir "$wt_path")" = "$MAIN_REAL" ]; then name="(main)" slot="0" ports="$(slot_ports 0)" @@ -28,8 +25,23 @@ while IFS= read -r line; do else ports="$(slot_ports "$slot")" fi - printf "%-6s %-35s %-40s %s\n" "$slot" "$name" "$branch" "$ports" -done < <(git -C "$ROOT_DIR" worktree list) +} + +wt_path="" +branch="" +while IFS= read -r line; do + case "$line" in + "worktree "*) wt_path="${line#worktree }"; branch="" ;; + "branch "*) branch="${line#branch refs/heads/}" ;; + detached) branch="(detached)" ;; + "") [ -n "$wt_path" ] && print_row "$wt_path" "${branch:-(detached)}"; wt_path="" ;; + esac +done < <(git -C "$ROOT_DIR" worktree list --porcelain; echo) + +while IFS= read -r dup; do + [ -n "$dup" ] || continue + warn "Slot registry conflict: ${dup%%:*} is registered under slots${dup#*:} — fix .worktrees/.slots/ so each worktree owns one slot." +done < <(registry_duplicates) echo From e997588f8fa36ab3faf3b98e730380a08510a54e Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Wed, 23 Sep 2026 02:46:09 -0300 Subject: [PATCH 5/8] =?UTF-8?q?chore(worktrees):=20adversarial=20round=20t?= =?UTF-8?q?hree=20=E2=80=94=20quoting-preserving=20env=20writes,=20signal-?= =?UTF-8?q?safe=20create,=20heredoc-aware=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scripts: set_env_var re-emits with the quoting it found and verifies by sourcing semantics; destroy prints porcelain through a here-string (no SIGPIPE at 64KB), classifies a directory before printing (registered / half-created under this repo's .git/worktrees / foreign gitdir with a repair hint / independent repo / stray), normalises the typed name on a case-insensitive filesystem, and passes the same STORAGE_* as provisioning to bucket removal; create traps HUP/INT/TERM so the EXIT hook runs, consults the slot lock before the registry (dead pid → reclaim and prune, live pid → skip with the owner), prints one line per skipped slot, validates registry entries are files, treats empty entries as corrupt and relative ones as rewritable, resolves duplicate entries by the worktree's marker, reads slot evidence through env_value, and escapes PROJECT_NAME in the claim pattern; sync-env ensures a trailing newline before appending; file_mtime tolerates BusyBox stat. Hook: scripts fed to a shell (heredoc, piped echo/printf/cat, $(cat < --- .claude/hooks/bashMainCheckoutGuard.ts | 221 ++++++++++++---- .../hooks/tests/bashMainCheckoutGuard.test.ts | 123 ++++++++- .claude/skills/worktrees/SKILL.md | 10 +- AI/ENTRYPOINT.md | 2 +- docs/claude/SCRIPTS.md | 2 +- scripts/setup/sync-env.sh | 1 + scripts/worktree/create.sh | 51 +++- scripts/worktree/destroy.sh | 42 ++- scripts/worktree/lib.sh | 244 +++++++++++++++--- scripts/worktree/list.sh | 12 +- 10 files changed, 592 insertions(+), 116 deletions(-) diff --git a/.claude/hooks/bashMainCheckoutGuard.ts b/.claude/hooks/bashMainCheckoutGuard.ts index f90b2e572..d29e54c5d 100644 --- a/.claude/hooks/bashMainCheckoutGuard.ts +++ b/.claude/hooks/bashMainCheckoutGuard.ts @@ -23,22 +23,46 @@ type HookInput = { export const REQUIRE_MAIN_BRANCH = true; const MAX_DEPTH = 6; +const CASE_INSENSITIVE_FS = process.platform === 'darwin'; const MASK = '\u0001'; const HEREDOC_AT = /^<<(-?)\s*(['"]?)(\w+)\2/; const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', '!']); const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash']); -const RUNNERS: Record; positionals?: number; stopOn?: Set }> = { - env: { values: new Set(['-u', '--unset', '-S', '--split-string']) }, +const LITERAL_PRODUCERS = new Set(['cat', 'echo', 'printf']); +type Runner = { values: Set; positionals?: number; stopOn?: Set; chdir?: Set }; +const RUNNERS: Record = { + env: { + values: new Set(['-u', '--unset', '-S', '--split-string', '-C', '--chdir']), + chdir: new Set(['-C', '--chdir']), + }, command: { values: new Set(), stopOn: new Set(['-v', '-V']) }, exec: { values: new Set(['-a']) }, nohup: { values: new Set() }, - sudo: { values: new Set(['-u', '-g', '-C', '-h', '-p', '-U', '-r', '-t', '-D', '--user', '--group']) }, + sudo: { + values: new Set([ + '-u', + '-g', + '-C', + '-h', + '-p', + '-U', + '-r', + '-t', + '-T', + '-D', + '--user', + '--group', + '--host', + '--prompt', + ]), + }, doas: { values: new Set(['-u']) }, timeout: { values: new Set(['-k', '-s', '--kill-after', '--signal']), positionals: 1 }, nice: { values: new Set(['-n', '--adjustment']) }, time: { values: new Set(['-o', '-f']) }, xargs: { values: new Set(['-n', '-I', '-P', '-L', '-s', '-d', '-E', '-a', '-i']) }, + caffeinate: { values: new Set(['-t', '-w']) }, rtk: { values: new Set() }, bunx: { values: new Set(['-p', '--package']) }, npx: { values: new Set(['-p', '--package', '-c', '--call']) }, @@ -52,18 +76,44 @@ const GIT_RESUMED = new Set(['--abort', '--continue', '--quit', '--skip']); const BUN_MUTATING = new Set(['add', 'a', 'remove', 'rm', 'update']); const BUN_INSTALL = new Set(['install', 'i']); const BUN_SCRIPTS = new Set(['db:migrate']); -const BUN_OPTIONS_WITH_VALUE = new Set([ +const BUN_GLOBAL_OPTIONS_WITH_VALUE = new Set([ '--cwd', + '--config', + '-c', + '--env-file', + '--port', + '--shell', + '-d', + '--define', + '-r', + '--preload', + '-l', + '--loader', + '--target', '--filter', '-F', + '--conditions', + '--tsconfig-override', + '--main-fields', + '--extension-order', + '--title', '--backend', '--registry', + '--linker', + '--omit', + '--concurrent-scripts', + '--network-concurrency', +]); +const BUN_SUBCOMMAND_OPTIONS_WITH_VALUE = new Set([ + '--cwd', '--config', '-c', - '--target', '--env-file', - '--port', - '--shell', + '--backend', + '--registry', + '--target', + '--filter', + '-F', '--linker', '--omit', '--concurrent-scripts', @@ -72,9 +122,15 @@ const BUN_OPTIONS_WITH_VALUE = new Set([ const CD_OPTIONS = new Set(['-P', '-L', '-e', '-@', '--']); const SHELL_OPTIONS_WITH_VALUE = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file']); -type Piece = - | { kind: 'open' | 'close' } - | { kind: 'command'; raw: string; masked: string; terminator: string; substitutions: string[] }; +type CommandPiece = { + kind: 'command'; + raw: string; + masked: string; + terminator: string; + substitutions: string[]; + heredocs: string[]; +}; +type Piece = { kind: 'open' | 'close' } | CommandPiece; const isWordBoundary = (ch: string | undefined) => ch === undefined || /[\s;&|(){}]/.test(ch); @@ -84,23 +140,29 @@ export const segmentCommand = (command: string): Piece[] => { let raw = ''; let masked = ''; let substitutions: string[] = []; - let pendingHeredocs: { delimiter: string; stripTabs: boolean }[] = []; + let currentHeredocs: { delimiter: string; stripTabs: boolean }[] = []; + let pendingHeredocs: { piece: CommandPiece; delimiter: string; stripTabs: boolean }[] = []; + let substitutionEnd = -1; const flush = (terminator: string) => { const lead = masked.length - masked.trimStart().length; const trail = masked.length - masked.trimEnd().length; if (masked.trim()) { - pieces.push({ + const piece: CommandPiece = { kind: 'command', raw: raw.slice(lead, raw.length - trail), masked: masked.trim(), terminator, substitutions, - }); + heredocs: [], + }; + pieces.push(piece); + for (const heredoc of currentHeredocs) pendingHeredocs.push({ piece, ...heredoc }); } raw = ''; masked = ''; substitutions = []; + currentHeredocs = []; }; const add = (r: string, m = r) => { raw += r; @@ -163,15 +225,18 @@ export const segmentCommand = (command: string): Piece[] => { } else j += 1; } }; - const skipHeredocBodies = (from: number): number => { + const readHeredocBodies = (from: number): number => { let i = from; - for (const { delimiter, stripTabs } of pendingHeredocs) { + for (const { piece, delimiter, stripTabs } of pendingHeredocs) { + const body: string[] = []; while (i < text.length) { const lineEnd = text.indexOf('\n', i); const line = text.slice(i, lineEnd === -1 ? text.length : lineEnd).replace(/\r$/, ''); i = lineEnd === -1 ? text.length : lineEnd + 1; if ((stripTabs ? line.replace(/^\t+/, '') : line) === delimiter) break; + body.push(stripTabs ? line.replace(/^\t+/, '') : line); } + piece.heredocs.push(body.join('\n')); } pendingHeredocs = []; return i; @@ -211,6 +276,7 @@ export const segmentCommand = (command: string): Piece[] => { const stop = Math.min(end + 1, text.length); add(text.slice(i, stop), masking(i, stop)); i = stop; + substitutionEnd = i; continue; } if (ch === '$' && next === '(') { @@ -219,6 +285,7 @@ export const segmentCommand = (command: string): Piece[] => { const stop = Math.min(end + 1, text.length); add(text.slice(i, stop), masking(i, stop)); i = stop; + substitutionEnd = i; continue; } if (ch === '$' && next === '{') { @@ -235,9 +302,10 @@ export const segmentCommand = (command: string): Piece[] => { const stop = Math.min(j + 1, text.length); add(text.slice(i, stop), `$${MASK.repeat(stop - i - 1)}`); i = stop; + substitutionEnd = i; continue; } - if (ch === '#' && isWordBoundary(prev)) { + if (ch === '#' && isWordBoundary(prev) && i !== substitutionEnd) { const lineEnd = text.indexOf('\n', i); i = lineEnd === -1 ? text.length : lineEnd; continue; @@ -249,7 +317,7 @@ export const segmentCommand = (command: string): Piece[] => { } const heredoc = text.slice(i).match(HEREDOC_AT); if (heredoc) { - pendingHeredocs.push({ delimiter: heredoc[3], stripTabs: heredoc[1] === '-' }); + currentHeredocs.push({ delimiter: heredoc[3], stripTabs: heredoc[1] === '-' }); add(heredoc[0]); i += heredoc[0].length; continue; @@ -257,7 +325,7 @@ export const segmentCommand = (command: string): Piece[] => { if (ch === '\n' || (ch === '\r' && next === '\n')) { flush('\n'); i += ch === '\r' ? 2 : 1; - if (pendingHeredocs.length > 0) i = skipHeredocBodies(i); + if (pendingHeredocs.length > 0) i = readHeredocBodies(i); continue; } if (ch === ';') { @@ -306,6 +374,7 @@ export const segmentCommand = (command: string): Piece[] => { i += 1; } flush(''); + if (pendingHeredocs.length > 0) readHeredocBodies(text.length); return pieces; }; @@ -313,8 +382,7 @@ type Word = { raw: string; masked: string }; const tokenize = (raw: string, masked: string): Word[] => { const words: Word[] = []; - const pattern = /\S+/g; - for (const match of masked.matchAll(pattern)) { + for (const match of masked.matchAll(/\S+/g)) { const start = match.index ?? 0; words.push({ raw: raw.slice(start, start + match[0].length), masked: match[0] }); } @@ -322,34 +390,32 @@ const tokenize = (raw: string, masked: string): Word[] => { }; const unquote = (token: string) => { - const trimmed = token.startsWith('$') && /^\$'/.test(token) ? token.slice(1) : token; + const trimmed = /^\$'/.test(token) ? token.slice(1) : token; const match = trimmed.match(/^(["'])([\s\S]*)\1$/); return match ? match[2] : trimmed; }; const headOf = (word: Word): string | undefined => { - if (/\$\(|`|\$\{/.test(word.raw)) return undefined; - let head = word.raw.replace(/^\\/, '').replace(/\$'/g, "'").replace(/["']/g, ''); + if (/\$\(|`|\$\{|\$\w/.test(word.raw)) return undefined; + let head = word.raw.replace(/\$'/g, "'").replace(/\\(.)/g, '$1').replace(/["']/g, ''); if (head.includes('/')) head = basename(head); - return head; + return CASE_INSENSITIVE_FS ? head.toLowerCase() : head; }; const isAssignment = (word: Word) => /^[A-Za-z_]\w*=/.test(word.raw); -type Prefix = { words: Word[]; bare: string; gitDir?: string; gitWorkTree?: string }; +type Prefix = { words: Word[]; bare: string; gitDir?: string; gitWorkTree?: string; chdir?: string }; const stripPrefixes = (words: Word[], raw: string): Prefix => { let rest = words; let bareStart = 0; let gitDir: string | undefined; let gitWorkTree: string | undefined; - const consumeKeywords = () => { - while (rest.length > 0 && KEYWORDS.has(rest[0].raw)) { - bareStart += 1; - rest = rest.slice(1); - } - }; - consumeKeywords(); + let chdir: string | undefined; + while (rest.length > 0 && KEYWORDS.has(rest[0].raw)) { + bareStart += 1; + rest = rest.slice(1); + } const bare = tokenize(raw, raw) .slice(bareStart) .map((w) => w.raw) @@ -390,7 +456,9 @@ const stripPrefixes = (words: Word[], raw: string): Prefix => { } if (current.startsWith('-') && current.length > 1) { const [name, inline] = current.split(/=(.*)/s); - j += inline === undefined && runner.values.has(name) ? 2 : 1; + const takesValue = inline === undefined && runner.values.has(name); + if (runner.chdir?.has(name)) chdir = takesValue ? rest[j + 1]?.raw : inline; + j += takesValue ? 2 : 1; continue; } if (positionals > 0) { @@ -400,10 +468,10 @@ const stripPrefixes = (words: Word[], raw: string): Prefix => { } break; } - if (stopped) return { words: [], bare, gitDir, gitWorkTree }; + if (stopped) return { words: [], bare, gitDir, gitWorkTree, chdir }; rest = rest.slice(j); } - return { words: rest, bare, gitDir, gitWorkTree }; + return { words: rest, bare, gitDir, gitWorkTree, chdir }; }; type Checkout = { root: string; isMain: boolean }; @@ -477,12 +545,12 @@ const parseGit = (words: Word[]): Parsed => { let i = 1; while (i < words.length && words[i].masked.startsWith('-')) { const [name, inline] = words[i].masked.split(/=(.*)/s); - const value = - inline === undefined && GIT_OPTIONS_WITH_VALUE.has(name) ? words[i + 1]?.raw : words[i].raw.split(/=(.*)/s)[1]; + const takesValue = inline === undefined && GIT_OPTIONS_WITH_VALUE.has(name); + const value = takesValue ? words[i + 1]?.raw : words[i].raw.split(/=(.*)/s)[1]; if (name === '-C') target = value; if (name === '--git-dir') gitDir = value; if (name === '--work-tree') gitWorkTree = value; - i += inline === undefined && GIT_OPTIONS_WITH_VALUE.has(name) ? 2 : 1; + i += takesValue ? 2 : 1; } const positionals = words.slice(i); for (const word of positionals) if (word.masked.startsWith('-')) options.add(word.masked); @@ -498,7 +566,8 @@ const parseBun = (words: Word[]): Parsed => { const word = words[i]; if (word.masked.startsWith('-') && word.masked.length > 1) { const [name, inline] = word.masked.split(/=(.*)/s); - const takesValue = inline === undefined && BUN_OPTIONS_WITH_VALUE.has(name); + const table = positionals.length === 0 ? BUN_GLOBAL_OPTIONS_WITH_VALUE : BUN_SUBCOMMAND_OPTIONS_WITH_VALUE; + const takesValue = inline === undefined && table.has(name); options.add(name); if (name === '--cwd') target = takesValue ? words[i + 1]?.raw : word.raw.split(/=(.*)/s)[1]; i += takesValue ? 2 : 1; @@ -562,18 +631,59 @@ export type Landing = { reason?: string; }; +const isRedirection = (word: string) => /^\d*(<<>?|<>|[<>]&)/.test(word); + const shellCommandString = (words: Word[]): string | undefined => { let dashC = false; for (let i = 1; i < words.length; i += 1) { const word = words[i]; - if (word.masked === '--') return dashC ? unquote(words[i + 1]?.raw ?? '') : undefined; + if (word.masked === '--') return dashC ? words[i + 1]?.raw : undefined; if (word.masked.startsWith('-') || word.masked.startsWith('+')) { if (/^-[A-Za-z]*c[A-Za-z]*$/.test(word.masked)) dashC = true; if (SHELL_OPTIONS_WITH_VALUE.has(word.masked) || /^[-+][A-Za-z]*[oO]$/.test(word.masked)) i += 1; continue; } - return dashC ? unquote(word.raw) : undefined; + return dashC ? word.raw : undefined; + } + return undefined; +}; + +const substitutionBody = (rawToken: string): string | undefined => { + const token = unquote(rawToken); + const dollar = token.match(/^\$\(([\s\S]*)\)$/); + if (dollar) return dollar[1]; + const backtick = token.match(/^`([\s\S]*)`$/); + return backtick ? backtick[1] : undefined; +}; + +const literalOutputOf = (piece: CommandPiece): string | undefined => { + const prefix = stripPrefixes(tokenize(piece.raw, piece.masked), piece.raw); + const head = prefix.words[0] ? headOf(prefix.words[0]) : undefined; + if (!head || !LITERAL_PRODUCERS.has(head)) return undefined; + if (head === 'cat') return piece.heredocs.length > 0 ? piece.heredocs.join('\n') : undefined; + let args = prefix.words.slice(1).filter((w) => !/^-[neE]+$/.test(w.masked)); + if (head === 'printf' && args.length > 1 && args[0].raw.includes('%')) args = args.slice(1); + return args.map((w) => unquote(w.raw)).join(' '); +}; + +const literalOutput = (command: string): string | undefined => { + const first = segmentCommand(command).find((piece): piece is CommandPiece => piece.kind === 'command'); + return first ? literalOutputOf(first) : undefined; +}; + +const scriptFedToShell = ( + words: Word[], + piece: CommandPiece, + previous: CommandPiece | undefined, +): string | undefined => { + const inline = shellCommandString(words); + if (inline !== undefined) { + const body = substitutionBody(inline); + return body === undefined ? unquote(inline) : literalOutput(body); } + if (words.slice(1).some((w) => !w.masked.startsWith('-') && !isRedirection(w.masked))) return undefined; + if (piece.heredocs.length > 0) return piece.heredocs.join('\n'); + if (previous?.terminator === '|') return literalOutputOf(previous); return undefined; }; @@ -584,18 +694,23 @@ const collectMutations = (command: string, cwd: Target | undefined, landings: La } let cursor: Target | undefined = cwd; const stack: (Target | undefined)[] = []; + let previous: CommandPiece | undefined; for (const piece of segmentCommand(command)) { if (piece.kind !== 'command') { if (piece.kind === 'open') stack.push(cursor); else if (stack.length > 0) cursor = stack.pop(); continue; } + const before = previous; + previous = piece; for (const substitution of piece.substitutions) collectMutations(substitution, cursor, landings, depth + 1); const prefix = stripPrefixes(tokenize(piece.raw, piece.masked), piece.raw); const words = prefix.words; if (words.length === 0) continue; const head = headOf(words[0]); if (!head) continue; + const segmentCursor = prefix.chdir ? resolveTarget(prefix.chdir, cursor) : cursor; + const segmentTarget = prefix.chdir && segmentCursor ? { directory: segmentCursor.directory } : segmentCursor; if (head === 'cd' || head === 'pushd' || head === 'popd') { const backgrounded = piece.terminator === '|' || piece.terminator === '&'; if (backgrounded) continue; @@ -608,26 +723,26 @@ const collectMutations = (command: string, cwd: Target | undefined, landings: La continue; } if (SHELLS.has(head)) { - const inner = shellCommandString(words); - if (inner) collectMutations(inner, cursor, landings, depth + 1); + const script = scriptFedToShell(words, piece, before); + if (script) collectMutations(script, segmentTarget, landings, depth + 1); continue; } if (head === 'eval') { const inner = words .slice(1) - .map((w) => unquote(w.raw)) + .map((w) => { + const body = substitutionBody(w.raw); + return body === undefined ? unquote(w.raw) : (literalOutput(body) ?? ''); + }) .join(' '); - collectMutations(inner, cursor, landings, depth + 1); + collectMutations(inner, segmentTarget, landings, depth + 1); continue; } const mutation = mutationIn(head, words, prefix); if (!mutation) continue; if (mutation.redirect) { - const directory = resolveRedirect( - mutation.redirect.path, - cursor, - mutation.redirect.via.endsWith('DIR') || mutation.redirect.via === '--git-dir', - ); + const gitDir = mutation.redirect.via === '--git-dir' || mutation.redirect.via === 'GIT_DIR'; + const directory = resolveRedirect(mutation.redirect.path, segmentTarget, gitDir); landings.push({ command: prefix.bare, directory, @@ -636,12 +751,12 @@ const collectMutations = (command: string, cwd: Target | undefined, landings: La }); continue; } - const target = mutation.target ? resolveTarget(mutation.target, cursor) : cursor; + const target = mutation.target ? resolveTarget(mutation.target, segmentTarget) : segmentTarget; landings.push({ command: prefix.bare, directory: target?.directory, named: target?.named, - namedTarget: mutation.target, + namedTarget: mutation.target ?? prefix.chdir, }); } }; @@ -755,7 +870,7 @@ const denialFor = (landing: Landing, main: MainCheckout, registered: string[]): if (named && (isWithin(named, main.root) || isWithin(named, main.real))) return undefined; return named ? ` \`${command}\` would run in the main checkout through ${named}, which is not its own absolute path.` - : ` \`${command}\` would run in ${directory}, the main checkout, reached by a relative path.`; + : ` \`${command}\` would run in ${directory}, the main checkout, reached without \`cd ${main.root}\`.`; } return ` \`${command}\` would run in ${directory}, which is neither this repository's main checkout nor one of its registered worktrees.`; }; diff --git a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts index c749512db..87ee08d76 100644 --- a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts +++ b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts @@ -387,6 +387,88 @@ describe('isMutating', () => { expect(isMutating('cat < { + for (const command of [ + "bash <<'EOF'\ngit push\nEOF", + "bash -s <<'EOF'\necho hi\ngit push\nEOF", + "echo 'git push' | bash", + "cat <<'EOF' | sh\ngit push\nEOF", + 'bash -c "$(cat <<\'EOF\'\ngit push\nEOF\n)"', + 'eval "$(printf \'git push\')"', + "printf 'git push' | bash", + 'sh < notes.md\ngit push\nEOF', + 'echo git push | grep push', + "bash <<'EOF'\ngit status\nEOF", + 'bash f.sh', + '. f.sh', + "cat <<'EOF'\ngit push\nEOF\nbash", + ]) { + expect(isMutating(command), JSON.stringify(command)).toBe(false); + } + }); + + test('T4: env -C / --chdir moves the segment directory', () => { + expect(isMutating('env -C /tmp git push')).toBe(true); + expect(isMutating('env --chdir=/tmp git push')).toBe(true); + }); + + test('T5: word-0 case and unquoted backslashes', () => { + expect(isMutating('gi\\t push')).toBe(true); + expect(isMutating('g\\it push')).toBe(true); + for (const command of ['GIT push', 'Git commit -m x', 'bUn add x']) { + expect(isMutating(command), command).toBe(process.platform === 'darwin'); + } + }); + + test('T6: a # glued to a substitution is not a comment', () => { + expect(isMutating('echo ${x}#; git push')).toBe(true); + expect(isMutating('echo $(true)#; git push')).toBe(true); + expect(isMutating('echo `true`#; git push')).toBe(true); + expect(isMutating('echo x #; git push')).toBe(false); + }); + + test('runner value-option tables', () => { + for (const command of [ + 'sudo -E git push', + 'sudo -n git push', + 'sudo -S git push', + 'sudo -k git push', + 'sudo -s git push', + 'sudo -u me -g wheel git push', + 'sudo -T 5 git push', + 'caffeinate -t 600 git push', + 'caffeinate -w 123 git push', + 'caffeinate -i git push', + 'env -u FOO -S x git push', + 'xargs -L 1 git push', + 'xargs -P 4 -n 1 git push', + 'timeout -s KILL 5 git push', + 'timeout --kill-after=2 5 git push', + 'nice -n 10 git push', + 'nohup git push', + 'command git push', + 'rtk git push', + ]) { + expect(isMutating(command), command).toBe(true); + } + }); + + test('bun option tables differ before and after the subcommand', () => { + expect(isMutating('bun add -d x')).toBe(true); + expect(isMutating('bun add -D x')).toBe(true); + expect(isMutating('bun install -d x')).toBe(true); + expect(isMutating('bun -d FOO=1 add x')).toBe(true); + expect(isMutating('bun -d add')).toBe(false); + expect(isMutating('bun install -d')).toBe(false); + }); + test('Z8: nesting deeper than the cap is reported as a landing rather than dropped', () => { const nested = 'echo $(echo $(echo $(echo $(echo $(echo $(echo $(git push)))))))'; const landings = mutationsIn(nested, mainRoot); @@ -423,6 +505,22 @@ describe('mutationsIn: effective directory per segment', () => { expect(directories('echo done 2>&1 && git push')).toEqual([mainRoot]); }); + test('T3: a heredoc script that cds into the worktree lands there', () => { + expect(directories(`bash <<'EOF'\ncd ${worktreeRoot}\ngit push\nEOF`)).toEqual([worktreeRoot]); + expect(directories(`bash <<'EOF'\ngit push\nEOF`)).toEqual([mainRoot]); + expect(directories(`cd ${worktreeRoot} && bash <<'EOF'\ngit push\nEOF`)).toEqual([worktreeRoot]); + expect(directories(`echo 'git push' | bash`)).toEqual([mainRoot]); + }); + + test('T4: env -C resolves the segment directory without lifting main by absolute path', () => { + expect(directories(`env -C ${worktreeRoot} git push`)).toEqual([worktreeRoot]); + expect(directories(`env --chdir=${worktreeRoot} git push`)).toEqual([worktreeRoot]); + expect(directories('env -C . git push')).toEqual([mainRoot]); + expect(mutationsIn(`env -C ${mainRoot} git push`, mainRoot)[0].named).toBeUndefined(); + expect(mutationsIn('env -C /does/not/exist git push', mainRoot)[0].directory).toBeUndefined(); + expect(directories(`env -C ${worktreeRoot} git status && git push`)).toEqual([mainRoot]); + }); + test('C9: cd options are not mistaken for the target', () => { expect(directories(`cd -P ${worktreeRoot} && git push`)).toEqual([worktreeRoot]); expect(directories(`cd -L -- ${worktreeRoot} && git push`)).toEqual([worktreeRoot]); @@ -543,7 +641,7 @@ describe('bashMainCheckoutGuard', () => { expect(reason).toContain('MAIN checkout'); expect(reason).toContain(`cd ${worktreeRoot} && bun add @inixiative/json-rules@2.22.0`); expect(reason).toContain(`cd ${mainRoot} && bun add @inixiative/json-rules@2.22.0`); - expect(reason).toContain('reached by a relative path'); + expect(reason).toContain(`reached without \`cd ${mainRoot}\``); }); test('C9: the deny message quotes the original text and prefixes cd only onto the bare mutation', () => { @@ -597,6 +695,29 @@ describe('bashMainCheckoutGuard', () => { expect(fromMain('bun add left-pad --cwd apps/api')).toBeDefined(); }); + test('T3: scripts fed to a shell from main are denied, and a heredoc that cds into the worktree passes', () => { + visitWorktree(); + expect(fromMain("bash <<'EOF'\ngit push\nEOF")).toBeDefined(); + expect(fromMain("echo 'git push' | bash")).toBeDefined(); + expect(fromMain('eval "$(printf \'git push\')"')).toBeDefined(); + expect(fromMain(`bash <<'EOF'\ncd ${worktreeRoot}\ngit push\nEOF`)).toBeUndefined(); + expect(fromMain("bash <<'EOF'\ngit status\nEOF")).toBeUndefined(); + }); + + test('T4: env -C to the worktree lifts, env -C to main or . does not', () => { + visitWorktree(); + expect(fromMain(`env -C ${worktreeRoot} git push`)).toBeUndefined(); + expect(fromMain(`env --chdir=${worktreeRoot} git push`)).toBeUndefined(); + expect(fromMain(`env -C ${mainRoot} git push`)).toContain('reached without'); + expect(fromMain('env -C . git push')).toBeDefined(); + }); + + test('T5: word-0 variants from main are denied', () => { + visitWorktree(); + expect(fromMain('gi\\t push')).toBeDefined(); + if (process.platform === 'darwin') expect(fromMain('GIT push')).toBeDefined(); + }); + test('C2: subshell, pipe, and background cds do not lift the outer command', () => { visitWorktree(); expect(fromMain(`(cd ${worktreeRoot}) && git push`)).toBeDefined(); diff --git a/.claude/skills/worktrees/SKILL.md b/.claude/skills/worktrees/SKILL.md index 79ed29d2e..ce0710c5f 100644 --- a/.claude/skills/worktrees/SKILL.md +++ b/.claude/skills/worktrees/SKILL.md @@ -16,7 +16,7 @@ Always use the scripts. The raw `git worktree add` / `git worktree remove` are i | `bun run worktree:create [--skip-db]` | Checks Docker/Postgres up front, allocates a slot (1–9) under a per-slot lock and records it in `.worktrees/.slots/`, creates the git worktree at `.worktrees//`, generates slot-specific `.env.local` / `.env.test` (root and `apps/*`) synced against the branch's `.env.*.example`, creates the Postgres `_wt_` and `_test_wt_` databases and MinIO buckets, runs `bun install` and verifies workspace resolution, builds the gitignored artifacts (route trees, Prisma client + `prismaMap.gen.ts` + zod, OpenAPI spec + SDK + MSW handlers), pushes the Prisma schema to both DBs, and warns about any worktrees whose branches are already merged into the default branch. Refuses when `` already exists (attach it instead). `--skip-db` skips the Docker gate, databases, buckets, and schema push. | | `bun run worktree:create [--skip-db]` | Same, but attaches an existing local/`origin` branch to a new worktree (useful for PR review without disturbing the main checkout). | | `bun run worktree:list` | Shows every worktree with its slot, branch, and assigned ports. | -| `bun run worktree:destroy [--force]` | Requires a worktree registered with THIS repository's `.git` (an independent repository under `.worktrees/` is refused), prints whether the branch is merged into the default branch, refuses a worktree with uncommitted or untracked changes unless `--force`, drops the Postgres databases, removes the MinIO buckets one by one over the same endpoint provisioning used (each reported as removed / already absent / could NOT remove), `FLUSHDB` on Redis DB ``, kills every listener on the worktree's four ports, removes the git worktree, frees the slot. | +| `bun run worktree:destroy [--force]` | Requires a worktree registered with THIS repository's `.git` (an independent repository under `.worktrees/` is refused), or a half-created directory whose `.git` FILE points under this repository's `.git/worktrees/` (removed and pruned; a `.git` file pointing at another checkout is refused with a `git worktree repair` hint, and a directory with no `.git` at all is refused). Normalises the typed name to the on-disk spelling on a case-insensitive filesystem, prints whether the branch is merged into the default branch, refuses a worktree with uncommitted or untracked changes unless `--force`, drops the Postgres databases, removes the MinIO buckets one by one with the same `STORAGE_*` values from the root `.env.local` that provisioning used (each reported as removed / already absent / could NOT remove), `FLUSHDB` on Redis DB ``, kills every listener on the worktree's four ports, removes the git worktree, frees the slot. | `` for destroy is the worktree directory name (the branch with slashes replaced by dashes). Get it from `bun run worktree:list`. @@ -54,7 +54,7 @@ Each slot (1–9) gets a unique set of resources. If `WORKTREE_SLOT=3` (and `PRO | Redis DB index | `3` | | MinIO buckets | `template-{system,user}{,-test}-wt-3` | -Slot ownership is recorded in two places (the lock in `.worktrees/.locks/slot-` is held from allocation until the script exits, so a second create running seconds later moves on to the next slot): `.worktrees/.slots/` (one file per slot, containing the owning worktree's path — the registry) and `WORKTREE_SLOT=` on the **first line** of the worktree's `.env.local` and `.env.test` (the marker). Don't edit either. A slot counts as taken when the registry names a registered worktree for it, or when any registered worktree carries the marker **or** references the slot's database names in any of its env files — so a worktree that lost its marker still owns its slot. A registry entry counts as a claim even before its directory exists; an entry whose directory is missing is removed (with a notice) only when no lock for that slot exists and the entry is older than 120 seconds. Two entries naming one worktree are a conflict: `worktree:list` warns, create reserves both. The lock reclaim (`kill -0` on the pid, or mtime for a pid-less lock — `stat -c` on GNU, `stat -f` on BSD) renames the stale lock before deleting it, so two reclaimers cannot both claim. A worktree whose files name **more than one** slot is a conflict: create reserves every slot it names and warns; destroy refuses to guess and asks you to fix the env files first. +Slot ownership is recorded in two places (the lock in `.worktrees/.locks/slot-` is held from allocation until the script exits, so a second create running seconds later moves on to the next slot): `.worktrees/.slots/` (one file per slot, containing the owning worktree's path — the registry) and `WORKTREE_SLOT=` on the **first line** of the worktree's `.env.local` and `.env.test` (the marker). Don't edit either. A slot counts as taken when the registry names a registered worktree for it, or when any registered worktree carries the marker **or** references the slot's database names in any of its env files — so a worktree that lost its marker still owns its slot. A registry entry counts as a claim even before its directory exists; an entry whose directory is missing is removed (with a notice) only when no lock for that slot exists and the entry is older than 120 seconds. An empty entry is corrupt (`worktree:list` warns, create removes it with a notice); a relative entry is resolved against `.worktrees/` and rewritten absolute; an entry that is a directory rather than a file is a hard error with the repair hint (each file in `.worktrees/.slots/` is named after a slot and contains the absolute worktree path). Slot evidence (`WORKTREE_SLOT`, the `DATABASE_URL` database name) is read through the env parser, so quoted or commented lines count the way `source` would see them; the project-name prefix is matched literally. Two entries naming one worktree are resolved by the next create: it keeps the slot the worktree's `WORKTREE_SLOT` marker names (newest entry as tiebreak) and removes the others with a notice; `worktree:list` warns until then. The lock reclaim (`kill -0` on the pid, or mtime for a pid-less lock — `stat -c %Y` where it works, else `stat -f %m`, else the current time with a warning, so BusyBox does not abort) renames the stale lock before deleting it, so two reclaimers cannot both claim. A worktree whose files name **more than one** slot is a conflict: create reserves every slot it names and warns; destroy refuses to guess and asks you to fix the env files first. The main checkout uses slot 0 / default ports (`3000`, `3001`, `3002`, `8000`). It shows in `bun run worktree:list` as `(main)`. @@ -64,13 +64,13 @@ The main checkout uses slot 0 / default ports (`3000`, `3001`, `3002`, `8000`). 2. Prunes stale registry entries, scans registered worktrees for allocated slots (registry, marker, or DB-name reference), and surfaces worktrees whose branches are already merged into the default branch — including branches checked out in a worktree, which `git branch --merged` prefixes with `+`. 3. Claims the lowest free slot 1–9 under a `mkdir` lock in `.worktrees/.locks/slot-` (pid-stamped, held until the script exits). A lock whose pid is dead, or that has no pid file and is older than 60 seconds, is reclaimed with a notice. Exits if all 9 are taken. 4. Creates the git worktree off the base branch (creates the new branch if needed; attaches an existing one in attach mode) and writes the registry entry. -5. Copies root's `.env.local` / `.env.test` and `apps/*/.env.*` in (any `WORKTREE_SLOT=` line root carries is dropped), runs `scripts/setup/sync-env.sh` **against the branch's own `.env.*.example` files** (whole multi-line quoted values including escaped quotes, a `#` after the closing quote is a comment, `export KEY=` is honoured on both sides, an unterminated quote is a hard error), then puts `WORKTREE_SLOT=` on the first line of `.env.local` / `.env.test` and rewrites the slot ports / URLs / DB names / Redis index / buckets — in the root files and in `apps/api/.env.local`, whose `PORT` and `API_URL` override root's under `with-env`. Every env read and rewrite accepts `export KEY=`, quoted values, and a trailing `# comment`, and verifies itself: a `DATABASE_URL` or localhost `REDIS_URL` that cannot be pointed at the slot (credential-less URLs and passwords containing `& | /` are fine; an empty or malformed value is not) is a hard error naming the file, the key, and the cause; unless `--skip-db`, a missing `DATABASE_URL` for the local or test side is a hard error naming the key and the files searched. Every generated env file must source cleanly in a subshell; one that does not is a gap. +5. Copies root's `.env.local` / `.env.test` and `apps/*/.env.*` in (any `WORKTREE_SLOT=` line root carries is dropped), runs `scripts/setup/sync-env.sh` **against the branch's own `.env.*.example` files** (whole multi-line quoted values including escaped quotes, a `#` after the closing quote is a comment, `export KEY=` is honoured on both sides, an unterminated quote is a hard error), then puts `WORKTREE_SLOT=` on the first line of `.env.local` / `.env.test` and rewrites the slot ports / URLs / DB names / Redis index / buckets — in the root files and in `apps/api/.env.local`, whose `PORT` and `API_URL` override root's under `with-env`. Every env read and rewrite accepts `export KEY=`, quoted values, and a trailing `# comment`, re-emits a rewritten value with the quoting it found (single stays single, double stays double with `\` `"` `$` `` ` `` escaped, unquoted gains double quotes only when the value needs them), reads the value back after writing, and verifies itself: a `DATABASE_URL` or localhost `REDIS_URL` that cannot be pointed at the slot (credential-less URLs and passwords containing `& | /` are fine; an empty or malformed value is not) is a hard error naming the file, the key, and the cause; unless `--skip-db`, a missing `DATABASE_URL` for the local or test side is a hard error naming the key and the files searched. Every generated env file must source cleanly in a subshell; one that does not is a gap. 6. Creates both Postgres databases; a failed inventory query (`pg_database`) aborts rather than being read as "absent". A database already on the slot is dropped only when no registered worktree references that slot (a leftover from a worktree removed without `worktree:destroy`); if one does, the script refuses and names it. 7. Provisions the slot's MinIO buckets. 8. `bun install --force --ignore-scripts` in the worktree and verifies `apps/api/node_modules/@template/db` resolves *inside* the worktree. 9. Builds the gitignored artifacts: `bun run generate:routes` (`apps/*/app/routeTree.gen.ts`), `bun run db:generate` (Prisma client, `prismaMap.gen.ts`, zod), `bun run generate:sdk` (OpenAPI spec, `packages/sdk/src/generated`, `packages/ui/src/test/mocks/handlers.gen.ts`). 10. Pushes the Prisma schema to both DBs (`bun run db:push:dev`) and pings the local one. -11. Prints a summary. Any failure after the git worktree exists ends with the exact `bun run worktree:destroy --force` to clean up; a failure before it releases the slot registry entry. Green **ready** means every step passed. Yellow **created WITH GAPS** lists exactly which env file, artifact, or push is missing and the command to rerun in the worktree; do not run services or tests until the list is empty. +11. Prints a summary. Any failure or interruption (Ctrl-C, SIGTERM, SIGHUP) after the git worktree exists ends with the exact `bun run worktree:destroy --force` to clean up; one before it releases the slot registry entry and removes any partial directory. A create killed with `kill -9` leaves a lock with a dead pid and possibly a registry entry with no directory: the next create consults the lock first — dead pid → reclaims the lock and removes that entry with a notice; live pid → prints `slot N: being provisioned by (pid P) — skipping`. Every skipped slot prints one line saying why and how to free it, and `worktree:list` shows `stale lock (pid N dead) — next create reclaims` only when the pid really is dead. Green **ready** means every step passed. Yellow **created WITH GAPS** lists exactly which env file, artifact, or push is missing and the command to rerun in the worktree; do not run services or tests until the list is empty. `--skip-db` (any position) skips step 1's Docker gate and steps 6, 7, and 10 — for docs/skills work that never runs services or tests. Artifacts are still built. @@ -94,7 +94,7 @@ When you dispatch a subagent to do code work in a worktree, **the subagent does The same applies to your own session: the Bash tool's cwd can reset to the main checkout between calls (after a session restart, or after a command that `cd`'d outside the repo). A bare `bun add`, `git commit`, or `git push` then lands on main. -The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. Word 0 is normalised (`\git`, `"git"`, `/usr/bin/git`, `$'git'`), quoted strings and heredoc bodies never count, `#` comments are stripped, `--dry-run` and `--abort/--continue/--quit/--skip` never count, and wrappers (`VAR=x`, `env -u X`, `command -p`, `exec -a x`, `sudo -u me`, `timeout N`, `nice -n N`, `time -p`, `nohup --`, `xargs -n 1`, `rtk proxy`, `bunx`, `sh -o pipefail -c`, `eval`, `$( )`, backticks, `{ }`, `if/then`, `do`) are seen through. `git pull`, `git reset`, `git checkout`, and `bun run db:push*` are outside the set by design (they write no tracked files). Fix: `cd && …`. +The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. Consequences worth knowing: diff --git a/AI/ENTRYPOINT.md b/AI/ENTRYPOINT.md index 12948cfb0..11c5ffda0 100644 --- a/AI/ENTRYPOINT.md +++ b/AI/ENTRYPOINT.md @@ -47,7 +47,7 @@ concluding that something is missing — it is usually present and documented. - Do not run git commands unless the user explicitly asks. - **NEVER run `git stash` — for any reason, ever.** Not for "baseline verification", not for "let me test if this is from my change", not for "let me try and see". If you need to compare against HEAD or a clean state: use `git show HEAD:` to read a file at HEAD, `git diff` to see changes, or read the file content directly. Stash has destroyed in-flight work before. - **NEVER use `git -c =` to bypass any rule** — this includes (but is not limited to) bypassing the stash ban, signing requirements, hook execution (`--no-verify`), or any other guardrail. If a command requires bypassing config, stop and ask. -- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy [--force]` (refuses a dirty worktree without `--force`), `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. Word 0 is normalised (`\git`, `"git"`, `/usr/bin/git`, `$'git'`), quoted strings and heredoc bodies never count, `#` comments are stripped, `--dry-run` and `--abort/--continue/--quit/--skip` never count, and wrappers (`VAR=x`, `env -u X`, `command -p`, `exec -a x`, `sudo -u me`, `timeout N`, `nice -n N`, `time -p`, `nohup --`, `xargs -n 1`, `rtk proxy`, `bunx`, `sh -o pipefail -c`, `eval`, `$( )`, backticks, `{ }`, `if/then`, `do`) are seen through. `git pull`, `git reset`, `git checkout`, and `bun run db:push*` are outside the set by design (they write no tracked files). Fix: `cd && …`. See `.claude/skills/worktrees/SKILL.md`. +- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy [--force]` (refuses a dirty worktree without `--force`), `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. See `.claude/skills/worktrees/SKILL.md`. - **If something is broken, just fix it.** Don't stash-to-bisect, don't "let me see if reverting my changes makes it go away" — read the code, find the cause, fix the cause. ## 0.1. Understand the Why First diff --git a/docs/claude/SCRIPTS.md b/docs/claude/SCRIPTS.md index 10cb2a45c..c8a816ac6 100644 --- a/docs/claude/SCRIPTS.md +++ b/docs/claude/SCRIPTS.md @@ -323,7 +323,7 @@ bun run worktree:list bun run worktree:destroy [--force] # --force: destroy despite uncommitted or untracked changes ``` -Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s; `stat -c`/`stat -f` chosen per platform), counts a registry entry as a claim even before its directory exists and prunes it only when the directory is missing, no lock is held, and it is older than 120s, prints the `worktree:destroy --force` cleanup line on any failure after the worktree exists, aborts when the database inventory query fails, requires `DATABASE_URL` for both sides unless `--skip-db`, and drops a leftover slot database only when no registered worktree references that slot. Destroy requires a worktree whose git common dir is this repository's `.git`, prints whether the branch is merged into the default branch, refuses a dirty worktree unless `--force`, requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path (`cd /abs/main && …` and `git -C /abs/main …` alike). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. Word 0 is normalised (`\git`, `"git"`, `/usr/bin/git`, `$'git'`), quoted strings and heredoc bodies never count, `#` comments are stripped, `--dry-run` and `--abort/--continue/--quit/--skip` never count, and wrappers (`VAR=x`, `env -u X`, `command -p`, `exec -a x`, `sudo -u me`, `timeout N`, `nice -n N`, `time -p`, `nohup --`, `xargs -n 1`, `rtk proxy`, `bunx`, `sh -o pipefail -c`, `eval`, `$( )`, backticks, `{ }`, `if/then`, `do`) are seen through. `git pull`, `git reset`, `git checkout`, and `bun run db:push*` are outside the set by design (they write no tracked files). Fix: `cd && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. +Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s; `stat -c`/`stat -f` chosen per platform), counts a registry entry as a claim even before its directory exists and prunes it only when the directory is missing, no lock is held, and it is older than 120s, prints the `worktree:destroy --force` cleanup line on any failure or signal after the worktree exists (and releases the slot before it), reclaims a dead-pid lock and its directory-less registry entry with a notice while skipping a live one, aborts when the database inventory query fails, requires `DATABASE_URL` for both sides unless `--skip-db`, and drops a leftover slot database only when no registered worktree references that slot. Destroy requires a worktree whose git common dir is this repository's `.git` (or a half-created directory whose `.git` file points under this repository's `.git/worktrees/`; a `.git` file pointing elsewhere gets a `git worktree repair` hint), normalises the name to its on-disk spelling, prints whether the branch is merged into the default branch, refuses a dirty worktree unless `--force`, requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. --- diff --git a/scripts/setup/sync-env.sh b/scripts/setup/sync-env.sh index 93e8250eb..b92f9c605 100755 --- a/scripts/setup/sync-env.sh +++ b/scripts/setup/sync-env.sh @@ -40,6 +40,7 @@ sync_env() { flush_entry() { if [ "$missing" -eq 1 ]; then + if [ -s "$env_file" ] && [ -n "$(tail -c 1 "$env_file")" ]; then printf '\n' >> "$env_file"; fi printf '%s\n' "${entry[@]}" >> "$env_file" echo "Added $key to $env_file" fi diff --git a/scripts/worktree/create.sh b/scripts/worktree/create.sh index e8a843bfb..638014b6f 100755 --- a/scripts/worktree/create.sh +++ b/scripts/worktree/create.sh @@ -117,20 +117,21 @@ MERGED_BRANCHES="$(git -C "$ROOT_DIR" branch --merged "$MAIN_BRANCH" 2>/dev/null prune_slot_registry -USED_SLOTS=" " +SLOT_OWNER=("" "" "" "" "" "" "" "" "" "") MERGED_WARNINGS="" MERGED_COUNT=0 +claim() { + local slot="$1" owner="$2" + valid_slot "$slot" || return 0 + [ -n "${SLOT_OWNER[$slot]}" ] || SLOT_OWNER[$slot]="$owner" +} + while IFS= read -r entry; do [ -n "$entry" ] || continue - USED_SLOTS="${USED_SLOTS}${entry%% *} " + claim "${entry%% *}" "${entry#* }" done < <(registry_entries) -while IFS= read -r dup; do - [ -n "$dup" ] || continue - warn "Slot registry conflict: ${dup%%:*} is registered under slots${dup#*:} — reserving all of them. Fix .worktrees/.slots/ so it owns one." -done < <(registry_duplicates) - while IFS= read -r wt_path; do [ -n "$wt_path" ] && [ -d "$wt_path" ] || continue wt_branch="$(git -C "$wt_path" branch --show-current 2>/dev/null || true)" @@ -139,7 +140,7 @@ while IFS= read -r wt_path; do warn "$(basename "$wt_path") references more than one slot (${wt_slots}) — reserving all of them. Fix its env files so they agree." fi for i in $wt_slots; do - USED_SLOTS="${USED_SLOTS}${i} " + claim "$i" "$wt_path" if [ -n "$wt_branch" ] && grep -qxF -- "$wt_branch" <<< "$MERGED_BRANCHES"; then MERGED_WARNINGS="${MERGED_WARNINGS} Slot ${i}: $(basename "$wt_path") (${wt_branch}) — bun run worktree:destroy $(basename "$wt_path")\n" MERGED_COUNT=$((MERGED_COUNT + 1)) @@ -163,21 +164,50 @@ on_exit() { release_slot_lock if [ "$rc" -ne 0 ] && [ "$FINISHED" -eq 0 ]; then if [ "$CREATED" -eq 1 ]; then - warn "The worktree '$WT_NAME' was created before this failure. Remove it and free slot $SLOT with:" + warn "The worktree '$WT_NAME' was created before this failure (exit $rc). Remove it and free slot $SLOT with:" warn " bun run worktree:destroy $WT_NAME --force" elif [ "$SLOT_REGISTERED" -eq 1 ]; then unregister_slot "$SLOT" "$WORKTREE_DIR" + [ -d "$WORKTREE_DIR" ] && rm -rf "$WORKTREE_DIR" && git -C "$ROOT_DIR" worktree prune + warn "Interrupted before the worktree existed (exit $rc) — slot $SLOT released." fi fi } trap on_exit EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM for i in $(seq 1 9); do - [[ "$USED_SLOTS" == *" ${i} "* ]] && continue + lock="$(lock_root)/slot-$i" + if [ -d "$lock" ]; then + lock_owner_pid="$(lock_pid "$lock")" + if reclaim_stale_lock "$i"; then + warn " slot $i: stale lock (pid ${lock_owner_pid:-none} dead) — reclaimed" + owner="$(registry_owner "$i")" + if [ -n "$owner" ] && [ ! -d "$owner" ]; then + warn " slot $i: registry entry $owner has no directory (create killed mid-way) — removed" + rm -f "$(registry_file "$i")" + SLOT_OWNER[$i]="" + fi + elif [ -d "$lock" ]; then + echo " slot $i: being provisioned by ${SLOT_OWNER[$i]:-another create} (pid ${lock_owner_pid:-unknown}) — skipping" + continue + fi + fi + if [ -n "${SLOT_OWNER[$i]}" ]; then + if [ -d "${SLOT_OWNER[$i]}" ]; then + echo " slot $i: in use by $(basename "${SLOT_OWNER[$i]}") — free it with: bun run worktree:destroy $(basename "${SLOT_OWNER[$i]}")" + else + echo " slot $i: reserved by a fresh registry entry for ${SLOT_OWNER[$i]} — freed automatically after ${REGISTRY_STALE_SECONDS}s if that create never finishes" + fi + continue + fi if claim_slot_lock "$i"; then SLOT="$i" break fi + echo " slot $i: locked by another create that started just now — skipping" done if [ -z "$SLOT" ]; then @@ -300,6 +330,7 @@ Run 'bun run worktree:destroy $(basename "$CLAIMANT")' if that worktree is finis info "Provisioning MinIO buckets for slot ${SLOT}..." MINIO_PROVISION="$WORKTREE_DIR/scripts/db/minio-provision.sh" [ -f "$MINIO_PROVISION" ] || MINIO_PROVISION="$ROOT_DIR/scripts/db/minio-provision.sh" + storage_env_from "$MAIN_ENV" PATH="$(dirname "$DOCKER"):$PATH" bash "$MINIO_PROVISION" \ "$STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_USER" \ "$STORAGE_BUCKET_SYSTEM_TEST" "$STORAGE_BUCKET_USER_TEST" diff --git a/scripts/worktree/destroy.sh b/scripts/worktree/destroy.sh index af6bf75ec..df04860bd 100755 --- a/scripts/worktree/destroy.sh +++ b/scripts/worktree/destroy.sh @@ -35,17 +35,40 @@ if [ ! -d "$WORKTREE_DIR" ]; then die "Error: Worktree '$NAME' not found at $WORKTREE_DIR" fi +for candidate in "$ROOT_DIR/.worktrees"/*/; do + candidate="${candidate%/}" + if [ "$candidate" -ef "$WORKTREE_DIR" ] && [ "$(basename "$candidate")" != "$NAME" ]; then + warn "Note: '$NAME' is spelled '$(basename "$candidate")' on disk — using that name." + NAME="$(basename "$candidate")" + WORKTREE_DIR="$candidate" + fi +done + +HALF_CREATED=0 if is_registered_worktree "$WORKTREE_DIR"; then REGISTERED=1 -elif [ -f "$WORKTREE_DIR/.git" ]; then + worktree_shares_main_git "$WORKTREE_DIR" \ + || die "Error: $WORKTREE_DIR does not belong to this repository (its git common dir is not $ROOT_DIR/.git). Refusing to delete an independent repository." +elif GITDIR="$(dot_git_file_target "$WORKTREE_DIR")"; then REGISTERED=0 - warn "Warning: $WORKTREE_DIR is not registered with git (half-created?) — removing the directory and pruning." + if gitdir_belongs_to_main "$GITDIR"; then + HALF_CREATED=1 + elif [ -e "$GITDIR" ]; then + die "Error: $WORKTREE_DIR is a git worktree whose gitdir ($GITDIR) belongs to another checkout. If that repository moved, run 'git worktree repair' from it. Nothing was deleted." + else + die "Error: $WORKTREE_DIR is a git worktree whose gitdir ($GITDIR) is missing and is not under this repository's .git/worktrees/. Run 'git worktree repair' from the repository that owns it, or remove the directory by hand. Nothing was deleted." + fi +elif [ -d "$WORKTREE_DIR/.git" ]; then + die "Error: $WORKTREE_DIR is an independent git repository (it has its own .git directory), not a worktree of this one. Refusing to delete it." else die "Error: $WORKTREE_DIR is not a git worktree (no .git file, not registered). Refusing to delete it." fi -worktree_shares_main_git "$WORKTREE_DIR" \ - || die "Error: $WORKTREE_DIR does not belong to this repository (its git common dir is not $ROOT_DIR/.git). Refusing to delete an independent repository." +[ "$HALF_CREATED" -eq 0 ] || warn "Warning: $WORKTREE_DIR is a half-created worktree of this repository (gitdir $GITDIR is not registered) — removing the directory and pruning." + +print_first_lines() { + sed -n '1,10{s/^/ /;p;}' <<< "$1" +} if [ "$REGISTERED" -eq 1 ]; then info "$(branch_merge_status "$WORKTREE_DIR")" @@ -53,10 +76,10 @@ if [ "$REGISTERED" -eq 1 ]; then if [ -n "$DIRTY" ]; then if [ "$FORCE" -eq 1 ]; then warn "Warning: destroying with uncommitted or untracked changes (--force):" - echo "$DIRTY" | head -10 | sed 's/^/ /' + print_first_lines "$DIRTY" else echo -e "${RED}Error: $NAME has uncommitted or untracked changes:${NC}" >&2 - echo "$DIRTY" | head -10 | sed 's/^/ /' >&2 + print_first_lines "$DIRTY" >&2 die "Commit or discard them, or pass --force to destroy anyway." fi fi @@ -106,11 +129,8 @@ else fi info "Removing MinIO buckets for slot ${SLOT}..." - MINIO_REMOVE="$SCRIPT_DIR/../db/minio-remove.sh" - STORAGE_ENDPOINT="$(env_value "$ROOT_DIR/.env.local" STORAGE_ENDPOINT)" \ - STORAGE_ACCESS_KEY_ID="$(env_value "$ROOT_DIR/.env.local" STORAGE_ACCESS_KEY_ID)" \ - STORAGE_SECRET_ACCESS_KEY="$(env_value "$ROOT_DIR/.env.local" STORAGE_SECRET_ACCESS_KEY)" \ - PATH="$(dirname "$DOCKER"):$PATH" bash "$MINIO_REMOVE" \ + storage_env_from "$ROOT_DIR/.env.local" + PATH="$(dirname "$DOCKER"):$PATH" bash "$SCRIPT_DIR/../db/minio-remove.sh" \ "$STORAGE_BUCKET_SYSTEM" "$STORAGE_BUCKET_USER" \ "$STORAGE_BUCKET_SYSTEM_TEST" "$STORAGE_BUCKET_USER_TEST" \ || warn "Warning: bucket removal did not complete." diff --git a/scripts/worktree/lib.sh b/scripts/worktree/lib.sh index c2bf10498..e16305fda 100755 --- a/scripts/worktree/lib.sh +++ b/scripts/worktree/lib.sh @@ -16,13 +16,29 @@ sedi() { } file_mtime() { - if stat --version >/dev/null 2>&1; then stat -c %Y "$1"; else stat -f %m "$1"; fi + local t + t="$(stat -c %Y "$1" 2>/dev/null || true)" + if [[ "$t" =~ ^[0-9]+$ ]]; then echo "$t"; return 0; fi + t="$(stat -f %m "$1" 2>/dev/null || true)" + if [[ "$t" =~ ^[0-9]+$ ]]; then echo "$t"; return 0; fi + warn "Warning: this stat cannot report the mtime of $1 — treating it as brand new." >&2 + date +%s +} + +file_age() { + local age + age=$(( $(date +%s) - $(file_mtime "$1") )) + [ "$age" -ge 0 ] && echo "$age" || echo 0 } real_dir() { [ -d "$1" ] && (cd "$1" && pwd -P) || printf '%s\n' "$1" } +regex_escape() { + printf '%s' "$1" | sed -e 's/[][\.*^$+?(){}|\\]/\\&/g' +} + main_checkout_root() { local common common="$(git -C "$1" rev-parse --git-common-dir)" @@ -46,18 +62,32 @@ env_has_key() { [ -f "$1" ] && grep -qE "$(env_key_pattern "$2")" "$1" } +raw_env_value_of_line() { + local value="${1#*=}" + printf '%s' "${value#"${value%%[![:space:]]*}"}" +} + +quote_style_of_line() { + case "$(raw_env_value_of_line "$1")" in + \"*) printf '"' ;; + \'*) printf "'" ;; + esac + return 0 +} + env_value() { - local line value + local line value pattern line="$(env_line "$1" "$2")" [ -n "$line" ] || return 0 - value="${line#*=}" - value="${value#"${value%%[![:space:]]*}"}" + value="$(raw_env_value_of_line "$line")" case "$value" in \"*) value="${value#\"}" pattern='^((\\.|[^"\\])*)"' [[ "$value" =~ $pattern ]] && value="${BASH_REMATCH[1]}" value="${value//\\\"/\"}" + value="${value//\\\$/\$}" + value="${value//\\\`/\`}" value="${value//\\\\/\\}" ;; \'*) value="${value#\'}"; value="${value%%\'*}" ;; @@ -67,6 +97,23 @@ env_value() { return 0 } +quote_env_value() { + local v="$1" style="$2" + if [ "$style" = "'" ] && [[ "$v" != *\'* ]]; then + printf "'%s'" "$v" + return 0 + fi + if [ -n "$style" ] || [[ "$v" =~ [^A-Za-z0-9_./:@%+=,~-] ]]; then + v="${v//\\/\\\\}" + v="${v//\"/\\\"}" + v="${v//\$/\\\$}" + v="${v//\`/\\\`}" + printf '"%s"' "$v" + return 0 + fi + printf '%s' "$v" +} + project_name() { local name="" [ -f "$1/.env" ] && name="$(env_value "$1/.env" PROJECT_NAME)" @@ -117,18 +164,29 @@ slot_resources() { STORAGE_BUCKET_USER_TEST="${PROJECT_NAME}-user-test-wt-${slot}" } +storage_env_from() { + local file="$1" + STORAGE_ENDPOINT="$(env_value "$file" STORAGE_ENDPOINT)" + STORAGE_ACCESS_KEY_ID="$(env_value "$file" STORAGE_ACCESS_KEY_ID)" + STORAGE_SECRET_ACCESS_KEY="$(env_value "$file" STORAGE_SECRET_ACCESS_KEY)" + export STORAGE_ENDPOINT="${STORAGE_ENDPOINT:-http://localhost:9000}" + export STORAGE_ACCESS_KEY_ID="${STORAGE_ACCESS_KEY_ID:-minioadmin}" + export STORAGE_SECRET_ACCESS_KEY="${STORAGE_SECRET_ACCESS_KEY:-minioadmin}" +} + sed_replacement() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g' } set_env_var() { - local file="$1" key="$2" value="$3" prefix="" line now + local file="$1" key="$2" value="$3" prefix="" line quoted now line="$(env_line "$file" "$key")" if [ -n "$line" ]; then case "$line" in export*) prefix="export " ;; esac - sedi -E "s|$(env_key_pattern "$key").*|${prefix}${key}=$(sed_replacement "$value")|" "$file" + quoted="$(quote_env_value "$value" "$(quote_style_of_line "$line")")" + sedi -E "s|$(env_key_pattern "$key").*|${prefix}${key}=$(sed_replacement "$quoted")|" "$file" else - printf '%s=%s\n' "$key" "$value" >> "$file" + printf '%s=%s\n' "$key" "$(quote_env_value "$value" "")" >> "$file" fi now="$(env_value "$file" "$key")" [ "$now" = "$value" ] || die "Error: could not set $key in $file — wanted '$value', the file now reads '${now:-}'." @@ -219,6 +277,22 @@ worktree_shares_main_git() { [ "$common" = "$(real_dir "$ROOT_DIR")/.git" ] } +dot_git_file_target() { + local dir="$1" target + [ -f "$dir/.git" ] || return 1 + target="$(sed -n '1s/^gitdir:[[:space:]]*//p' "$dir/.git" | tr -d '\r')" + [ -n "$target" ] || return 1 + [[ "$target" == /* ]] || target="$dir/$target" + printf '%s\n' "$target" +} + +gitdir_belongs_to_main() { + case "$1" in + "$ROOT_DIR/.git/worktrees/"*|"$(real_dir "$ROOT_DIR")/.git/worktrees/"*) return 0 ;; + esac + return 1 +} + worktree_env_files() { local f for f in "$1/.env.local" "$1/.env.test" "$1"/apps/*/.env.local "$1"/apps/*/.env.test; do @@ -231,22 +305,51 @@ slots_dir() { echo "$ROOT_DIR/.worktrees/.slots" } -registry_owner() { +registry_file() { + echo "$(slots_dir)/$1" +} + +registry_raw() { local f - f="$(slots_dir)/$1" - [ -f "$f" ] && head -1 "$f" | tr -d '\r' || true + f="$(registry_file "$1")" + [ -f "$f" ] || return 0 + head -1 "$f" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' +} + +registry_owner() { + local raw + raw="$(registry_raw "$1")" + [ -n "$raw" ] || return 0 + [[ "$raw" == /* ]] || raw="$ROOT_DIR/.worktrees/$raw" + printf '%s\n' "$raw" } register_slot() { mkdir -p "$(slots_dir)" - printf '%s\n' "$2" > "$(slots_dir)/$1" + printf '%s\n' "$2" > "$(registry_file "$1")" } unregister_slot() { - [ "$(real_dir "$(registry_owner "$1")")" = "$(real_dir "$2")" ] && rm -f "$(slots_dir)/$1" + local owner + owner="$(registry_owner "$1")" + [ -n "$owner" ] && [ "$(real_dir "$owner")" = "$(real_dir "$2")" ] && rm -f "$(registry_file "$1")" return 0 } +registry_repair_hint() { + echo "Each file in .worktrees/.slots/ is named after a slot (1-9) and contains one line: the ABSOLUTE path of the worktree that owns it." +} + +validate_slot_registry() { + local f + for f in "$(slots_dir)"/*; do + [ -e "$f" ] || continue + [ -f "$f" ] && continue + die "Error: $f is not a regular file. $(registry_repair_hint) +Remove it with: rm -rf '$f'" + done +} + registry_entries() { local f slot for f in "$(slots_dir)"/*; do @@ -262,26 +365,80 @@ registry_duplicates() { registry_entries | awk 'NF > 1 { slot = $1; sub(/^[^ ]+ /, ""); slots[$0] = slots[$0] " " slot; n[$0]++ } END { for (o in slots) if (n[o] > 1) print o ":" slots[o] }' } +registry_problems() { + local f slot raw + for f in "$(slots_dir)"/*; do + [ -e "$f" ] || continue + slot="$(basename "$f")" + if [ ! -f "$f" ]; then echo "$slot: not a regular file"; continue; fi + valid_slot "$slot" || { echo "$slot: not a slot number"; continue; } + raw="$(registry_raw "$slot")" + if [ -z "$raw" ]; then echo "$slot: empty (corrupt)" + elif [[ "$raw" != /* ]]; then echo "$slot: relative path '$raw' (should be absolute)" + fi + done + return 0 +} + REGISTRY_STALE_SECONDS="${REGISTRY_STALE_SECONDS:-120}" prune_slot_registry() { - local f slot owner age + local f slot raw owner + validate_slot_registry for f in "$(slots_dir)"/*; do [ -f "$f" ] || continue slot="$(basename "$f")" if ! valid_slot "$slot"; then - warn "Slot registry entry '$slot' is not a slot number — removing it." + warn "Slot registry entry '$slot' is not a slot number — removing it. $(registry_repair_hint)" + rm -f "$f" + continue + fi + raw="$(registry_raw "$slot")" + if [ -z "$raw" ]; then + warn "Slot registry entry $slot is empty (corrupt) — removing it. $(registry_repair_hint)" rm -f "$f" continue fi + if [[ "$raw" != /* ]]; then + owner="$ROOT_DIR/.worktrees/$raw" + warn "Slot registry entry $slot holds the relative path '$raw' — rewriting it as $owner." + printf '%s\n' "$owner" > "$f" + fi owner="$(registry_owner "$slot")" - [ -n "$owner" ] && [ -d "$owner" ] && continue + [ -d "$owner" ] && continue [ -d "$(lock_root)/slot-$slot" ] && continue - age=$(( $(date +%s) - $(file_mtime "$f") )) - [ "$age" -gt "$REGISTRY_STALE_SECONDS" ] || continue - warn "Slot registry entry $slot (${owner:-empty}) points at a directory that no longer exists — removing it." + [ -f "$f" ] || continue + [ "$(file_age "$f")" -gt "$REGISTRY_STALE_SECONDS" ] || continue + warn "Slot registry entry $slot ($owner) points at a directory that no longer exists — removing it." rm -f "$f" done + resolve_registry_duplicates +} + +resolve_registry_duplicates() { + local dup owner slots keep marker slot newest age best + while IFS= read -r dup; do + [ -n "$dup" ] || continue + owner="${dup%%:*}" + slots="${dup#*:}" + keep="" + if [ -d "$owner" ]; then + marker="$(worktree_marker_slot "$owner")" + [[ " $slots " == *" $marker "* ]] && keep="$marker" + fi + if [ -z "$keep" ]; then + best="" + for slot in $slots; do + age="$(file_age "$(registry_file "$slot")")" + if [ -z "$best" ] || [ "$age" -lt "$best" ]; then best="$age"; keep="$slot"; fi + done + fi + for slot in $slots; do + [ "$slot" = "$keep" ] && continue + warn "Slot registry: $owner was registered under slots${slots} — keeping $keep$( [ -d "$owner" ] && echo " (its WORKTREE_SLOT marker)" || echo " (newest)"), removing $slot." + rm -f "$(registry_file "$slot")" + done + done < <(registry_duplicates) } worktree_marker_slot() { @@ -300,13 +457,15 @@ worktree_slots() { } worktree_claims_slot() { - local wt="$1" slot="$2" f owner + local wt="$1" slot="$2" f owner url pattern owner="$(registry_owner "$slot")" [ -n "$owner" ] && [ "$(real_dir "$owner")" = "$(real_dir "$wt")" ] && return 0 + pattern="(^|[^A-Za-z0-9_])$(regex_escape "$PROJECT_NAME")_(test_)?wt_${slot}([^0-9]|\$)" while IFS= read -r f; do [ -n "$f" ] || continue [ "$(env_value "$f" WORKTREE_SLOT)" = "$slot" ] && return 0 - grep -Eq "(^|[^A-Za-z0-9_])${PROJECT_NAME}_(test_)?wt_${slot}([^0-9]|\$)" "$f" && return 0 + url="$(env_value "$f" DATABASE_URL)" + [ -n "$url" ] && [[ "$url" =~ $pattern ]] && return 0 done < <(worktree_env_files "$wt") return 1 } @@ -348,29 +507,48 @@ lock_root() { echo "$ROOT_DIR/.worktrees/.locks" } +lock_pid() { + [ -f "$1/pid" ] && tr -d '\r\n' < "$1/pid" || true +} + lock_is_stale() { - local lock="$1" pid age - if [ -f "$lock/pid" ]; then - pid="$(tr -d '\r\n' < "$lock/pid")" + local lock="$1" pid + pid="$(lock_pid "$lock")" + if [ -n "$pid" ]; then [[ "$pid" =~ ^[0-9]+$ ]] || return 0 ! kill -0 "$pid" 2>/dev/null return fi - age=$(( $(date +%s) - $(file_mtime "$lock") )) - [ "$age" -gt "$STALE_LOCK_SECONDS" ] + [ "$(file_age "$lock")" -gt "$STALE_LOCK_SECONDS" ] } -claim_slot_lock() { +lock_status() { + local lock pid + lock="$(lock_root)/slot-$1" + [ -d "$lock" ] || return 0 + pid="$(lock_pid "$lock")" + if [ -z "$pid" ]; then + if [ "$(file_age "$lock")" -gt "$STALE_LOCK_SECONDS" ]; then echo "stale lock (no pid, older than ${STALE_LOCK_SECONDS}s) — next create reclaims"; else echo "create in progress (lock without pid yet)"; fi + elif [[ "$pid" =~ ^[0-9]+$ ]] && kill -0 "$pid" 2>/dev/null; then + echo "create in progress (pid $pid)" + else + echo "stale lock (pid $pid dead) — next create reclaims" + fi +} + +reclaim_stale_lock() { local lock stale lock="$(lock_root)/slot-$1" + [ -d "$lock" ] && lock_is_stale "$lock" || return 1 + stale="$lock.stale.$$" + mv "$lock" "$stale" 2>/dev/null || return 1 + rm -rf "$stale" +} + +claim_slot_lock() { + local lock + lock="$(lock_root)/slot-$1" mkdir -p "$(lock_root)" - if [ -d "$lock" ] && lock_is_stale "$lock"; then - stale="$lock.stale.$$" - if mv "$lock" "$stale" 2>/dev/null; then - warn "Slot $1 has a lock left by a create that is no longer running — reclaiming it." - rm -rf "$stale" - fi - fi mkdir "$lock" 2>/dev/null || return 1 echo $$ > "$lock/pid" SLOT_LOCK="$lock" diff --git a/scripts/worktree/list.sh b/scripts/worktree/list.sh index 88700670d..dbbfedce3 100755 --- a/scripts/worktree/list.sh +++ b/scripts/worktree/list.sh @@ -39,9 +39,19 @@ while IFS= read -r line; do esac done < <(git -C "$ROOT_DIR" worktree list --porcelain; echo) +for i in $(seq 1 9); do + status="$(lock_status "$i")" + [ -n "$status" ] && warn "Slot $i: $status" +done + +while IFS= read -r problem; do + [ -n "$problem" ] || continue + warn "Slot registry entry ${problem} — $(registry_repair_hint)" +done < <(registry_problems) + while IFS= read -r dup; do [ -n "$dup" ] || continue - warn "Slot registry conflict: ${dup%%:*} is registered under slots${dup#*:} — fix .worktrees/.slots/ so each worktree owns one slot." + warn "Slot registry conflict: ${dup%%:*} is registered under slots${dup#*:} — the next create keeps the slot its WORKTREE_SLOT marker names." done < <(registry_duplicates) echo From 15feae314ddf4f8209ff51652a43731a5bdf3752 Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Wed, 23 Sep 2026 03:20:05 -0300 Subject: [PATCH 6/8] =?UTF-8?q?chore(worktrees):=20final=20pass=20?= =?UTF-8?q?=E2=80=94=20refuse=20hand-moved=20worktrees,=20honest=20bucket?= =?UTF-8?q?=20removal,=20pass-through=20pipes=20in=20the=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit destroy: a .git file whose gitdir under this repo's .git/worktrees/ still exists is a moved worktree — refused with a git worktree repair hint; only a gone gitdir is removed and pruned. minio-remove: mc ls before and after rb, so "removed" means it existed and is gone. Duplicate registry resolution labels the rule that applied (marker or newest); list.sh matches. Hook: piped text is carried through tee and argument-less cat into a shell. Docs: deny sentence updated identically in all three places; scripts/db tree completed. Co-Authored-By: Claude Fable 5.1 --- .claude/hooks/bashMainCheckoutGuard.ts | 38 ++++++++++--------- .../hooks/tests/bashMainCheckoutGuard.test.ts | 26 +++++++++++++ .claude/skills/worktrees/SKILL.md | 6 +-- AI/ENTRYPOINT.md | 2 +- docs/claude/SCRIPTS.md | 7 +++- scripts/db/minio-remove.sh | 15 ++++++-- scripts/worktree/destroy.sh | 6 ++- scripts/worktree/lib.sh | 10 +++-- scripts/worktree/list.sh | 2 +- 9 files changed, 79 insertions(+), 33 deletions(-) diff --git a/.claude/hooks/bashMainCheckoutGuard.ts b/.claude/hooks/bashMainCheckoutGuard.ts index d29e54c5d..563a0f97d 100644 --- a/.claude/hooks/bashMainCheckoutGuard.ts +++ b/.claude/hooks/bashMainCheckoutGuard.ts @@ -656,26 +656,28 @@ const substitutionBody = (rawToken: string): string | undefined => { return backtick ? backtick[1] : undefined; }; -const literalOutputOf = (piece: CommandPiece): string | undefined => { +const producedText = (piece: CommandPiece, stdin: string | undefined): string | undefined => { const prefix = stripPrefixes(tokenize(piece.raw, piece.masked), piece.raw); const head = prefix.words[0] ? headOf(prefix.words[0]) : undefined; - if (!head || !LITERAL_PRODUCERS.has(head)) return undefined; - if (head === 'cat') return piece.heredocs.length > 0 ? piece.heredocs.join('\n') : undefined; - let args = prefix.words.slice(1).filter((w) => !/^-[neE]+$/.test(w.masked)); - if (head === 'printf' && args.length > 1 && args[0].raw.includes('%')) args = args.slice(1); - return args.map((w) => unquote(w.raw)).join(' '); + if (!head) return undefined; + const args = prefix.words.slice(1); + if (head === 'tee') return stdin; + if (head === 'cat') { + if (piece.heredocs.length > 0) return piece.heredocs.join('\n'); + return args.every((w) => w.masked.startsWith('-') || isRedirection(w.masked)) ? stdin : undefined; + } + if (!LITERAL_PRODUCERS.has(head)) return undefined; + let literal = args.filter((w) => !/^-[neE]+$/.test(w.masked)); + if (head === 'printf' && literal.length > 1 && literal[0].raw.includes('%')) literal = literal.slice(1); + return literal.map((w) => unquote(w.raw)).join(' '); }; const literalOutput = (command: string): string | undefined => { const first = segmentCommand(command).find((piece): piece is CommandPiece => piece.kind === 'command'); - return first ? literalOutputOf(first) : undefined; + return first ? producedText(first, undefined) : undefined; }; -const scriptFedToShell = ( - words: Word[], - piece: CommandPiece, - previous: CommandPiece | undefined, -): string | undefined => { +const scriptFedToShell = (words: Word[], piece: CommandPiece, stdin: string | undefined): string | undefined => { const inline = shellCommandString(words); if (inline !== undefined) { const body = substitutionBody(inline); @@ -683,8 +685,7 @@ const scriptFedToShell = ( } if (words.slice(1).some((w) => !w.masked.startsWith('-') && !isRedirection(w.masked))) return undefined; if (piece.heredocs.length > 0) return piece.heredocs.join('\n'); - if (previous?.terminator === '|') return literalOutputOf(previous); - return undefined; + return stdin; }; const collectMutations = (command: string, cwd: Target | undefined, landings: Landing[], depth: number) => { @@ -694,15 +695,16 @@ const collectMutations = (command: string, cwd: Target | undefined, landings: La } let cursor: Target | undefined = cwd; const stack: (Target | undefined)[] = []; - let previous: CommandPiece | undefined; + let piped: string | undefined; for (const piece of segmentCommand(command)) { if (piece.kind !== 'command') { if (piece.kind === 'open') stack.push(cursor); else if (stack.length > 0) cursor = stack.pop(); + piped = undefined; continue; } - const before = previous; - previous = piece; + const stdin = piped; + piped = piece.terminator === '|' ? producedText(piece, stdin) : undefined; for (const substitution of piece.substitutions) collectMutations(substitution, cursor, landings, depth + 1); const prefix = stripPrefixes(tokenize(piece.raw, piece.masked), piece.raw); const words = prefix.words; @@ -723,7 +725,7 @@ const collectMutations = (command: string, cwd: Target | undefined, landings: La continue; } if (SHELLS.has(head)) { - const script = scriptFedToShell(words, piece, before); + const script = scriptFedToShell(words, piece, stdin); if (script) collectMutations(script, segmentTarget, landings, depth + 1); continue; } diff --git a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts index 87ee08d76..3ffc5bba4 100644 --- a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts +++ b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts @@ -414,6 +414,27 @@ describe('isMutating', () => { } }); + test('final pass: piped text passes through tee and an argument-less cat into a shell', () => { + for (const command of [ + "cat <<'EOF' | tee f | bash\ngit push\nEOF", + 'cat < { expect(isMutating('env -C /tmp git push')).toBe(true); expect(isMutating('env --chdir=/tmp git push')).toBe(true); @@ -512,6 +533,11 @@ describe('mutationsIn: effective directory per segment', () => { expect(directories(`echo 'git push' | bash`)).toEqual([mainRoot]); }); + test('final pass: a piped body that cds into the worktree lands there', () => { + expect(directories(`cat <<'EOF' | tee f | bash\ncd ${worktreeRoot}\ngit push\nEOF`)).toEqual([worktreeRoot]); + expect(directories(`cat <<'EOF' | cat | bash\ngit push\nEOF`)).toEqual([mainRoot]); + }); + test('T4: env -C resolves the segment directory without lifting main by absolute path', () => { expect(directories(`env -C ${worktreeRoot} git push`)).toEqual([worktreeRoot]); expect(directories(`env --chdir=${worktreeRoot} git push`)).toEqual([worktreeRoot]); diff --git a/.claude/skills/worktrees/SKILL.md b/.claude/skills/worktrees/SKILL.md index ce0710c5f..d80e4aee8 100644 --- a/.claude/skills/worktrees/SKILL.md +++ b/.claude/skills/worktrees/SKILL.md @@ -16,7 +16,7 @@ Always use the scripts. The raw `git worktree add` / `git worktree remove` are i | `bun run worktree:create [--skip-db]` | Checks Docker/Postgres up front, allocates a slot (1–9) under a per-slot lock and records it in `.worktrees/.slots/`, creates the git worktree at `.worktrees//`, generates slot-specific `.env.local` / `.env.test` (root and `apps/*`) synced against the branch's `.env.*.example`, creates the Postgres `_wt_` and `_test_wt_` databases and MinIO buckets, runs `bun install` and verifies workspace resolution, builds the gitignored artifacts (route trees, Prisma client + `prismaMap.gen.ts` + zod, OpenAPI spec + SDK + MSW handlers), pushes the Prisma schema to both DBs, and warns about any worktrees whose branches are already merged into the default branch. Refuses when `` already exists (attach it instead). `--skip-db` skips the Docker gate, databases, buckets, and schema push. | | `bun run worktree:create [--skip-db]` | Same, but attaches an existing local/`origin` branch to a new worktree (useful for PR review without disturbing the main checkout). | | `bun run worktree:list` | Shows every worktree with its slot, branch, and assigned ports. | -| `bun run worktree:destroy [--force]` | Requires a worktree registered with THIS repository's `.git` (an independent repository under `.worktrees/` is refused), or a half-created directory whose `.git` FILE points under this repository's `.git/worktrees/` (removed and pruned; a `.git` file pointing at another checkout is refused with a `git worktree repair` hint, and a directory with no `.git` at all is refused). Normalises the typed name to the on-disk spelling on a case-insensitive filesystem, prints whether the branch is merged into the default branch, refuses a worktree with uncommitted or untracked changes unless `--force`, drops the Postgres databases, removes the MinIO buckets one by one with the same `STORAGE_*` values from the root `.env.local` that provisioning used (each reported as removed / already absent / could NOT remove), `FLUSHDB` on Redis DB ``, kills every listener on the worktree's four ports, removes the git worktree, frees the slot. | +| `bun run worktree:destroy [--force]` | Requires a worktree registered with THIS repository's `.git` (an independent repository under `.worktrees/` is refused), or a half-created directory whose `.git` FILE points under this repository's `.git/worktrees/` at a gitdir that no longer exists (removed and pruned). A `.git` file whose gitdir still exists — here (a worktree moved by hand) or at another checkout — is refused with a `git worktree repair` hint and nothing is deleted; a directory with no `.git` at all is refused. Normalises the typed name to the on-disk spelling on a case-insensitive filesystem, prints whether the branch is merged into the default branch, refuses a worktree with uncommitted or untracked changes unless `--force`, drops the Postgres databases, removes the MinIO buckets one by one with the same `STORAGE_*` values from the root `.env.local` that provisioning used (each bucket is listed first: `already absent` when it is not there, `removed` only when it existed and the post-check shows it gone, `could NOT remove` otherwise), `FLUSHDB` on Redis DB ``, kills every listener on the worktree's four ports, removes the git worktree, frees the slot. | `` for destroy is the worktree directory name (the branch with slashes replaced by dashes). Get it from `bun run worktree:list`. @@ -54,7 +54,7 @@ Each slot (1–9) gets a unique set of resources. If `WORKTREE_SLOT=3` (and `PRO | Redis DB index | `3` | | MinIO buckets | `template-{system,user}{,-test}-wt-3` | -Slot ownership is recorded in two places (the lock in `.worktrees/.locks/slot-` is held from allocation until the script exits, so a second create running seconds later moves on to the next slot): `.worktrees/.slots/` (one file per slot, containing the owning worktree's path — the registry) and `WORKTREE_SLOT=` on the **first line** of the worktree's `.env.local` and `.env.test` (the marker). Don't edit either. A slot counts as taken when the registry names a registered worktree for it, or when any registered worktree carries the marker **or** references the slot's database names in any of its env files — so a worktree that lost its marker still owns its slot. A registry entry counts as a claim even before its directory exists; an entry whose directory is missing is removed (with a notice) only when no lock for that slot exists and the entry is older than 120 seconds. An empty entry is corrupt (`worktree:list` warns, create removes it with a notice); a relative entry is resolved against `.worktrees/` and rewritten absolute; an entry that is a directory rather than a file is a hard error with the repair hint (each file in `.worktrees/.slots/` is named after a slot and contains the absolute worktree path). Slot evidence (`WORKTREE_SLOT`, the `DATABASE_URL` database name) is read through the env parser, so quoted or commented lines count the way `source` would see them; the project-name prefix is matched literally. Two entries naming one worktree are resolved by the next create: it keeps the slot the worktree's `WORKTREE_SLOT` marker names (newest entry as tiebreak) and removes the others with a notice; `worktree:list` warns until then. The lock reclaim (`kill -0` on the pid, or mtime for a pid-less lock — `stat -c %Y` where it works, else `stat -f %m`, else the current time with a warning, so BusyBox does not abort) renames the stale lock before deleting it, so two reclaimers cannot both claim. A worktree whose files name **more than one** slot is a conflict: create reserves every slot it names and warns; destroy refuses to guess and asks you to fix the env files first. +Slot ownership is recorded in two places (the lock in `.worktrees/.locks/slot-` is held from allocation until the script exits, so a second create running seconds later moves on to the next slot): `.worktrees/.slots/` (one file per slot, containing the owning worktree's path — the registry) and `WORKTREE_SLOT=` on the **first line** of the worktree's `.env.local` and `.env.test` (the marker). Don't edit either. A slot counts as taken when the registry names a registered worktree for it, or when any registered worktree carries the marker **or** references the slot's database names in any of its env files — so a worktree that lost its marker still owns its slot. A registry entry counts as a claim even before its directory exists; an entry whose directory is missing is removed (with a notice) only when no lock for that slot exists and the entry is older than 120 seconds. An empty entry is corrupt (`worktree:list` warns, create removes it with a notice); a relative entry is resolved against `.worktrees/` and rewritten absolute; an entry that is a directory rather than a file is a hard error with the repair hint (each file in `.worktrees/.slots/` is named after a slot and contains the absolute worktree path). Slot evidence (`WORKTREE_SLOT`, the `DATABASE_URL` database name) is read through the env parser, so quoted or commented lines count the way `source` would see them; the project-name prefix is matched literally. Two entries naming one worktree are resolved by the next create: it keeps the slot the worktree's `WORKTREE_SLOT` marker names, or the newest entry when the marker names neither, and removes the others with a notice saying which rule applied; `worktree:list` warns until then. The lock reclaim (`kill -0` on the pid, or mtime for a pid-less lock — `stat -c %Y` where it works, else `stat -f %m`, else the current time with a warning, so BusyBox does not abort) renames the stale lock before deleting it, so two reclaimers cannot both claim. A worktree whose files name **more than one** slot is a conflict: create reserves every slot it names and warns; destroy refuses to guess and asks you to fix the env files first. The main checkout uses slot 0 / default ports (`3000`, `3001`, `3002`, `8000`). It shows in `bun run worktree:list` as `(main)`. @@ -94,7 +94,7 @@ When you dispatch a subagent to do code work in a worktree, **the subagent does The same applies to your own session: the Bash tool's cwd can reset to the main checkout between calls (after a session restart, or after a command that `cd`'d outside the repo). A bare `bun add`, `git commit`, or `git push` then lands on main. -The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. +The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. Consequences worth knowing: diff --git a/AI/ENTRYPOINT.md b/AI/ENTRYPOINT.md index 11c5ffda0..3d37d4964 100644 --- a/AI/ENTRYPOINT.md +++ b/AI/ENTRYPOINT.md @@ -47,7 +47,7 @@ concluding that something is missing — it is usually present and documented. - Do not run git commands unless the user explicitly asks. - **NEVER run `git stash` — for any reason, ever.** Not for "baseline verification", not for "let me test if this is from my change", not for "let me try and see". If you need to compare against HEAD or a clean state: use `git show HEAD:` to read a file at HEAD, `git diff` to see changes, or read the file content directly. Stash has destroyed in-flight work before. - **NEVER use `git -c =` to bypass any rule** — this includes (but is not limited to) bypassing the stash ban, signing requirements, hook execution (`--no-verify`), or any other guardrail. If a command requires bypassing config, stop and ask. -- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy [--force]` (refuses a dirty worktree without `--force`), `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. See `.claude/skills/worktrees/SKILL.md`. +- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy [--force]` (refuses a dirty worktree without `--force`), `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. See `.claude/skills/worktrees/SKILL.md`. - **If something is broken, just fix it.** Don't stash-to-bisect, don't "let me see if reverting my changes makes it go away" — read the code, find the cause, fix the cause. ## 0.1. Understand the Why First diff --git a/docs/claude/SCRIPTS.md b/docs/claude/SCRIPTS.md index c8a816ac6..eab3c46fd 100644 --- a/docs/claude/SCRIPTS.md +++ b/docs/claude/SCRIPTS.md @@ -49,7 +49,12 @@ scripts/ │ ├── dump.sh # Export database │ ├── restore.sh # Import database │ ├── clone.sh # Clone remote → local (with webhook cleanup) +│ ├── push-dev.sh # db:push:dev — worktree-aware, refuses non-local DATABASE_URLs +│ ├── release.sh # Release-time database step │ ├── pg-init.sh # Postgres initialization +│ ├── minio-init.sh # MinIO container entrypoint (creates the four base buckets) +│ ├── minio-provision.sh # Ensure buckets exist (setup + worktree:create) +│ ├── minio-remove.sh # Remove buckets, reporting each (worktree:destroy) │ ├── wait-postgres.sh # Wait for postgres ready │ └── wait-redis.sh # Wait for redis ready ├── deployment/ @@ -323,7 +328,7 @@ bun run worktree:list bun run worktree:destroy [--force] # --force: destroy despite uncommitted or untracked changes ``` -Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s; `stat -c`/`stat -f` chosen per platform), counts a registry entry as a claim even before its directory exists and prunes it only when the directory is missing, no lock is held, and it is older than 120s, prints the `worktree:destroy --force` cleanup line on any failure or signal after the worktree exists (and releases the slot before it), reclaims a dead-pid lock and its directory-less registry entry with a notice while skipping a live one, aborts when the database inventory query fails, requires `DATABASE_URL` for both sides unless `--skip-db`, and drops a leftover slot database only when no registered worktree references that slot. Destroy requires a worktree whose git common dir is this repository's `.git` (or a half-created directory whose `.git` file points under this repository's `.git/worktrees/`; a `.git` file pointing elsewhere gets a `git worktree repair` hint), normalises the name to its on-disk spelling, prints whether the branch is merged into the default branch, refuses a dirty worktree unless `--force`, requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. +Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s; `stat -c`/`stat -f` chosen per platform), counts a registry entry as a claim even before its directory exists and prunes it only when the directory is missing, no lock is held, and it is older than 120s, prints the `worktree:destroy --force` cleanup line on any failure or signal after the worktree exists (and releases the slot before it), reclaims a dead-pid lock and its directory-less registry entry with a notice while skipping a live one, aborts when the database inventory query fails, requires `DATABASE_URL` for both sides unless `--skip-db`, and drops a leftover slot database only when no registered worktree references that slot. Destroy requires a worktree whose git common dir is this repository's `.git` (or a half-created directory whose `.git` file points under this repository's `.git/worktrees/` at a gitdir that is gone; a `.git` file whose gitdir still exists, here or elsewhere, gets a `git worktree repair` hint and nothing is deleted), normalises the name to its on-disk spelling, prints whether the branch is merged into the default branch, refuses a dirty worktree unless `--force`, requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. --- diff --git a/scripts/db/minio-remove.sh b/scripts/db/minio-remove.sh index 8f0faf83d..1bc6a87eb 100755 --- a/scripts/db/minio-remove.sh +++ b/scripts/db/minio-remove.sh @@ -30,15 +30,22 @@ if ! mc ls local >/dev/null 2>&1; then exit 1 fi +bucket_exists() { + mc ls "local/${1}" >/dev/null 2>&1 +} + failed=0 for bucket in "$@"; do - if mc rb --force "local/${bucket}" >/dev/null 2>&1; then - echo " MinIO: removed ${bucket}" - elif mc ls "local/${bucket}" >/dev/null 2>&1; then + if ! bucket_exists "$bucket"; then + echo " MinIO: ${bucket} was already absent" + continue + fi + mc rb --force "local/${bucket}" >/dev/null 2>&1 || true + if bucket_exists "$bucket"; then echo " MinIO: could NOT remove ${bucket} (still present)" >&2 failed=1 else - echo " MinIO: ${bucket} was already absent" + echo " MinIO: removed ${bucket}" fi done exit "$failed" diff --git a/scripts/worktree/destroy.sh b/scripts/worktree/destroy.sh index df04860bd..2997a5af3 100755 --- a/scripts/worktree/destroy.sh +++ b/scripts/worktree/destroy.sh @@ -51,8 +51,10 @@ if is_registered_worktree "$WORKTREE_DIR"; then || die "Error: $WORKTREE_DIR does not belong to this repository (its git common dir is not $ROOT_DIR/.git). Refusing to delete an independent repository." elif GITDIR="$(dot_git_file_target "$WORKTREE_DIR")"; then REGISTERED=0 - if gitdir_belongs_to_main "$GITDIR"; then + if gitdir_belongs_to_main "$GITDIR" && [ ! -e "$GITDIR" ]; then HALF_CREATED=1 + elif gitdir_belongs_to_main "$GITDIR"; then + die "Error: $WORKTREE_DIR is a worktree of this repository that was moved by hand (its gitdir $GITDIR still exists but git does not know this location). Re-attach it with: git worktree repair '$WORKTREE_DIR' — then destroy it normally. Nothing was deleted." elif [ -e "$GITDIR" ]; then die "Error: $WORKTREE_DIR is a git worktree whose gitdir ($GITDIR) belongs to another checkout. If that repository moved, run 'git worktree repair' from it. Nothing was deleted." else @@ -64,7 +66,7 @@ else die "Error: $WORKTREE_DIR is not a git worktree (no .git file, not registered). Refusing to delete it." fi -[ "$HALF_CREATED" -eq 0 ] || warn "Warning: $WORKTREE_DIR is a half-created worktree of this repository (gitdir $GITDIR is not registered) — removing the directory and pruning." +[ "$HALF_CREATED" -eq 0 ] || warn "Warning: $WORKTREE_DIR is a half-created worktree of this repository (its gitdir $GITDIR no longer exists) — removing the directory and pruning." print_first_lines() { sed -n '1,10{s/^/ /;p;}' <<< "$1" diff --git a/scripts/worktree/lib.sh b/scripts/worktree/lib.sh index e16305fda..bf056c730 100755 --- a/scripts/worktree/lib.sh +++ b/scripts/worktree/lib.sh @@ -416,15 +416,19 @@ prune_slot_registry() { } resolve_registry_duplicates() { - local dup owner slots keep marker slot newest age best + local dup owner slots keep reason marker slot age best while IFS= read -r dup; do [ -n "$dup" ] || continue owner="${dup%%:*}" slots="${dup#*:}" keep="" + reason="(newest)" if [ -d "$owner" ]; then marker="$(worktree_marker_slot "$owner")" - [[ " $slots " == *" $marker "* ]] && keep="$marker" + if [ -n "$marker" ] && [[ " $slots " == *" $marker "* ]]; then + keep="$marker" + reason="(its WORKTREE_SLOT marker)" + fi fi if [ -z "$keep" ]; then best="" @@ -435,7 +439,7 @@ resolve_registry_duplicates() { fi for slot in $slots; do [ "$slot" = "$keep" ] && continue - warn "Slot registry: $owner was registered under slots${slots} — keeping $keep$( [ -d "$owner" ] && echo " (its WORKTREE_SLOT marker)" || echo " (newest)"), removing $slot." + warn "Slot registry: $owner was registered under slots${slots} — keeping $keep $reason, removing $slot." rm -f "$(registry_file "$slot")" done done < <(registry_duplicates) diff --git a/scripts/worktree/list.sh b/scripts/worktree/list.sh index dbbfedce3..e4a8f2e37 100755 --- a/scripts/worktree/list.sh +++ b/scripts/worktree/list.sh @@ -51,7 +51,7 @@ done < <(registry_problems) while IFS= read -r dup; do [ -n "$dup" ] || continue - warn "Slot registry conflict: ${dup%%:*} is registered under slots${dup#*:} — the next create keeps the slot its WORKTREE_SLOT marker names." + warn "Slot registry conflict: ${dup%%:*} is registered under slots${dup#*:} — the next create keeps the slot its WORKTREE_SLOT marker names, else the newest entry." done < <(registry_duplicates) echo From 312a6bc500404fd0e3c27568f3865785f4295f10 Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Thu, 24 Sep 2026 00:18:09 -0300 Subject: [PATCH 7/8] chore(worktrees): cut the main-checkout guard back to its purpose The guard exists to catch an honest agent whose Bash cwd drifted back to the main checkout. Three adversarial rounds turned it into a ~940-line shell parser chasing evasion (piped heredocs, sudo tables, case folding) while it missed the two rules that matter: - git worktree add/remove/move/prune is denied in every session, pointing at bun run worktree:create/destroy. - once a session (or subagent, keyed by agent_id) has worked in a registered worktree of this repository, a mutating command landing in the main checkout is denied on ANY branch; REQUIRE_MAIN_BRANCH is gone, since the main checkout usually sits on a feature branch and that is exactly where a drifted commit does damage. Commands are read by words with quoted strings masked and heredoc bodies dropped; cd / git -C / bun --cwd move the landing, ( ) scopes a cd, an absolute path into main is an explicit choice, a $VAR target is unknown and allowed. State is keyed per session, subagent and main checkout. 939 -> ~290 lines; tests 1002 -> ~320 lines, 20 pass. Docs replace the long deny sentence in ENTRYPOINT, SCRIPTS and the worktrees skill. Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/hooks/bashMainCheckoutGuard.ts | 961 +++-------------- .../hooks/tests/bashMainCheckoutGuard.test.ts | 970 +++--------------- .claude/skills/worktrees/SKILL.md | 17 +- AI/ENTRYPOINT.md | 2 +- docs/claude/SCRIPTS.md | 2 +- 5 files changed, 303 insertions(+), 1649 deletions(-) diff --git a/.claude/hooks/bashMainCheckoutGuard.ts b/.claude/hooks/bashMainCheckoutGuard.ts index 563a0f97d..fdc3a1e5c 100644 --- a/.claude/hooks/bashMainCheckoutGuard.ts +++ b/.claude/hooks/bashMainCheckoutGuard.ts @@ -16,773 +16,165 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; type HookInput = { hook_event_name?: string; session_id?: string; + agent_id?: string; tool_name?: string; cwd?: string; tool_input?: { command?: string }; }; -export const REQUIRE_MAIN_BRANCH = true; -const MAX_DEPTH = 6; -const CASE_INSENSITIVE_FS = process.platform === 'darwin'; - -const MASK = '\u0001'; -const HEREDOC_AT = /^<<(-?)\s*(['"]?)(\w+)\2/; -const KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'do', 'done', 'while', 'until', '!']); -const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash']); -const LITERAL_PRODUCERS = new Set(['cat', 'echo', 'printf']); -type Runner = { values: Set; positionals?: number; stopOn?: Set; chdir?: Set }; -const RUNNERS: Record = { - env: { - values: new Set(['-u', '--unset', '-S', '--split-string', '-C', '--chdir']), - chdir: new Set(['-C', '--chdir']), - }, - command: { values: new Set(), stopOn: new Set(['-v', '-V']) }, - exec: { values: new Set(['-a']) }, - nohup: { values: new Set() }, - sudo: { - values: new Set([ - '-u', - '-g', - '-C', - '-h', - '-p', - '-U', - '-r', - '-t', - '-T', - '-D', - '--user', - '--group', - '--host', - '--prompt', - ]), - }, - doas: { values: new Set(['-u']) }, - timeout: { values: new Set(['-k', '-s', '--kill-after', '--signal']), positionals: 1 }, - nice: { values: new Set(['-n', '--adjustment']) }, - time: { values: new Set(['-o', '-f']) }, - xargs: { values: new Set(['-n', '-I', '-P', '-L', '-s', '-d', '-E', '-a', '-i']) }, - caffeinate: { values: new Set(['-t', '-w']) }, - rtk: { values: new Set() }, - bunx: { values: new Set(['-p', '--package']) }, - npx: { values: new Set(['-p', '--package', '-c', '--call']) }, - pnpx: { values: new Set() }, -}; const GIT_MUTATING = new Set(['commit', 'push', 'merge', 'rebase', 'cherry-pick', 'revert', 'am']); -const GIT_RESUMABLE = new Set(['merge', 'rebase', 'cherry-pick', 'revert', 'am']); -const GIT_WORKTREE_MUTATING = new Set(['add', 'remove', 'move', 'prune']); -const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']); -const GIT_RESUMED = new Set(['--abort', '--continue', '--quit', '--skip']); +const GIT_WORKTREE_LIFECYCLE = new Set(['add', 'remove', 'move', 'prune']); +const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree']); const BUN_MUTATING = new Set(['add', 'a', 'remove', 'rm', 'update']); const BUN_INSTALL = new Set(['install', 'i']); -const BUN_SCRIPTS = new Set(['db:migrate']); -const BUN_GLOBAL_OPTIONS_WITH_VALUE = new Set([ - '--cwd', - '--config', - '-c', - '--env-file', - '--port', - '--shell', - '-d', - '--define', - '-r', - '--preload', - '-l', - '--loader', - '--target', +const BUN_OPTIONS_WITH_VALUE = new Set([ '--filter', '-F', - '--conditions', - '--tsconfig-override', - '--main-fields', - '--extension-order', - '--title', - '--backend', - '--registry', '--linker', + '--backend', '--omit', - '--concurrent-scripts', - '--network-concurrency', -]); -const BUN_SUBCOMMAND_OPTIONS_WITH_VALUE = new Set([ - '--cwd', + '--registry', '--config', '-c', '--env-file', - '--backend', - '--registry', - '--target', - '--filter', - '-F', - '--linker', - '--omit', - '--concurrent-scripts', - '--network-concurrency', ]); -const CD_OPTIONS = new Set(['-P', '-L', '-e', '-@', '--']); -const SHELL_OPTIONS_WITH_VALUE = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file']); - -type CommandPiece = { - kind: 'command'; - raw: string; - masked: string; - terminator: string; - substitutions: string[]; - heredocs: string[]; -}; -type Piece = { kind: 'open' | 'close' } | CommandPiece; - -const isWordBoundary = (ch: string | undefined) => ch === undefined || /[\s;&|(){}]/.test(ch); - -export const segmentCommand = (command: string): Piece[] => { - const text = command.replace(/\\\r?\n/g, ''); - const pieces: Piece[] = []; - let raw = ''; - let masked = ''; - let substitutions: string[] = []; - let currentHeredocs: { delimiter: string; stripTabs: boolean }[] = []; - let pendingHeredocs: { piece: CommandPiece; delimiter: string; stripTabs: boolean }[] = []; - let substitutionEnd = -1; - - const flush = (terminator: string) => { - const lead = masked.length - masked.trimStart().length; - const trail = masked.length - masked.trimEnd().length; - if (masked.trim()) { - const piece: CommandPiece = { - kind: 'command', - raw: raw.slice(lead, raw.length - trail), - masked: masked.trim(), - terminator, - substitutions, - heredocs: [], - }; - pieces.push(piece); - for (const heredoc of currentHeredocs) pendingHeredocs.push({ piece, ...heredoc }); - } - raw = ''; - masked = ''; - substitutions = []; - currentHeredocs = []; - }; - const add = (r: string, m = r) => { - raw += r; - masked += m; - }; - const scanSingle = (from: number) => { - const end = text.indexOf("'", from + 1); - return end === -1 ? text.length : end; - }; - const scanBacktick = (from: number) => { - let j = from + 1; - while (j < text.length && text[j] !== '`') j += text[j] === '\\' ? 2 : 1; - return Math.min(j, text.length); - }; - const scanParen = (from: number): number => { - let depth = 1; - let j = from + 1; - while (j < text.length) { - const ch = text[j]; - if (ch === '\\') j += 2; - else if (ch === "'") j = scanSingle(j) + 1; - else if (ch === '"') j = scanDouble(j) + 1; - else if (ch === '`') j = scanBacktick(j) + 1; - else if (ch === '(') { - depth += 1; - j += 1; - } else if (ch === ')') { - depth -= 1; - if (depth === 0) return j; - j += 1; - } else j += 1; - } - return text.length; - }; - const scanDouble = (from: number): number => { - let j = from + 1; - while (j < text.length) { - const ch = text[j]; - if (ch === '\\') j += 2; - else if (ch === '"') return j; - else if (ch === '$' && text[j + 1] === '(') j = scanParen(j + 1) + 1; - else if (ch === '`') j = scanBacktick(j) + 1; - else j += 1; - } - return text.length; - }; - const collectSubstitutions = (from: number, to: number) => { - let j = from; - while (j < to) { - const ch = text[j]; - if (ch === '\\') j += 2; - else if (ch === '$' && text[j + 1] === '(') { - const end = scanParen(j + 1); - substitutions.push(text.slice(j + 2, end)); - j = end + 1; - } else if (ch === '`') { - const end = scanBacktick(j); - substitutions.push(text.slice(j + 1, end)); - j = end + 1; - } else j += 1; - } - }; - const readHeredocBodies = (from: number): number => { - let i = from; - for (const { piece, delimiter, stripTabs } of pendingHeredocs) { - const body: string[] = []; - while (i < text.length) { - const lineEnd = text.indexOf('\n', i); - const line = text.slice(i, lineEnd === -1 ? text.length : lineEnd).replace(/\r$/, ''); - i = lineEnd === -1 ? text.length : lineEnd + 1; - if ((stripTabs ? line.replace(/^\t+/, '') : line) === delimiter) break; - body.push(stripTabs ? line.replace(/^\t+/, '') : line); - } - piece.heredocs.push(body.join('\n')); - } - pendingHeredocs = []; - return i; - }; - const masking = (from: number, to: number) => text.slice(from, to).replace(/[^\n]/g, MASK); - - let i = 0; - while (i < text.length) { - const ch = text[i]; - const prev = i === 0 ? undefined : text[i - 1]; - const next = text[i + 1]; - if (ch === '\\') { - add(text.slice(i, i + 2)); - i += 2; - continue; - } - if (ch === "'") { - const end = scanSingle(i); - const inner = text.slice(i + 1, end); - const close = end < text.length ? "'" : ''; - add(`'${inner}${close}`, `'${MASK.repeat(inner.length)}${close}`); - i = end + 1; - continue; - } - if (ch === '"') { - const end = scanDouble(i); - collectSubstitutions(i + 1, end); - const inner = text.slice(i + 1, end); - const close = end < text.length ? '"' : ''; - add(`"${inner}${close}`, `"${MASK.repeat(inner.length)}${close}`); - i = end + 1; - continue; - } - if (ch === '`') { - const end = scanBacktick(i); - substitutions.push(text.slice(i + 1, end)); - const stop = Math.min(end + 1, text.length); - add(text.slice(i, stop), masking(i, stop)); - i = stop; - substitutionEnd = i; - continue; - } - if (ch === '$' && next === '(') { - const end = scanParen(i + 1); - substitutions.push(text.slice(i + 2, end)); - const stop = Math.min(end + 1, text.length); - add(text.slice(i, stop), masking(i, stop)); - i = stop; - substitutionEnd = i; - continue; - } - if (ch === '$' && next === '{') { - let depth = 0; - let j = i + 1; - while (j < text.length) { - if (text[j] === '{') depth += 1; - else if (text[j] === '}') { - depth -= 1; - if (depth === 0) break; - } - j += 1; - } - const stop = Math.min(j + 1, text.length); - add(text.slice(i, stop), `$${MASK.repeat(stop - i - 1)}`); - i = stop; - substitutionEnd = i; - continue; - } - if (ch === '#' && isWordBoundary(prev) && i !== substitutionEnd) { - const lineEnd = text.indexOf('\n', i); - i = lineEnd === -1 ? text.length : lineEnd; - continue; - } - if (text.startsWith('<<<', i)) { - add('<<<'); - i += 3; - continue; - } - const heredoc = text.slice(i).match(HEREDOC_AT); - if (heredoc) { - currentHeredocs.push({ delimiter: heredoc[3], stripTabs: heredoc[1] === '-' }); - add(heredoc[0]); - i += heredoc[0].length; - continue; - } - if (ch === '\n' || (ch === '\r' && next === '\n')) { - flush('\n'); - i += ch === '\r' ? 2 : 1; - if (pendingHeredocs.length > 0) i = readHeredocBodies(i); - continue; - } - if (ch === ';') { - flush(';'); - i += next === ';' ? 2 : 1; - continue; - } - if (ch === '&' && next === '&') { - flush('&&'); - i += 2; - continue; - } - if (ch === '|' && next === '|') { - flush('||'); - i += 2; - continue; - } - if (ch === '|') { - flush('|'); - i += next === '&' ? 2 : 1; - continue; - } - if (ch === '&' && prev !== '>' && prev !== '<' && next !== '>') { - flush('&'); - i += 1; - continue; - } - if (ch === '(') { - flush('('); - pieces.push({ kind: 'open' }); - i += 1; - continue; - } - if (ch === ')') { - flush(')'); - pieces.push({ kind: 'close' }); - i += 1; - continue; - } - if ((ch === '{' || ch === '}') && isWordBoundary(prev) && (next === undefined || /[\s;&|)]/.test(next))) { - flush(ch); - i += 1; - continue; - } - add(ch); - i += 1; - } - flush(''); - if (pendingHeredocs.length > 0) readHeredocBodies(text.length); - return pieces; -}; - -type Word = { raw: string; masked: string }; - -const tokenize = (raw: string, masked: string): Word[] => { - const words: Word[] = []; - for (const match of masked.matchAll(/\S+/g)) { - const start = match.index ?? 0; - words.push({ raw: raw.slice(start, start + match[0].length), masked: match[0] }); - } - return words; -}; - -const unquote = (token: string) => { - const trimmed = /^\$'/.test(token) ? token.slice(1) : token; - const match = trimmed.match(/^(["'])([\s\S]*)\1$/); - return match ? match[2] : trimmed; -}; - -const headOf = (word: Word): string | undefined => { - if (/\$\(|`|\$\{|\$\w/.test(word.raw)) return undefined; - let head = word.raw.replace(/\$'/g, "'").replace(/\\(.)/g, '$1').replace(/["']/g, ''); - if (head.includes('/')) head = basename(head); - return CASE_INSENSITIVE_FS ? head.toLowerCase() : head; -}; - -const isAssignment = (word: Word) => /^[A-Za-z_]\w*=/.test(word.raw); - -type Prefix = { words: Word[]; bare: string; gitDir?: string; gitWorkTree?: string; chdir?: string }; +const PASS_THROUGH = new Set(['rtk', 'proxy', 'env', 'command', 'sudo', 'time', 'nohup', 'bunx', 'npx']); -const stripPrefixes = (words: Word[], raw: string): Prefix => { - let rest = words; - let bareStart = 0; - let gitDir: string | undefined; - let gitWorkTree: string | undefined; - let chdir: string | undefined; - while (rest.length > 0 && KEYWORDS.has(rest[0].raw)) { - bareStart += 1; - rest = rest.slice(1); - } - const bare = tokenize(raw, raw) - .slice(bareStart) - .map((w) => w.raw) - .join(' '); - for (let guard = 0; guard < 32 && rest.length > 0; guard += 1) { - const word = rest[0]; - if (isAssignment(word)) { - const [name, value] = word.raw.split(/=(.*)/s); - if (name === 'GIT_DIR') gitDir = value; - if (name === 'GIT_WORK_TREE') gitWorkTree = value; - rest = rest.slice(1); - continue; - } - const head = headOf(word); - if (!head) break; - if (head === 'bun' && rest[1]?.raw === 'x') { - rest = rest.slice(2); - continue; - } - if (head === 'rtk') { - rest = rest.slice(rest[1]?.raw === 'proxy' ? 2 : 1); - continue; - } - const runner = RUNNERS[head]; - if (!runner) break; - let j = 1; - let positionals = runner.positionals ?? 0; - let stopped = false; - while (j < rest.length) { - const current = rest[j].raw; - if (current === '--') { - j += 1; - break; - } - if (runner.stopOn?.has(current)) { - stopped = true; - break; - } - if (current.startsWith('-') && current.length > 1) { - const [name, inline] = current.split(/=(.*)/s); - const takesValue = inline === undefined && runner.values.has(name); - if (runner.chdir?.has(name)) chdir = takesValue ? rest[j + 1]?.raw : inline; - j += takesValue ? 2 : 1; - continue; - } - if (positionals > 0) { - positionals -= 1; - j += 1; - continue; - } - break; - } - if (stopped) return { words: [], bare, gitDir, gitWorkTree, chdir }; - rest = rest.slice(j); - } - return { words: rest, bare, gitDir, gitWorkTree, chdir }; +export type Landing = { + command: string; + directory?: string; + absolute: boolean; + lifecycle: boolean; }; -type Checkout = { root: string; isMain: boolean }; - -const linkedGitdir = (dotGitFile: string, root: string): string | undefined => { - const gitdir = readFileSync(dotGitFile, 'utf8') - .match(/^gitdir:\s*(.+)$/m)?.[1] - ?.trim(); - return gitdir ? resolve(root, gitdir) : undefined; -}; +const unquote = (token: string) => token.replace(/^(['"])(.*)\1$/s, '$2'); -const findCheckout = (from: string): Checkout | undefined => { - let dir = resolve(from); - while (true) { - const dotGit = join(dir, '.git'); - if (existsSync(dotGit)) { - if (statSync(dotGit).isDirectory()) return { root: dir, isMain: true }; - const gitdir = linkedGitdir(dotGit, dir); - return gitdir && existsSync(gitdir) ? { root: dir, isMain: false } : undefined; - } - const parent = dirname(dir); - if (parent === dir) return undefined; - dir = parent; - } -}; +const withoutHeredocBodies = (command: string) => + command.replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n\s*\2(?=\n|$)/g, '$3'); -const isDirectory = (path: string) => existsSync(path) && statSync(path).isDirectory(); +const maskQuoted = (command: string) => + command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (quoted) => + quoted.replace(/[\s;&|(){}]/g, (ch) => `\u0001${ch.charCodeAt(0)}\u0002`), + ); -const isWithin = (path: string, root: string) => path === root || path.startsWith(`${root}/`); +const unmask = (text: string) => text.replace(/\u0001(\d+)\u0002/g, (_, code) => String.fromCharCode(Number(code))); -type Target = { directory: string; named?: string }; +const expandHome = (path: string) => (path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(1)) : path); -const expandToken = (rawToken: string): string | undefined => { - const token = unquote(rawToken); - if (!token || /[$`*?]/.test(token) || token === '-') return undefined; - return token === '~' || token.startsWith('~/') ? join(homedir(), token.slice(1)) : token; -}; +const realOrResolved = (path: string) => (existsSync(path) ? realpathSync(path) : resolve(path)); -const resolveTarget = (rawToken: string, from: Target | undefined): Target | undefined => { - const expanded = expandToken(rawToken); - if (!expanded) return undefined; - if (isAbsolute(expanded)) { - const named = resolve(expanded); - return isDirectory(named) ? { directory: realpathSync(named), named } : undefined; - } - if (!from) return undefined; - const path = resolve(from.directory, expanded); - return isDirectory(path) ? { directory: realpathSync(path) } : undefined; -}; +type Cursor = { directory?: string; absolute: boolean }; -const resolveRedirect = (rawToken: string, from: Target | undefined, gitDir: boolean): string | undefined => { - const expanded = expandToken(rawToken); - if (!expanded) return undefined; - if (!isAbsolute(expanded) && !from) return undefined; - const path = isAbsolute(expanded) ? resolve(expanded) : resolve(from?.directory as string, expanded); - if (!existsSync(path)) return undefined; - const real = realpathSync(path); - if (!gitDir) return statSync(real).isDirectory() ? real : dirname(real); - return statSync(real).isDirectory() && basename(real) !== '.git' ? real : dirname(real); +const moveTo = (cursor: Cursor, target: string | undefined): Cursor => { + const path = expandHome(unquote(target ?? '~')); + if (/[$`]/.test(path)) return { absolute: false }; + if (isAbsolute(path)) return { directory: realOrResolved(path), absolute: true }; + if (!cursor.directory) return cursor; + return { directory: realOrResolved(resolve(cursor.directory, path)), absolute: cursor.absolute }; }; -type Mutation = { target?: string; redirect?: { via: string; path: string } }; - -type Parsed = { positionals: Word[]; target?: string; options: Set; gitDir?: string; gitWorkTree?: string }; - -const parseGit = (words: Word[]): Parsed => { - const options = new Set(); - let target: string | undefined; - let gitDir: string | undefined; - let gitWorkTree: string | undefined; - let i = 1; - while (i < words.length && words[i].masked.startsWith('-')) { - const [name, inline] = words[i].masked.split(/=(.*)/s); - const takesValue = inline === undefined && GIT_OPTIONS_WITH_VALUE.has(name); - const value = takesValue ? words[i + 1]?.raw : words[i].raw.split(/=(.*)/s)[1]; - if (name === '-C') target = value; - if (name === '--git-dir') gitDir = value; - if (name === '--work-tree') gitWorkTree = value; - i += takesValue ? 2 : 1; - } - const positionals = words.slice(i); - for (const word of positionals) if (word.masked.startsWith('-')) options.add(word.masked); - return { positionals: positionals.filter((w) => !w.masked.startsWith('-')), target, options, gitDir, gitWorkTree }; +const commandWords = (segment: string): string[] => { + const words = segment + .replace(/[(){}]/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean); + let start = 0; + while (start < words.length && (/^[A-Za-z_]\w*=/.test(words[start]) || PASS_THROUGH.has(basename(words[start])))) + start += 1; + return words.slice(start).map((word, i) => (i === 0 ? basename(unquote(unmask(word))) : unmask(word))); }; -const parseBun = (words: Word[]): Parsed => { - const options = new Set(); - const positionals: Word[] = []; - let target: string | undefined; +const gitLanding = (words: string[], cursor: Cursor): Omit | undefined => { + let here = cursor; let i = 1; - while (i < words.length) { - const word = words[i]; - if (word.masked.startsWith('-') && word.masked.length > 1) { - const [name, inline] = word.masked.split(/=(.*)/s); - const table = positionals.length === 0 ? BUN_GLOBAL_OPTIONS_WITH_VALUE : BUN_SUBCOMMAND_OPTIONS_WITH_VALUE; - const takesValue = inline === undefined && table.has(name); - options.add(name); - if (name === '--cwd') target = takesValue ? words[i + 1]?.raw : word.raw.split(/=(.*)/s)[1]; - i += takesValue ? 2 : 1; - continue; - } - positionals.push(word); - i += 1; + while (i < words.length && words[i].startsWith('-')) { + if (words[i] === '-C') here = moveTo(here, words[i + 1]); + i += GIT_OPTIONS_WITH_VALUE.has(words[i]) ? 2 : 1; } - return { positionals, target, options }; + const subcommand = words[i]; + if (subcommand === 'worktree' && GIT_WORKTREE_LIFECYCLE.has(words[i + 1] ?? '')) return { ...here, lifecycle: true }; + return GIT_MUTATING.has(subcommand ?? '') ? { ...here, lifecycle: false } : undefined; }; -const isPrismaMigrateDev = (words: Word[]) => - words[0]?.masked === 'prisma' && words[1]?.masked === 'migrate' && words[2]?.masked === 'dev'; - -const mutationIn = (head: string, words: Word[], prefix: Prefix): Mutation | undefined => { - if (head === 'git') { - const parsed = parseGit(words); - if (parsed.options.has('--dry-run')) return undefined; - const subcommand = parsed.positionals[0]?.masked; - if (!subcommand) return undefined; - const gitDir = parsed.gitDir ?? prefix.gitDir; - const gitWorkTree = parsed.gitWorkTree ?? prefix.gitWorkTree; - const redirect = gitWorkTree - ? { via: parsed.gitWorkTree ? '--work-tree' : 'GIT_WORK_TREE', path: gitWorkTree } - : gitDir - ? { via: parsed.gitDir ? '--git-dir' : 'GIT_DIR', path: gitDir } - : undefined; - if (GIT_MUTATING.has(subcommand)) { - if (GIT_RESUMABLE.has(subcommand) && [...parsed.options].some((o) => GIT_RESUMED.has(o))) return undefined; - return { target: parsed.target, redirect }; - } - if (subcommand === 'worktree' && GIT_WORKTREE_MUTATING.has(parsed.positionals[1]?.masked ?? '')) - return { target: parsed.target, redirect }; - return undefined; - } - if (head === 'bun') { - const parsed = parseBun(words); - if (parsed.options.has('--dry-run')) return undefined; - const subcommand = parsed.positionals[0]?.masked; - if (!subcommand) return undefined; - const rest = parsed.positionals.slice(1); - if (BUN_MUTATING.has(subcommand)) return { target: parsed.target }; - if (BUN_INSTALL.has(subcommand)) return rest.length > 0 ? { target: parsed.target } : undefined; - if (BUN_SCRIPTS.has(subcommand) || isPrismaMigrateDev(parsed.positionals)) return { target: parsed.target }; - if (subcommand !== 'run') return undefined; - const script = rest[0]?.masked; - if (script && (BUN_SCRIPTS.has(script) || isPrismaMigrateDev(rest))) return { target: parsed.target }; - return undefined; - } - if (head === 'prisma') - return isPrismaMigrateDev(words) && !words.some((w) => w.masked === '--dry-run') ? {} : undefined; - return undefined; -}; +const isPrismaMigrateDev = (words: string[]) => words.join(' ').match(/(^|\s)prisma migrate dev(\s|$)/) !== null; -export type Landing = { - command: string; - directory?: string; - named?: string; - namedTarget?: string; - redirect?: string; - reason?: string; -}; - -const isRedirection = (word: string) => /^\d*(<<>?|<>|[<>]&)/.test(word); - -const shellCommandString = (words: Word[]): string | undefined => { - let dashC = false; +const bunLanding = (words: string[], cursor: Cursor): Omit | undefined => { + let here = cursor; + const positionals: string[] = []; for (let i = 1; i < words.length; i += 1) { - const word = words[i]; - if (word.masked === '--') return dashC ? words[i + 1]?.raw : undefined; - if (word.masked.startsWith('-') || word.masked.startsWith('+')) { - if (/^-[A-Za-z]*c[A-Za-z]*$/.test(word.masked)) dashC = true; - if (SHELL_OPTIONS_WITH_VALUE.has(word.masked) || /^[-+][A-Za-z]*[oO]$/.test(word.masked)) i += 1; - continue; - } - return dashC ? word.raw : undefined; + const [name, inline] = words[i].split(/=(.*)/s); + if (name === '--cwd') here = moveTo(here, inline ?? words[i + 1]); + if ((name === '--cwd' || BUN_OPTIONS_WITH_VALUE.has(name)) && inline === undefined) i += 1; + else if (!words[i].startsWith('-')) positionals.push(words[i]); } - return undefined; + const [subcommand, ...rest] = positionals; + const script = subcommand === 'run' ? rest[0] : subcommand; + const mutating = + BUN_MUTATING.has(subcommand ?? '') || + (BUN_INSTALL.has(subcommand ?? '') && rest.length > 0) || + script === 'db:migrate' || + isPrismaMigrateDev(positionals); + return mutating ? { ...here, lifecycle: false } : undefined; }; -const substitutionBody = (rawToken: string): string | undefined => { - const token = unquote(rawToken); - const dollar = token.match(/^\$\(([\s\S]*)\)$/); - if (dollar) return dollar[1]; - const backtick = token.match(/^`([\s\S]*)`$/); - return backtick ? backtick[1] : undefined; +const landingOf = (words: string[], cursor: Cursor): Omit | undefined => { + if (words[0] === 'git') return gitLanding(words, cursor); + if (words[0] === 'bun') return bunLanding(words, cursor); + return words[0] === 'prisma' && isPrismaMigrateDev(words) ? { ...cursor, lifecycle: false } : undefined; }; -const producedText = (piece: CommandPiece, stdin: string | undefined): string | undefined => { - const prefix = stripPrefixes(tokenize(piece.raw, piece.masked), piece.raw); - const head = prefix.words[0] ? headOf(prefix.words[0]) : undefined; - if (!head) return undefined; - const args = prefix.words.slice(1); - if (head === 'tee') return stdin; - if (head === 'cat') { - if (piece.heredocs.length > 0) return piece.heredocs.join('\n'); - return args.every((w) => w.masked.startsWith('-') || isRedirection(w.masked)) ? stdin : undefined; +export const landingsIn = (command: string, cwd: string): Landing[] => { + const landings: Landing[] = []; + let cursor: Cursor = { directory: realOrResolved(cwd), absolute: false }; + const subshells: Cursor[] = []; + for (const segment of maskQuoted(withoutHeredocBodies(command)).split(/&&|\|\||[;|\n]/)) { + const opens = segment.match(/^\s*\(+/)?.[0].trim().length ?? 0; + const closes = segment.match(/\)+\s*$/)?.[0].trim().length ?? 0; + for (let i = 0; i < opens; i += 1) subshells.push(cursor); + const words = commandWords(segment); + if (words[0] === 'cd' || words[0] === 'pushd') { + cursor = moveTo( + cursor, + words.find((word, i) => i > 0 && !word.startsWith('-')), + ); + } else { + const landing = words.length > 0 ? landingOf(words, cursor) : undefined; + if (landing) landings.push({ command: unmask(segment).trim(), ...landing }); + } + for (let i = 0; i < closes && subshells.length > 0; i += 1) cursor = subshells.pop() as Cursor; } - if (!LITERAL_PRODUCERS.has(head)) return undefined; - let literal = args.filter((w) => !/^-[neE]+$/.test(w.masked)); - if (head === 'printf' && literal.length > 1 && literal[0].raw.includes('%')) literal = literal.slice(1); - return literal.map((w) => unquote(w.raw)).join(' '); + return landings; }; -const literalOutput = (command: string): string | undefined => { - const first = segmentCommand(command).find((piece): piece is CommandPiece => piece.kind === 'command'); - return first ? producedText(first, undefined) : undefined; -}; +type Checkout = { root: string; mainRoot: string }; -const scriptFedToShell = (words: Word[], piece: CommandPiece, stdin: string | undefined): string | undefined => { - const inline = shellCommandString(words); - if (inline !== undefined) { - const body = substitutionBody(inline); - return body === undefined ? unquote(inline) : literalOutput(body); - } - if (words.slice(1).some((w) => !w.masked.startsWith('-') && !isRedirection(w.masked))) return undefined; - if (piece.heredocs.length > 0) return piece.heredocs.join('\n'); - return stdin; +const mainRootOfLinked = (dotGitFile: string): string | undefined => { + const gitdir = readFileSync(dotGitFile, 'utf8') + .match(/^gitdir:\s*(.+)$/m)?.[1] + ?.trim(); + if (!gitdir) return undefined; + const commonDir = resolve(dirname(dotGitFile), gitdir, '..', '..'); + return basename(commonDir) === '.git' && existsSync(commonDir) ? realpathSync(dirname(commonDir)) : undefined; }; -const collectMutations = (command: string, cwd: Target | undefined, landings: Landing[], depth: number) => { - if (depth > MAX_DEPTH) { - landings.push({ command, reason: 'nested more deeply than this hook analyses' }); - return; - } - let cursor: Target | undefined = cwd; - const stack: (Target | undefined)[] = []; - let piped: string | undefined; - for (const piece of segmentCommand(command)) { - if (piece.kind !== 'command') { - if (piece.kind === 'open') stack.push(cursor); - else if (stack.length > 0) cursor = stack.pop(); - piped = undefined; - continue; - } - const stdin = piped; - piped = piece.terminator === '|' ? producedText(piece, stdin) : undefined; - for (const substitution of piece.substitutions) collectMutations(substitution, cursor, landings, depth + 1); - const prefix = stripPrefixes(tokenize(piece.raw, piece.masked), piece.raw); - const words = prefix.words; - if (words.length === 0) continue; - const head = headOf(words[0]); - if (!head) continue; - const segmentCursor = prefix.chdir ? resolveTarget(prefix.chdir, cursor) : cursor; - const segmentTarget = prefix.chdir && segmentCursor ? { directory: segmentCursor.directory } : segmentCursor; - if (head === 'cd' || head === 'pushd' || head === 'popd') { - const backgrounded = piece.terminator === '|' || piece.terminator === '&'; - if (backgrounded) continue; - if (head === 'popd') { - cursor = undefined; - continue; - } - const target = words.slice(1).find((w) => !CD_OPTIONS.has(w.masked) && !/^-[PLe@]+$/.test(w.masked)); - cursor = resolveTarget(target?.raw ?? '~', cursor); - continue; - } - if (SHELLS.has(head)) { - const script = scriptFedToShell(words, piece, stdin); - if (script) collectMutations(script, segmentTarget, landings, depth + 1); - continue; - } - if (head === 'eval') { - const inner = words - .slice(1) - .map((w) => { - const body = substitutionBody(w.raw); - return body === undefined ? unquote(w.raw) : (literalOutput(body) ?? ''); - }) - .join(' '); - collectMutations(inner, segmentTarget, landings, depth + 1); - continue; - } - const mutation = mutationIn(head, words, prefix); - if (!mutation) continue; - if (mutation.redirect) { - const gitDir = mutation.redirect.via === '--git-dir' || mutation.redirect.via === 'GIT_DIR'; - const directory = resolveRedirect(mutation.redirect.path, segmentTarget, gitDir); - landings.push({ - command: prefix.bare, - directory, - redirect: mutation.redirect.via, - namedTarget: mutation.redirect.path, - }); - continue; +const findCheckout = (from: string): Checkout | undefined => { + let dir = resolve(from); + while (true) { + const dotGit = join(dir, '.git'); + if (existsSync(dotGit)) { + const root = realpathSync(dir); + if (statSync(dotGit).isDirectory()) return { root, mainRoot: root }; + const mainRoot = mainRootOfLinked(dotGit); + return mainRoot ? { root, mainRoot } : undefined; } - const target = mutation.target ? resolveTarget(mutation.target, segmentTarget) : segmentTarget; - landings.push({ - command: prefix.bare, - directory: target?.directory, - named: target?.named, - namedTarget: mutation.target ?? prefix.chdir, - }); + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; } }; -export const mutationsIn = (command: string, cwd?: string): Landing[] => { - const landings: Landing[] = []; - collectMutations(command, cwd && isDirectory(cwd) ? { directory: realpathSync(cwd) } : undefined, landings, 0); - return landings; -}; - -export const isMutating = (command: string) => mutationsIn(command).length > 0; - -const currentBranch = (root: string): string | undefined => { - const head = readFileSync(join(root, '.git/HEAD'), 'utf8').trim(); - return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : undefined; -}; - -export const defaultBranch = (root: string): string => { - const originHead = join(root, '.git/refs/remotes/origin/HEAD'); - if (!existsSync(originHead)) return 'main'; - const ref = readFileSync(originHead, 'utf8').trim(); - const prefix = 'ref: refs/remotes/origin/'; - return ref.startsWith(prefix) ? ref.slice(prefix.length) : 'main'; -}; +const isWithin = (path: string, root: string) => path === root || path.startsWith(`${root}/`); export const registeredWorktrees = (mainRoot: string): string[] => { try { @@ -790,14 +182,13 @@ export const registeredWorktrees = (mainRoot: string): string[] => { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }); - const mainReal = realpathSync(mainRoot); return listing .split('\n') .filter((line) => line.startsWith('worktree ')) .map((line) => line.slice('worktree '.length).trim()) - .filter((path) => isDirectory(path)) + .filter((path) => existsSync(path)) .map((path) => realpathSync(path)) - .filter((path) => path !== mainReal); + .filter((path) => path !== realpathSync(mainRoot)); } catch { return []; } @@ -809,108 +200,76 @@ export type SessionState = { prune: (maxAgeMs: number) => void; }; -const reportOnce = (() => { - let reported = false; - return (message: string) => { - if (reported) return; - reported = true; - console.error(`bashMainCheckoutGuard: ${message}`); - }; -})(); - export const fileSessionState = (dir = join(tmpdir(), 'template-worktree-guard')): SessionState => { const file = (sessionId: string) => join(dir, createHash('sha256').update(sessionId).digest('hex').slice(0, 32)); return { - lastWorktree: (sessionId) => { - try { - return existsSync(file(sessionId)) ? readFileSync(file(sessionId), 'utf8').trim() : undefined; - } catch (error) { - reportOnce(`could not read session state: ${(error as Error).message}`); - return undefined; - } - }, + lastWorktree: (sessionId) => + existsSync(file(sessionId)) ? readFileSync(file(sessionId), 'utf8').trim() : undefined, rememberWorktree: (sessionId, worktreeRoot) => { - try { - mkdirSync(dir, { recursive: true }); - writeFileSync(file(sessionId), `${worktreeRoot}\n`); - } catch (error) { - reportOnce(`could not write session state: ${(error as Error).message}`); - } + mkdirSync(dir, { recursive: true }); + writeFileSync(file(sessionId), `${worktreeRoot}\n`); }, prune: (maxAgeMs) => { - try { - if (!existsSync(dir)) return; - const cutoff = Date.now() - maxAgeMs; - for (const name of readdirSync(dir)) { - const path = join(dir, name); - if (statSync(path).mtimeMs < cutoff) rmSync(path, { force: true }); - } - } catch (error) { - reportOnce(`could not prune session state: ${(error as Error).message}`); + if (!existsSync(dir)) return; + const cutoff = Date.now() - maxAgeMs; + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if ((statSync(path, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) < cutoff) rmSync(path, { force: true }); } }, }; }; -export type GuardOptions = { requireMainBranch?: boolean }; +const lifecycleDenial = (landings: Landing[]) => + [ + 'Worktrees are created and destroyed only through the repo scripts, which provision and tear down the slot (ports, env files, databases, buckets, install, generated artifacts):', + ...landings.map((landing) => ` \`${landing.command}\``), + 'Use `bun run worktree:create ` / `bun run worktree:create `, `bun run worktree:destroy `, `bun run worktree:list`.', + ].join('\n'); -type MainCheckout = { root: string; real: string }; +const driftDenial = (landings: Landing[], worktree: string, mainRoot: string) => + [ + `This session is working in the worktree ${worktree}, but these would run in the main checkout (${mainRoot}):`, + ...landings.map((landing) => ` \`${landing.command}\``), + 'Run them in the worktree:', + ...landings.map((landing) => ` cd ${worktree} && ${landing.command}`), + `If the main checkout really is the target, name it by its absolute path: cd ${mainRoot} && …`, + ].join('\n'); -const denialFor = (landing: Landing, main: MainCheckout, registered: string[]): string | undefined => { - const { command, directory, named, namedTarget, redirect, reason } = landing; - if (reason) return ` \`${command}\` is ${reason}.`; - if (!directory) { - return namedTarget - ? ` \`${command}\` names ${namedTarget}, which does not exist or cannot be resolved.` - : ` \`${command}\` runs in a directory this hook cannot resolve.`; - } - if (registered.some((root) => isWithin(directory, root))) return undefined; - if (redirect) return ` \`${command}\` targets ${directory} through ${redirect}, which is the main checkout.`; - const checkout = findCheckout(directory); - if (!checkout) return ` \`${command}\` would run in ${directory}, which is not a git checkout.`; - if (checkout.isMain && isWithin(directory, main.real)) { - if (named && (isWithin(named, main.root) || isWithin(named, main.real))) return undefined; - return named - ? ` \`${command}\` would run in the main checkout through ${named}, which is not its own absolute path.` - : ` \`${command}\` would run in ${directory}, the main checkout, reached without \`cd ${main.root}\`.`; - } - return ` \`${command}\` would run in ${directory}, which is neither this repository's main checkout nor one of its registered worktrees.`; +const armedWorktree = (stateKey: string, state: SessionState, registered: string[]): string | undefined => { + const worktree = state.lastWorktree(stateKey); + if (!worktree || !existsSync(worktree)) return undefined; + const real = realpathSync(worktree); + return registered.some((root) => isWithin(real, root)) ? worktree : undefined; }; -export const guard = (input: HookInput, state: SessionState, options: GuardOptions = {}): string | undefined => { - const requireMainBranch = options.requireMainBranch ?? REQUIRE_MAIN_BRANCH; +export const guard = (input: HookInput, state: SessionState): string | undefined => { if (!input.cwd || !input.session_id) return undefined; const checkout = findCheckout(input.cwd); if (!checkout) return undefined; - if (!checkout.isMain) { - state.rememberWorktree(input.session_id, checkout.root); - return undefined; - } + const { root, mainRoot } = checkout; + const stateKey = [input.session_id, input.agent_id ?? '', mainRoot].join('\0'); + if (root !== mainRoot) state.rememberWorktree(stateKey, root); const command = input.tool_input?.command; if (input.tool_name !== 'Bash' || !command) return undefined; + const landings = landingsIn(command, input.cwd); - const worktree = state.lastWorktree(input.session_id); - if (!worktree || !isDirectory(worktree)) return undefined; - const registered = registeredWorktrees(checkout.root); - const worktreeReal = realpathSync(worktree); - if (!registered.some((root) => isWithin(worktreeReal, root))) return undefined; - const branch = currentBranch(checkout.root); - if (requireMainBranch && branch !== defaultBranch(checkout.root)) return undefined; + const lifecycle = landings.filter((landing) => landing.lifecycle); + if (lifecycle.length > 0) return lifecycleDenial(lifecycle); - const landings = mutationsIn(command, input.cwd); if (landings.length === 0) return undefined; - const main = { root: checkout.root, real: realpathSync(checkout.root) }; - const denied = landings.filter((landing) => denialFor(landing, main, registered)); - if (denied.length === 0) return undefined; - - return [ - `This session has been working in the worktree ${worktree}, but the Bash cwd is now the MAIN checkout (${checkout.root}, branch ${branch}) — the session cwd resets to main between calls.`, - ...denied.map((landing) => denialFor(landing, main, registered)), - 'Dependency changes, commits, pushes, merges, worktree registry changes, and new migrations would land on main. Run it from the worktree:', - ...denied.map((landing) => ` cd ${worktree} && ${landing.command}`), - `If the main checkout really is the target, name it by its own absolute path: cd ${checkout.root} && ${denied[0].command}`, - ].join('\n'); + const registered = registeredWorktrees(mainRoot); + const worktree = armedWorktree(stateKey, state, registered); + if (!worktree) return undefined; + const drifted = landings.filter( + (landing) => + !landing.absolute && + landing.directory !== undefined && + isWithin(landing.directory, mainRoot) && + !registered.some((root) => isWithin(landing.directory as string, root)), + ); + return drifted.length > 0 ? driftDenial(drifted, worktree, mainRoot) : undefined; }; const main = () => { @@ -921,11 +280,7 @@ const main = () => { if (!reason) return; process.stdout.write( `${JSON.stringify({ - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'deny', - permissionDecisionReason: reason, - }, + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason }, })}\n`, ); }; @@ -934,6 +289,6 @@ if (import.meta.main) { try { main(); } catch (error) { - reportOnce(`failed open: ${(error as Error).message}`); + console.error(`bashMainCheckoutGuard: failed open: ${(error as Error).message}`); } } diff --git a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts index 3ffc5bba4..f8944c91e 100644 --- a/.claude/hooks/tests/bashMainCheckoutGuard.test.ts +++ b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts @@ -1,44 +1,27 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; import { - cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, - symlinkSync, utimesSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { - defaultBranch, - fileSessionState, - guard, - isMutating, - mutationsIn, - REQUIRE_MAIN_BRANCH, - registeredWorktrees, - type SessionState, -} from '../bashMainCheckoutGuard'; +import { fileSessionState, guard, landingsIn, registeredWorktrees, type SessionState } from '../bashMainCheckoutGuard'; const HOOKS_DIR = resolve(import.meta.dir, '..'); +const SESSION = 'session-a'; let sandbox: string; let mainRoot: string; let worktreeRoot: string; -let plainDir: string; -let foreignRoot: string; let foreignWorktree: string; -let unregisteredRoot: string; -let linkToMain: string; -let linkToWorktree: string; let state: SessionState; -const SESSION = 'session-a'; - const git = (...args: string[]) => { const result = Bun.spawnSync(['git', ...args], { stdout: 'pipe', @@ -56,63 +39,34 @@ const git = (...args: string[]) => { if (result.exitCode !== 0) throw new Error(result.stderr.toString()); }; -const bash = (command: string, cwd: string, sessionId = SESSION) => ({ +const bash = (command: string, cwd: string, sessionId = SESSION, agentId?: string) => ({ hook_event_name: 'PreToolUse', session_id: sessionId, + agent_id: agentId, tool_name: 'Bash', cwd, tool_input: { command }, }); -const visitWorktree = (sessionId = SESSION) => guard(bash('ls', worktreeRoot, sessionId), state); - +const visit = (cwd: string, sessionId = SESSION) => guard(bash('ls', cwd, sessionId), state); const fromMain = (command: string) => guard(bash(command, mainRoot), state); - -const directories = (command: string) => mutationsIn(command, mainRoot).map((landing) => landing.directory); - -const withRefs = (head: string, originHead: string, run: () => void) => { - writeFileSync(join(mainRoot, '.git/HEAD'), `ref: refs/heads/${head}\n`); - writeFileSync(join(mainRoot, '.git/refs/remotes/origin/HEAD'), `ref: refs/remotes/origin/${originHead}\n`); - try { - run(); - } finally { - writeFileSync(join(mainRoot, '.git/HEAD'), 'ref: refs/heads/main\n'); - writeFileSync(join(mainRoot, '.git/refs/remotes/origin/HEAD'), 'ref: refs/remotes/origin/main\n'); - } -}; +const isMutating = (command: string) => landingsIn(command, mainRoot).length > 0; beforeAll(() => { sandbox = realpathSync(mkdtempSync(join(tmpdir(), 'main-checkout-guard-'))); - mainRoot = join(sandbox, 'repo'); git('init', '-q', '-b', 'main', mainRoot); git('-C', mainRoot, 'commit', '-q', '--allow-empty', '-m', 'init'); - mkdirSync(join(mainRoot, '.git/refs/remotes/origin'), { recursive: true }); - writeFileSync(join(mainRoot, '.git/refs/remotes/origin/HEAD'), 'ref: refs/remotes/origin/main\n'); mkdirSync(join(mainRoot, 'apps/api'), { recursive: true }); - mkdirSync(join(mainRoot, 'packages/db'), { recursive: true }); - worktreeRoot = join(mainRoot, '.worktrees/FEAT-1234'); git('-C', mainRoot, 'worktree', 'add', '-q', worktreeRoot, '-b', 'FEAT-1234'); mkdirSync(join(worktreeRoot, 'apps/api'), { recursive: true }); - mkdirSync(join(worktreeRoot, 'packages/db'), { recursive: true }); - plainDir = join(sandbox, 'not-a-checkout'); - mkdirSync(plainDir, { recursive: true }); - - foreignRoot = join(sandbox, 'other-repo'); + const foreignRoot = join(sandbox, 'other-repo'); git('init', '-q', '-b', 'main', foreignRoot); git('-C', foreignRoot, 'commit', '-q', '--allow-empty', '-m', 'init'); foreignWorktree = join(foreignRoot, '.worktrees/OTHER-1'); git('-C', foreignRoot, 'worktree', 'add', '-q', foreignWorktree, '-b', 'OTHER-1'); - - unregisteredRoot = join(sandbox, 'unregistered-copy'); - cpSync(worktreeRoot, unregisteredRoot, { recursive: true }); - - linkToMain = join(sandbox, 'link-to-main'); - symlinkSync(mainRoot, linkToMain); - linkToWorktree = join(sandbox, 'link-to-worktree'); - symlinkSync(worktreeRoot, linkToWorktree); }); beforeEach(() => { @@ -123,880 +77,228 @@ afterAll(() => { rmSync(sandbox, { recursive: true, force: true }); }); -describe('isMutating', () => { - test('the guarded set, with global options before the subcommand', () => { +describe('landingsIn', () => { + test('the guarded set', () => { for (const command of [ 'bun add left-pad', - 'bun --cwd . add left-pad', + 'bun a left-pad', 'bun remove left-pad', + 'bun rm left-pad', 'bun update', + 'bun install left-pad', + 'bun i -d left-pad', 'git commit -m x', - 'git -C . commit -m x', 'git --no-pager push', 'git -c core.editor=true rebase origin/main', - 'git --git-dir=.git cherry-pick abc', 'git merge feature', + 'git cherry-pick abc', 'git revert abc', 'git am patch.mbox', - 'git worktree add ../x', - 'git worktree remove x', - 'git worktree move x ../y', - 'git worktree prune', 'bunx prisma migrate dev', + 'bun x prisma migrate dev', 'bun run db:migrate', 'bun run --cwd packages/db db:migrate', - 'bun run prisma migrate dev --name add_thing', + 'rtk git push', + 'FOO=1 git commit -m x', 'git add -A && git push origin HEAD', 'bun install; git commit -m x', - 'true || git push', '(git push)', + 'bun run --filter @template/db db:migrate', ]) { expect(isMutating(command), command).toBe(true); } }); - test('addendum C: bun aliases and install with a package argument', () => { + test('outside the set', () => { for (const command of [ - 'bun a left-pad', - 'bun rm left-pad', - 'bun install left-pad', - 'bun i left-pad', - 'bun i -d left-pad', - 'bun install --force left-pad', - ]) { - expect(isMutating(command), command).toBe(true); - } - for (const command of ['bun install', 'bun i', 'bun install --force', 'bun i --frozen-lockfile']) { - expect(isMutating(command), command).toBe(false); - } - }); - - test('C3: option values after the subcommand are consumed, not read as packages', () => { - for (const command of [ - `bun install --cwd ${'/tmp'}`, - 'bun install --filter web', - 'bun install --backend hardlink', - 'bun install --registry https://r.example', - 'bun install --config bunfig.toml', - 'bun i --cwd=apps/api', - ]) { - expect(isMutating(command), command).toBe(false); - } - expect(isMutating('bun add x --cwd apps/api')).toBe(true); - expect(isMutating('bun add --cwd apps/api x')).toBe(true); - expect(isMutating('bun install --filter web left-pad')).toBe(true); - }); - - test('addendum C: every bun --cwd / run form of the migrate script', () => { - for (const command of [ - 'bun --cwd packages/db db:migrate', - 'bun run --cwd packages/db db:migrate', - 'bun --cwd packages/db run db:migrate', - 'bun run db:migrate --cwd packages/db', - 'bun --cwd packages/db prisma migrate dev', - 'bun run --cwd packages/db prisma migrate dev', - ]) { - expect(isMutating(command), command).toBe(true); - } - for (const command of [ - 'bun --cwd packages/db db:push:dev', - 'bun --cwd packages/db db:generate', - 'bun --cwd packages/db run db:deploy', - 'bun --cwd packages/db prisma generate', - ]) { - expect(isMutating(command), command).toBe(false); - } - }); - - test('finding 1: newlines, indentation, comments, and line continuations do not hide a command', () => { - for (const command of [ - 'echo hi\ngit push', - ' git push', - '\tgit push', - '# comment\ngit push', - 'cd /tmp\ngit push', - 'git \\\n push', - 'echo a\r\ngit push', - 'echo a\n\n bun add x', - ]) { - expect(isMutating(command), JSON.stringify(command)).toBe(true); - } - }); - - test('Z7: a backslash-newline inside a word is removed, not turned into a space', () => { - expect(isMutating('git pu\\\nsh')).toBe(true); - expect(isMutating('bun a\\\ndd x')).toBe(true); - }); - - test('finding 2: wrappers, env assignments, shell -c, eval, and compound bodies are seen through', () => { - for (const command of [ - 'FOO=1 git push', - 'A=1 B=2 git push', - 'env git push', - 'env -i A=1 git push', - 'command git push', - 'exec git push', - 'nohup git push', - 'timeout 10 git push', - 'timeout -k 5 10 git push', - 'nice -n 5 git push', - 'nice git push', - 'time git push', - 'echo origin | xargs git push', - 'rtk git push', - 'rtk proxy git push', - 'rtk proxy bun add x', - 'bunx prisma migrate dev', - 'bun x prisma migrate dev', - 'npx prisma migrate dev', - 'sh -c "git push"', - "bash -c 'cd /tmp && git push'", - 'bash -lc "git commit -m x"', - 'eval "git push"', - "eval 'bun add x'", - 'eval git push', - '{ git push; }', - 'if true; then git push; fi', - 'for x in a b; do git push; done', - 'while true; do git commit -m x; done', - 'true && { git push; }', - 'if git push; then echo ok; fi', - '! git push', - ]) { - expect(isMutating(command), command).toBe(true); - } - }); - - test('C8: word-0 spellings and runner options are normalised', () => { - for (const command of [ - '\\git push', - '"git" push', - 'gi""t push', - "'git' push", - '/usr/bin/git push', - "$'git' push", - 'command -p git push', - 'exec -a x git push', - 'env -u FOO git push', - 'time -p git push', - 'sudo -u me git push', - 'nohup -- git push', - 'xargs -n 1 git push', - 'xargs -I {} git push {}', - "bash -o pipefail -c 'git push'", - 'bash -eo pipefail -c "git push"', - 'echo "$(git push)"', - 'echo "`git push`"', - 'x=$(git push)', - 'echo `git push`', - './node_modules/.bin/bun add x', - ]) { - expect(isMutating(command), command).toBe(true); - } - expect(isMutating('command -v git')).toBe(false); - expect(isMutating('command -V git push')).toBe(false); - }); - - test('C9: a comment does not split the line, and the text before it is analysed as usual', () => { - expect(isMutating('echo hi # ; git push')).toBe(false); - expect(isMutating('echo hi # git push\nls')).toBe(false); - expect(isMutating('git push # done')).toBe(true); - expect(isMutating('echo $# && git push')).toBe(true); - }); - - test('Z3: a here-string is not a heredoc', () => { - expect(isMutating('cat <<< "x"\ngit push')).toBe(true); - expect(isMutating('cat <<< "git push"')).toBe(false); - }); - - test('Z7: double quotes inside $( ) are balanced', () => { - expect(isMutating('echo "$(echo "a"; git push)"')).toBe(true); - expect(isMutating('echo "$(echo "a")" && git status')).toBe(false); - expect(isMutating('echo "$(echo ")")"; git push')).toBe(true); - }); - - test('finding 4: quoted strings and heredoc bodies are never matched', () => { - for (const command of [ - 'gh pr comment 1 --body "fix; git push later"', - 'gh pr comment 1 --body "run bun install && git push"', - 'echo "then; bun add x"', - "echo 'git push; bun add x'", - 'grep -rn "; git commit" docs/', - 'rg "git commit|git push" .', - 'gh pr create --body "$(cat < notes.md\n; git push\nEOF\necho done', - 'git log --grep="git push"', - ]) { - expect(isMutating(command), JSON.stringify(command)).toBe(false); - } - }); - - test('finding 4: non-mutating forms of guarded subcommands', () => { - for (const command of [ - 'git push --dry-run', - 'git push --dry-run origin HEAD', - 'git merge --abort', - 'git rebase --continue', - 'git rebase --quit', - 'git cherry-pick --skip', - 'git revert --continue', - 'git am --abort', - 'git worktree list', - 'git worktree prune --dry-run', - 'git commit --dry-run', - 'bun add --dry-run left-pad', - ]) { - expect(isMutating(command), command).toBe(false); - } - }); - - test('by design outside the set: pull, reset, checkout', () => { - for (const command of ['git pull', 'git pull --rebase', 'git reset --hard HEAD~1', 'git checkout -- .']) { - expect(isMutating(command), command).toBe(false); - } - }); - - test('finding 14: only the real migrate script names count', () => { - expect(isMutating('bun run db:migrate')).toBe(true); - expect(isMutating('bun run db:migrate:anything')).toBe(false); - expect(isMutating('bun run db:migrate-status')).toBe(false); - expect(isMutating('bun run db:push:dev')).toBe(false); - expect(isMutating('bun run db:deploy')).toBe(false); - }); - - test('read-only and routine commands are outside the set', () => { - for (const command of [ - 'git status', - 'git pull --ff-only origin main', - 'git -C . log --oneline', 'bun install', - 'bun run worktree:create main FEAT-9', - 'bun run worktree:destroy FEAT-1234', + 'bun i --frozen-lockfile', 'bun run test', - 'bun run check', - 'FOO=bar bun run test', + 'bun run db:push:dev', + 'git status', + 'git checkout feature', + 'git switch -c feature', + 'git pull', + 'git worktree list', + 'echo git push', + 'bun install --filter api', + 'bun i --linker isolated', ]) { expect(isMutating(command), command).toBe(false); } }); - test('a command after a heredoc body is still analysed', () => { - expect(isMutating('cat < { - for (const command of [ - "bash <<'EOF'\ngit push\nEOF", - "bash -s <<'EOF'\necho hi\ngit push\nEOF", - "echo 'git push' | bash", - "cat <<'EOF' | sh\ngit push\nEOF", - 'bash -c "$(cat <<\'EOF\'\ngit push\nEOF\n)"', - 'eval "$(printf \'git push\')"', - "printf 'git push' | bash", - 'sh < notes.md\ngit push\nEOF', - 'echo git push | grep push', - "bash <<'EOF'\ngit status\nEOF", - 'bash f.sh', - '. f.sh', - "cat <<'EOF'\ngit push\nEOF\nbash", - ]) { - expect(isMutating(command), JSON.stringify(command)).toBe(false); - } - }); - - test('final pass: piped text passes through tee and an argument-less cat into a shell', () => { - for (const command of [ - "cat <<'EOF' | tee f | bash\ngit push\nEOF", - 'cat < { - expect(isMutating('env -C /tmp git push')).toBe(true); - expect(isMutating('env --chdir=/tmp git push')).toBe(true); - }); - - test('T5: word-0 case and unquoted backslashes', () => { - expect(isMutating('gi\\t push')).toBe(true); - expect(isMutating('g\\it push')).toBe(true); - for (const command of ['GIT push', 'Git commit -m x', 'bUn add x']) { - expect(isMutating(command), command).toBe(process.platform === 'darwin'); - } - }); - - test('T6: a # glued to a substitution is not a comment', () => { - expect(isMutating('echo ${x}#; git push')).toBe(true); - expect(isMutating('echo $(true)#; git push')).toBe(true); - expect(isMutating('echo `true`#; git push')).toBe(true); - expect(isMutating('echo x #; git push')).toBe(false); + test('quoted strings and heredoc bodies are text, not commands', () => { + expect(landingsIn('git commit -m "docs: git worktree add is denied; use the scripts"', mainRoot)).toEqual([ + expect.objectContaining({ + command: 'git commit -m "docs: git worktree add is denied; use the scripts"', + lifecycle: false, + }), + ]); + const heredocCommit = + "git commit -F - <<'EOF'\nguard\n\ngit worktree add is denied\nbun add is denied\nEOF\ngit status"; + expect(landingsIn(heredocCommit, mainRoot).map((landing) => landing.lifecycle)).toEqual([false]); + const spaced = join(sandbox, 'dir with space'); + mkdirSync(spaced, { recursive: true }); + expect(landingsIn(`cd "${spaced}" && git push`, mainRoot)[0]?.directory).toBe(spaced); }); - test('runner value-option tables', () => { + test('git worktree add/remove/move/prune are lifecycle landings', () => { for (const command of [ - 'sudo -E git push', - 'sudo -n git push', - 'sudo -S git push', - 'sudo -k git push', - 'sudo -s git push', - 'sudo -u me -g wheel git push', - 'sudo -T 5 git push', - 'caffeinate -t 600 git push', - 'caffeinate -w 123 git push', - 'caffeinate -i git push', - 'env -u FOO -S x git push', - 'xargs -L 1 git push', - 'xargs -P 4 -n 1 git push', - 'timeout -s KILL 5 git push', - 'timeout --kill-after=2 5 git push', - 'nice -n 10 git push', - 'nohup git push', - 'command git push', - 'rtk git push', + 'git worktree add ../x', + 'git worktree remove x', + 'git worktree move x y', + 'git worktree prune', ]) { - expect(isMutating(command), command).toBe(true); + expect(landingsIn(command, mainRoot)[0]?.lifecycle, command).toBe(true); } }); - test('bun option tables differ before and after the subcommand', () => { - expect(isMutating('bun add -d x')).toBe(true); - expect(isMutating('bun add -D x')).toBe(true); - expect(isMutating('bun install -d x')).toBe(true); - expect(isMutating('bun -d FOO=1 add x')).toBe(true); - expect(isMutating('bun -d add')).toBe(false); - expect(isMutating('bun install -d')).toBe(false); - }); - - test('Z8: nesting deeper than the cap is reported as a landing rather than dropped', () => { - const nested = 'echo $(echo $(echo $(echo $(echo $(echo $(echo $(git push)))))))'; - const landings = mutationsIn(nested, mainRoot); - expect(landings.length).toBe(1); - expect(landings[0].reason).toContain('nested more deeply'); - }); -}); - -describe('mutationsIn: effective directory per segment', () => { - test('a cd applies to the segments after it, in order', () => { - expect(directories(`cd ${worktreeRoot} && git push`)).toEqual([worktreeRoot]); - expect(directories(`cd ${worktreeRoot} && cd .. && git push`)).toEqual([join(mainRoot, '.worktrees')]); - expect(directories(`cd ${worktreeRoot}; cd ${mainRoot}; git push`)).toEqual([mainRoot]); - expect(directories('cd .worktrees/FEAT-1234 && git push')).toEqual([worktreeRoot]); - expect(directories(`cd ${worktreeRoot}\ngit push`)).toEqual([worktreeRoot]); - }); - - test('a separator glued to the path still ends the cd token', () => { - expect(directories(`cd ${worktreeRoot};git push`)).toEqual([worktreeRoot]); - expect(directories(`cd ${worktreeRoot}&&git push`)).toEqual([worktreeRoot]); - expect(directories(`cd ${worktreeRoot}||git push`)).toEqual([worktreeRoot]); - }); - - test('C2: a cd inside a subshell, a pipe, or a background job does not move the outer cursor', () => { - expect(directories(`(cd ${worktreeRoot}) && git push`)).toEqual([mainRoot]); - expect(directories(`(cd ${worktreeRoot} && bun run check) && git commit -m x`)).toEqual([mainRoot]); - expect(directories(`(cd ${worktreeRoot}); git push`)).toEqual([mainRoot]); - expect(directories(`cd ${worktreeRoot} | cat; git push`)).toEqual([mainRoot]); - expect(directories(`cd ${worktreeRoot} & git push`)).toEqual([mainRoot]); - expect(directories(`cd ${worktreeRoot} |& cat; git push`)).toEqual([mainRoot]); - expect(directories(`{ cd ${worktreeRoot}; } && git push`)).toEqual([worktreeRoot]); - expect(directories(`(cd ${worktreeRoot} && git push)`)).toEqual([worktreeRoot]); - expect(directories(`(cd ${worktreeRoot}; (cd ..); git push)`)).toEqual([worktreeRoot]); - expect(directories('echo done 2>&1 && git push')).toEqual([mainRoot]); - }); - - test('T3: a heredoc script that cds into the worktree lands there', () => { - expect(directories(`bash <<'EOF'\ncd ${worktreeRoot}\ngit push\nEOF`)).toEqual([worktreeRoot]); - expect(directories(`bash <<'EOF'\ngit push\nEOF`)).toEqual([mainRoot]); - expect(directories(`cd ${worktreeRoot} && bash <<'EOF'\ngit push\nEOF`)).toEqual([worktreeRoot]); - expect(directories(`echo 'git push' | bash`)).toEqual([mainRoot]); - }); - - test('final pass: a piped body that cds into the worktree lands there', () => { - expect(directories(`cat <<'EOF' | tee f | bash\ncd ${worktreeRoot}\ngit push\nEOF`)).toEqual([worktreeRoot]); - expect(directories(`cat <<'EOF' | cat | bash\ngit push\nEOF`)).toEqual([mainRoot]); - }); - - test('T4: env -C resolves the segment directory without lifting main by absolute path', () => { - expect(directories(`env -C ${worktreeRoot} git push`)).toEqual([worktreeRoot]); - expect(directories(`env --chdir=${worktreeRoot} git push`)).toEqual([worktreeRoot]); - expect(directories('env -C . git push')).toEqual([mainRoot]); - expect(mutationsIn(`env -C ${mainRoot} git push`, mainRoot)[0].named).toBeUndefined(); - expect(mutationsIn('env -C /does/not/exist git push', mainRoot)[0].directory).toBeUndefined(); - expect(directories(`env -C ${worktreeRoot} git status && git push`)).toEqual([mainRoot]); - }); - - test('C9: cd options are not mistaken for the target', () => { - expect(directories(`cd -P ${worktreeRoot} && git push`)).toEqual([worktreeRoot]); - expect(directories(`cd -L -- ${worktreeRoot} && git push`)).toEqual([worktreeRoot]); - expect(mutationsIn(`cd -P && git push`, mainRoot)[0].directory).not.toBe(mainRoot); + test('cd, git -C and bun --cwd move the effective directory; an absolute path, and moves relative to it, are explicit', () => { + const land = (command: string) => landingsIn(command, mainRoot)[0]; + expect(land(`cd ${worktreeRoot} && git push`)).toMatchObject({ directory: worktreeRoot, absolute: true }); + expect(land(`git -C ${worktreeRoot} push`)).toMatchObject({ directory: worktreeRoot, absolute: true }); + expect(land(`bun --cwd=${worktreeRoot} add x`)).toMatchObject({ directory: worktreeRoot, absolute: true }); + expect(land('cd apps/api && bun add x')).toMatchObject({ directory: join(mainRoot, 'apps/api'), absolute: false }); + expect(land(`cd ${mainRoot} && cd apps/api && git push`)).toMatchObject({ absolute: true }); + expect(land(`cd ${mainRoot} && bun run --cwd apps/api db:migrate`)).toMatchObject({ absolute: true }); + expect(land(`cd ${mainRoot} && git push`)).toMatchObject({ directory: mainRoot, absolute: true }); }); - test('Z4: a substitution or unexpanded variable in a cd target makes the cursor unknown', () => { - expect(mutationsIn(`cd ${worktreeRoot} && cd $(pwd) && git push`, mainRoot)[0].directory).toBeUndefined(); - expect(mutationsIn(`cd ${worktreeRoot} && cd "$X" && git push`, mainRoot)[0].directory).toBeUndefined(); - expect(mutationsIn(`cd ${worktreeRoot} && cd \`pwd\` && git push`, mainRoot)[0].directory).toBeUndefined(); - expect(mutationsIn('cd ~/"$X" && git push', mainRoot)[0].directory).toBeUndefined(); + test('a cd inside ( ) does not leak past the subshell', () => { + expect(landingsIn(`(cd ${worktreeRoot} && bun test) && git commit -m x`, mainRoot)[0]?.directory).toBe(mainRoot); + expect(landingsIn(`(cd ${worktreeRoot} && git commit -m x)`, mainRoot)[0]?.directory).toBe(worktreeRoot); }); - test('git -C applies only to its own segment; bun --cwd anywhere in its segment is the directory', () => { - expect(directories(`git -C ${worktreeRoot} status && git push`)).toEqual([mainRoot]); - expect(directories(`git -C ${worktreeRoot} push && git status`)).toEqual([worktreeRoot]); - expect(directories(`cd ${worktreeRoot} && git -C . push`)).toEqual([worktreeRoot]); - expect(directories(`cd ${worktreeRoot} && bun --cwd . add x`)).toEqual([worktreeRoot]); - expect(directories(`bun --cwd ${worktreeRoot}/apps/api add x`)).toEqual([join(worktreeRoot, 'apps/api')]); - expect(directories(`bun add x --cwd ${worktreeRoot}`)).toEqual([worktreeRoot]); - expect(directories(`bun add x --cwd=${worktreeRoot}`)).toEqual([worktreeRoot]); - expect(directories(`bun run db:migrate --cwd ${worktreeRoot}/packages/db`)).toEqual([ - join(worktreeRoot, 'packages/db'), - ]); - expect(directories('bun run --cwd packages/db db:migrate')).toEqual([join(mainRoot, 'packages/db')]); - expect(directories('bun --cwd packages/db db:migrate')).toEqual([join(mainRoot, 'packages/db')]); - expect(directories('bun --cwd packages/db run db:migrate')).toEqual([join(mainRoot, 'packages/db')]); - }); - - test('every target is resolved through realpath', () => { - expect(directories(`cd ${linkToWorktree} && git push`)).toEqual([worktreeRoot]); - expect(directories(`cd ${linkToMain} && git push`)).toEqual([mainRoot]); - expect(directories(`git -C ${linkToMain} push`)).toEqual([mainRoot]); - expect(directories(`bun --cwd ${linkToMain}/apps/api add x`)).toEqual([join(mainRoot, 'apps/api')]); - expect(mutationsIn(`cd ${linkToMain} && git push`, mainRoot)[0].named).toBe(linkToMain); - expect(mutationsIn(`cd ${mainRoot} && git push`, mainRoot)[0].named).toBe(mainRoot); - expect(mutationsIn('cd apps/api && git push', mainRoot)[0].named).toBeUndefined(); - }); - - test('C8: --git-dir, --work-tree, GIT_DIR and GIT_WORK_TREE redirect the landing', () => { - expect(mutationsIn(`cd ${worktreeRoot} && git --git-dir=${mainRoot}/.git push`, mainRoot)[0]).toMatchObject({ - directory: mainRoot, - redirect: '--git-dir', - }); - expect(mutationsIn(`cd ${worktreeRoot} && git --work-tree ${mainRoot} commit -m x`, mainRoot)[0]).toMatchObject({ - directory: mainRoot, - redirect: '--work-tree', - }); - expect(mutationsIn(`cd ${worktreeRoot} && GIT_DIR=${mainRoot}/.git git push`, mainRoot)[0]).toMatchObject({ - directory: mainRoot, - redirect: 'GIT_DIR', - }); - expect(mutationsIn(`cd ${worktreeRoot} && GIT_WORK_TREE=${mainRoot} git commit -m x`, mainRoot)[0]).toMatchObject({ - directory: mainRoot, - redirect: 'GIT_WORK_TREE', - }); - expect(mutationsIn(`git --git-dir=${worktreeRoot}/.git push`, mainRoot)[0]).toMatchObject({ - directory: worktreeRoot, - redirect: '--git-dir', - }); - expect(mutationsIn('GIT_DIR=/does/not/exist git push', mainRoot)[0].directory).toBeUndefined(); - }); - - test('unknown targets leave the directory unresolved', () => { - expect(mutationsIn('cd /does/not/exist; git push', mainRoot)[0].directory).toBeUndefined(); - expect(mutationsIn('cd "$SOMEWHERE" && git push', mainRoot)[0].directory).toBeUndefined(); - expect(mutationsIn('cd - && git push', mainRoot)[0].directory).toBeUndefined(); - expect(mutationsIn('popd && git push', mainRoot)[0].directory).toBeUndefined(); - expect(mutationsIn('git -C /does/not/exist push', mainRoot)[0]).toMatchObject({ - directory: undefined, - namedTarget: '/does/not/exist', - }); - expect(mutationsIn(`cd ${worktreeRoot} && git -C /does/not/exist push`, mainRoot)[0].directory).toBeUndefined(); - }); - - test('quoted paths resolve like bare ones', () => { - expect(mutationsIn(`cd "${worktreeRoot}" && git push`, mainRoot)[0].directory).toBe(worktreeRoot); - expect(mutationsIn(`cd '${worktreeRoot}/apps/api'; git push`, mainRoot)[0].directory).toBe( - join(worktreeRoot, 'apps/api'), - ); - expect(mutationsIn(`git -C "${worktreeRoot}" push`, mainRoot)[0].directory).toBe(worktreeRoot); - }); - - test('shell -c and eval inherit the cursor', () => { - expect(mutationsIn(`cd ${worktreeRoot} && bash -c 'git push'`, mainRoot)[0].directory).toBe(worktreeRoot); - expect(mutationsIn(`bash -c 'cd ${worktreeRoot} && git push'`, mainRoot)[0].directory).toBe(worktreeRoot); - expect(mutationsIn(`eval "cd ${worktreeRoot}; git push"`, mainRoot)[0].directory).toBe(worktreeRoot); - expect(mutationsIn(`bash -o pipefail -c 'cd ${worktreeRoot} && git push'`, mainRoot)[0].directory).toBe( - worktreeRoot, - ); - }); - - test('C9: the landing carries the original segment text, keywords stripped, runners kept', () => { - expect(mutationsIn('FOO="a b" git push', mainRoot)[0].command).toBe('FOO="a b" git push'); - expect(mutationsIn('if true; then git commit -m "x y"; fi', mainRoot)[0].command).toBe('git commit -m "x y"'); - expect(mutationsIn('cd /does/not/exist; rtk proxy git push', mainRoot)[0].command).toBe('rtk proxy git push'); + test('a cd target with a variable or substitution is unknown, not a relative path', () => { + expect(landingsIn('cd "$WT" && git commit -m x', mainRoot)[0]?.directory).toBeUndefined(); + expect(landingsIn('cd $(git rev-parse --show-toplevel) && git push', mainRoot)[0]?.directory).toBeUndefined(); }); }); describe('registeredWorktrees', () => { - test('lists the linked worktrees git knows about, realpath-compared, without the main checkout', () => { - expect(registeredWorktrees(mainRoot)).toEqual([worktreeRoot]); - expect(registeredWorktrees(linkToMain)).toEqual([worktreeRoot]); - expect(registeredWorktrees(plainDir)).toEqual([]); + test('lists linked worktrees, not the main checkout', () => { + expect(registeredWorktrees(mainRoot)).toEqual([realpathSync(worktreeRoot)]); }); }); -describe('bashMainCheckoutGuard', () => { - test('a session that has only ever been in the main checkout is never guarded', () => { +describe('guard: drift back to the main checkout', () => { + test('a session that never entered a worktree is not touched', () => { + expect(fromMain('git push')).toBeUndefined(); expect(fromMain('bun add left-pad')).toBeUndefined(); - expect(fromMain('git commit -m "docs"')).toBeUndefined(); - expect(guard(bash('git push origin main', join(mainRoot, 'apps/api')), state)).toBeUndefined(); - }); - - test('after the session has been in a worktree, a bare mutation from main is denied and names that worktree', () => { - visitWorktree(); - const reason = fromMain('bun add @inixiative/json-rules@2.22.0'); - expect(reason).toContain('MAIN checkout'); - expect(reason).toContain(`cd ${worktreeRoot} && bun add @inixiative/json-rules@2.22.0`); - expect(reason).toContain(`cd ${mainRoot} && bun add @inixiative/json-rules@2.22.0`); - expect(reason).toContain(`reached without \`cd ${mainRoot}\``); }); - test('C9: the deny message quotes the original text and prefixes cd only onto the bare mutation', () => { - visitWorktree(); - const reason = fromMain('cd /does/not/exist; FOO="a b" git push') ?? ''; - expect(reason).toContain('`FOO="a b" git push`'); - expect(reason).toContain(` cd ${worktreeRoot} && FOO="a b" git push`); - expect(reason).not.toContain(`&& cd /does/not/exist`); - expect(reason).not.toContain('�'); - }); - - test('finding 1: a hidden second line from main is denied', () => { - visitWorktree(); - expect(fromMain('echo hi\ngit push')).toBeDefined(); - expect(fromMain('# comment\ngit push')).toBeDefined(); - expect(fromMain('git \\\n push')).toBeDefined(); - }); - - test('finding 2 and C8: wrappers from main are denied', () => { - visitWorktree(); - for (const command of [ - 'FOO=1 git push', - 'rtk proxy git push', - 'sh -c "git push"', - 'eval "git push"', - '{ git push; }', - '\\git push', - '/usr/bin/git push', - 'sudo -u me git push', - 'echo "$(git push)"', - ]) { - expect(fromMain(command), command).toBeDefined(); - } + test('after the session worked in a worktree, a mutation landing in main is denied and names the worktree', () => { + visit(worktreeRoot); + const reason = fromMain('git commit -m x'); + expect(reason).toContain(worktreeRoot); + expect(reason).toContain(`cd ${worktreeRoot} && git commit -m x`); + expect(fromMain('cd apps/api && bun add x')).toBeDefined(); }); - test('addendum C: aliases and install-with-package from main are denied', () => { - visitWorktree(); - for (const command of ['bun a x', 'bun rm x', 'bun i x', 'bun install x', 'bun --cwd packages/db db:migrate']) { - expect(fromMain(command), command).toBeDefined(); + test('fires whatever branch the main checkout is on', () => { + git('-C', mainRoot, 'checkout', '-q', '-b', 'some-feature'); + try { + visit(worktreeRoot); + expect(fromMain('git push')).toBeDefined(); + } finally { + git('-C', mainRoot, 'checkout', '-q', 'main'); } - expect(fromMain('bun install')).toBeUndefined(); - expect(fromMain('bun i')).toBeUndefined(); - expect(fromMain(`bun install --cwd ${worktreeRoot}`)).toBeUndefined(); - expect(fromMain('bun install --filter web')).toBeUndefined(); }); - test('C3: a post-subcommand --cwd naming the worktree lifts', () => { - visitWorktree(); - expect(fromMain(`bun add left-pad --cwd ${worktreeRoot}`)).toBeUndefined(); - expect(fromMain(`bun add left-pad --cwd=${worktreeRoot}/apps/api`)).toBeUndefined(); - expect(fromMain('bun add left-pad --cwd apps/api')).toBeDefined(); - }); - - test('T3: scripts fed to a shell from main are denied, and a heredoc that cds into the worktree passes', () => { - visitWorktree(); - expect(fromMain("bash <<'EOF'\ngit push\nEOF")).toBeDefined(); - expect(fromMain("echo 'git push' | bash")).toBeDefined(); - expect(fromMain('eval "$(printf \'git push\')"')).toBeDefined(); - expect(fromMain(`bash <<'EOF'\ncd ${worktreeRoot}\ngit push\nEOF`)).toBeUndefined(); - expect(fromMain("bash <<'EOF'\ngit status\nEOF")).toBeUndefined(); - }); - - test('T4: env -C to the worktree lifts, env -C to main or . does not', () => { - visitWorktree(); - expect(fromMain(`env -C ${worktreeRoot} git push`)).toBeUndefined(); - expect(fromMain(`env --chdir=${worktreeRoot} git push`)).toBeUndefined(); - expect(fromMain(`env -C ${mainRoot} git push`)).toContain('reached without'); - expect(fromMain('env -C . git push')).toBeDefined(); - }); - - test('T5: word-0 variants from main are denied', () => { - visitWorktree(); - expect(fromMain('gi\\t push')).toBeDefined(); - if (process.platform === 'darwin') expect(fromMain('GIT push')).toBeDefined(); - }); - - test('C2: subshell, pipe, and background cds do not lift the outer command', () => { - visitWorktree(); - expect(fromMain(`(cd ${worktreeRoot}) && git push`)).toBeDefined(); - expect(fromMain(`(cd ${worktreeRoot} && bun run check) && git commit -m x`)).toBeDefined(); - expect(fromMain(`cd ${worktreeRoot} | cat; git push`)).toBeDefined(); - expect(fromMain(`cd ${worktreeRoot} & git push`)).toBeDefined(); - expect(fromMain(`{ cd ${worktreeRoot}; } && git push`)).toBeUndefined(); - expect(fromMain(`(cd ${worktreeRoot} && git push)`)).toBeUndefined(); - }); - - test('finding 3: naming a worktree somewhere in the command does not whitelist the whole command', () => { - visitWorktree(); - expect(fromMain(`git -C ${worktreeRoot} status && git push`)).toBeDefined(); - expect(fromMain(`cd ${worktreeRoot} && cd .. && git push`)).toBeDefined(); - expect(fromMain(`cd ${worktreeRoot}; cd ../..; git push`)).toBeDefined(); - expect(fromMain('cd . && git push')).toBeDefined(); - expect(fromMain('cd apps/api && bun add left-pad')).toBeDefined(); - expect(fromMain('git -C . commit -m x')).toBeDefined(); - }); - - test('finding 3: a mutation whose effective directory is a registered worktree passes', () => { - visitWorktree(); - expect(fromMain(`cd ${worktreeRoot} && git commit -m x`)).toBeUndefined(); - expect(fromMain(`cd "${worktreeRoot}" && git commit -m x`)).toBeUndefined(); - expect(fromMain(`cd '${worktreeRoot}/apps/api'; git push`)).toBeUndefined(); - expect(fromMain('cd .worktrees/FEAT-1234 && git push')).toBeUndefined(); - expect(fromMain(`cd ${worktreeRoot};git push`)).toBeUndefined(); - expect(fromMain(`cd ${worktreeRoot}&&git push`)).toBeUndefined(); - expect(fromMain(`cd -P ${worktreeRoot} && git push`)).toBeUndefined(); + test('targeting the worktree, or naming main by its absolute path, is allowed', () => { + visit(worktreeRoot); + expect(fromMain(`cd ${worktreeRoot} && git push`)).toBeUndefined(); expect(fromMain(`git -C ${worktreeRoot} commit -m x`)).toBeUndefined(); - expect(fromMain('git -C .worktrees/FEAT-1234 commit -m x')).toBeUndefined(); - expect(fromMain(`git -C ${worktreeRoot} push && git status`)).toBeUndefined(); - expect(fromMain(`cd ${worktreeRoot} && git -C . push`)).toBeUndefined(); - expect(fromMain(`bun --cwd ${worktreeRoot} add left-pad`)).toBeUndefined(); - expect(fromMain(`bash -c 'cd ${worktreeRoot} && git push'`)).toBeUndefined(); - }); - - test('C8: git-dir or work-tree pointing at main is denied even from a worktree cd', () => { - visitWorktree(); - expect(fromMain(`cd ${worktreeRoot} && git --git-dir=${mainRoot}/.git push`)).toContain('through --git-dir'); - expect(fromMain(`cd ${worktreeRoot} && GIT_DIR=${mainRoot}/.git git push`)).toContain('through GIT_DIR'); - expect(fromMain(`cd ${worktreeRoot} && GIT_WORK_TREE=${mainRoot} git commit -m x`)).toContain( - 'through GIT_WORK_TREE', - ); - expect(fromMain(`git --git-dir=${worktreeRoot}/.git push`)).toBeUndefined(); - }); - - test('addendum C: the main checkout is lifted only by its own absolute path, for cd and -C alike', () => { - visitWorktree(); - expect(fromMain(`cd ${mainRoot} && bun add left-pad`)).toBeUndefined(); - expect(fromMain(`cd ${mainRoot}/apps/api && git push`)).toBeUndefined(); + expect(fromMain(`cd ${mainRoot} && git push`)).toBeUndefined(); expect(fromMain(`git -C ${mainRoot} push`)).toBeUndefined(); - expect(fromMain(`git -C "${mainRoot}" push`)).toBeUndefined(); - expect(fromMain(`bun --cwd ${mainRoot} add left-pad`)).toBeUndefined(); - expect(fromMain(`cd ${mainRoot} && cd apps/api && git push`)).toBeDefined(); - }); - - test('addendum C: a symlink to the main checkout does not lift, a symlink to a worktree does', () => { - visitWorktree(); - expect(fromMain(`cd ${linkToMain} && git push`)).toContain( - `through ${linkToMain}, which is not its own absolute path`, - ); - expect(fromMain(`git -C ${linkToMain} push`)).toBeDefined(); - expect(fromMain(`bun --cwd ${linkToMain} add x`)).toBeDefined(); - expect(fromMain(`cd ${linkToWorktree} && git push`)).toBeUndefined(); - expect(fromMain(`git -C ${linkToWorktree}/apps/api commit -m x`)).toBeUndefined(); - }); - - test('addendum C: an arbitrary directory, an unregistered checkout, or another repository never lifts', () => { - visitWorktree(); - expect(fromMain(`cd ${plainDir} && git push`)).toContain('not a git checkout'); - expect(fromMain(`git -C ${plainDir} push`)).toBeDefined(); - expect(fromMain('cd /tmp && bun add x')).toBeDefined(); - expect(fromMain(`cd ${unregisteredRoot} && git push`)).toContain('registered worktrees'); - expect(fromMain(`cd ${foreignRoot} && git push`)).toContain('registered worktrees'); - }); - - test('Z4: a substitution in a cd target is unknown and therefore denied', () => { - visitWorktree(); - expect(fromMain(`cd ${worktreeRoot} && cd $(pwd) && git push`)).toBeDefined(); - expect(fromMain(`cd ${worktreeRoot} && cd "$X" && git push`)).toBeDefined(); - }); - - test('a cd whose target does not exist does not bypass the guard', () => { - visitWorktree(); - expect(fromMain('cd /does/not/exist; git push')).toContain('cannot resolve'); - expect(fromMain('cd /bad || git commit -m x')).toBeDefined(); - expect(fromMain('cd "$SOMEWHERE" && git push')).toBeDefined(); - expect(fromMain('git -C /does/not/exist commit -m x')).toContain('names /does/not/exist'); - expect(fromMain(`cd ${worktreeRoot} && git -C /does/not/exist push`)).toBeDefined(); - }); - - test('Z8: over-deep nesting is denied, not silently allowed', () => { - visitWorktree(); - expect(fromMain('echo $(echo $(echo $(echo $(echo $(echo $(echo $(git push)))))))')).toContain( - 'nested more deeply', - ); - }); - - test('finding 4 and C9: mention-only text, comments, and non-mutating forms pass from main', () => { - visitWorktree(); - for (const command of [ - 'gh pr comment 1 --body "fix; git push later"', - 'gh pr comment 1 --body "run bun install && git push"', - 'echo "then; bun add x"', - 'grep -rn "; git commit" docs/', - 'rg "git commit|git push" .', - 'gh pr create --body "$(cat < { - visitWorktree(); expect(fromMain('git status')).toBeUndefined(); - expect(fromMain('git pull --ff-only origin main')).toBeUndefined(); - expect(fromMain('bun install')).toBeUndefined(); - expect(fromMain('bun run worktree:create main FEAT-9')).toBeUndefined(); - expect(fromMain('bun run worktree:destroy FEAT-1234')).toBeUndefined(); - expect(fromMain('bun run test')).toBeUndefined(); }); - test('anything inside a worktree passes and records the worktree for the session', () => { + test('commands run from inside the worktree are allowed; a relative cd out of it into main is denied', () => { + visit(worktreeRoot); expect(guard(bash('git commit -m x', worktreeRoot), state)).toBeUndefined(); - expect(guard(bash('bun add left-pad', join(worktreeRoot, 'apps/api')), state)).toBeUndefined(); - expect(state.lastWorktree(SESSION)).toBe(worktreeRoot); + expect(guard(bash('cd ../.. && git push', worktreeRoot), state)).toBeDefined(); }); - test('sessions are independent: another session in main is unaffected by this one visiting a worktree', () => { - visitWorktree('session-a'); - expect(guard(bash('git commit -m x', mainRoot, 'session-b'), state)).toBeUndefined(); - expect(guard(bash('git commit -m x', mainRoot, 'session-a'), state)).toBeDefined(); + test('a removed worktree does not arm the guard', () => { + const doomed = join(mainRoot, '.worktrees/DOOMED'); + git('-C', mainRoot, 'worktree', 'add', '-q', doomed, '-b', 'DOOMED'); + visit(doomed); + git('-C', mainRoot, 'worktree', 'remove', doomed); + expect(fromMain('git push')).toBeUndefined(); }); - test('a remembered worktree that no longer exists is not guarded', () => { - visitWorktree(); - state.rememberWorktree(SESSION, join(mainRoot, '.worktrees/destroyed')); - expect(fromMain('git commit -m x')).toBeUndefined(); + test('another repository’s worktree neither arms the guard nor replaces this repository’s worktree', () => { + visit(foreignWorktree); + expect(fromMain('git push')).toBeUndefined(); + visit(worktreeRoot); + visit(foreignWorktree); + expect(fromMain('git push')).toContain(worktreeRoot); }); - test("Z5: a remembered worktree of another repository does not arm this repository's guard", () => { - guard(bash('ls', foreignWorktree), state); - expect(state.lastWorktree(SESSION)).toBe(foreignWorktree); + test('state is per session and per subagent', () => { + visit(worktreeRoot, 'session-b'); expect(fromMain('git push')).toBeUndefined(); - state.rememberWorktree(SESSION, unregisteredRoot); + guard(bash('ls', worktreeRoot, SESSION, 'agent-1'), state); expect(fromMain('git push')).toBeUndefined(); - visitWorktree(); - expect(fromMain('git push')).toBeDefined(); - }); - - test('finding 13: REQUIRE_MAIN_BRANCH is on, so a main checkout on another branch is not guarded', () => { - expect(REQUIRE_MAIN_BRANCH).toBe(true); - visitWorktree(); - withRefs('hotfix', 'main', () => { - expect(fromMain('git commit -m x')).toBeUndefined(); - }); - expect(fromMain('git commit -m x')).toBeDefined(); - }); - - test('finding 13: with the requirement off, the branch no longer matters', () => { - visitWorktree(); - withRefs('hotfix', 'main', () => { - expect(guard(bash('git commit -m x', mainRoot), state, { requireMainBranch: false })).toBeDefined(); - }); - }); - - test('finding 13: the guarded branch is the one origin/HEAD points at, as create.sh reads it', () => { - expect(defaultBranch(mainRoot)).toBe('main'); - visitWorktree(); - withRefs('develop', 'develop', () => { - expect(defaultBranch(mainRoot)).toBe('develop'); - expect(fromMain('git commit -m x')).toBeDefined(); - }); - withRefs('main', 'develop', () => { - expect(fromMain('git commit -m x')).toBeUndefined(); - }); + expect(guard(bash('git push', mainRoot, SESSION, 'agent-1'), state)).toContain(worktreeRoot); }); +}); - test('a SessionStart event inside a worktree records it before any Bash call', () => { - expect( - guard({ hook_event_name: 'SessionStart', session_id: SESSION, cwd: join(worktreeRoot, 'apps/api') }, state), - ).toBeUndefined(); - expect(fromMain('git push')).toBeDefined(); +describe('guard: worktree lifecycle goes through the scripts', () => { + test('git worktree add/remove is denied in every session and every checkout', () => { + expect(fromMain('git worktree add .worktrees/x -b x')).toContain('bun run worktree:create'); + expect(guard(bash('git worktree remove .', worktreeRoot, 'fresh'), state)).toContain('bun run worktree:destroy'); + expect(fromMain('git worktree list')).toBeUndefined(); }); +}); - test('Z8: state file names are hashes of the session id, and files older than a day are pruned', () => { +describe('session state', () => { + test('prune drops entries older than the cutoff', () => { const dir = mkdtempSync(join(sandbox, 'prune-')); const pruned = fileSessionState(dir); - pruned.rememberWorktree('old/../../etc', worktreeRoot); + pruned.rememberWorktree('old', worktreeRoot); + const [oldFile] = readdirSync(dir); pruned.rememberWorktree('fresh', worktreeRoot); - const names = readdirSync(dir); - expect(names.length).toBe(2); - for (const name of names) expect(name).toMatch(/^[0-9a-f]{32}$/); - expect(existsSync(join(dir, 'fresh'))).toBe(false); const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); - for (const name of names) if (pruned.lastWorktree('fresh') && name !== names[0]) break; - const oldFile = names.find((name) => !existsSync(join(dir, name)) || true) as string; utimesSync(join(dir, oldFile), twoDaysAgo, twoDaysAgo); pruned.prune(24 * 60 * 60 * 1000); expect(readdirSync(dir).length).toBe(1); }); +}); - test('Z8: a state-file failure is reported on stderr once and the hook still exits 0', () => { - const blocker = join(sandbox, 'state-as-file'); - writeFileSync(blocker, 'not a directory\n'); +describe('hook binary', () => { + const run = (input: Record, stateDir: string) => { const result = Bun.spawnSync(['bun', join(HOOKS_DIR, 'bashMainCheckoutGuard.ts')], { - stdin: Buffer.from(JSON.stringify(bash('ls', worktreeRoot))), + stdin: Buffer.from(JSON.stringify(input)), stdout: 'pipe', stderr: 'pipe', - env: { ...process.env, WORKTREE_GUARD_STATE_DIR: blocker }, + env: { ...process.env, WORKTREE_GUARD_STATE_DIR: stateDir }, }); expect(result.exitCode).toBe(0); - const stderr = result.stderr.toString(); - expect(stderr.match(/bashMainCheckoutGuard:/g)?.length).toBe(1); - expect(result.stdout.toString()).toBe(''); - }); + return { stdout: result.stdout.toString().trim(), stderr: result.stderr.toString() }; + }; - test('the hook binary keeps per-session state, emits a PreToolUse deny, prunes on SessionStart, and stays silent otherwise', () => { + test('records on SessionStart, prunes stale state, emits a PreToolUse deny, and is silent otherwise', () => { const stateDir = mkdtempSync(join(sandbox, 'bin-state-')); - const run = (input: Record) => { - const result = Bun.spawnSync(['bun', join(HOOKS_DIR, 'bashMainCheckoutGuard.ts')], { - stdin: Buffer.from(JSON.stringify(input)), - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, WORKTREE_GUARD_STATE_DIR: stateDir }, - }); - expect(result.exitCode).toBe(0); - expect(result.stderr.toString()).toBe(''); - return result.stdout.toString().trim(); - }; writeFileSync(join(stateDir, 'stale'), `${worktreeRoot}\n`); const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); utimesSync(join(stateDir, 'stale'), twoDaysAgo, twoDaysAgo); - expect(run(bash('git push', mainRoot))).toBe(''); - expect(run({ hook_event_name: 'SessionStart', session_id: SESSION, cwd: worktreeRoot })).toBe(''); + expect(run(bash('git push', mainRoot), stateDir).stdout).toBe(''); + expect(run({ hook_event_name: 'SessionStart', session_id: SESSION, cwd: worktreeRoot }, stateDir).stdout).toBe(''); expect(existsSync(join(stateDir, 'stale'))).toBe(false); - const denied = JSON.parse(run(bash('git push', mainRoot))) as { - hookSpecificOutput: { hookEventName: string; permissionDecision: string; permissionDecisionReason: string }; - }; - expect(denied.hookSpecificOutput.hookEventName).toBe('PreToolUse'); - expect(denied.hookSpecificOutput.permissionDecision).toBe('deny'); + const denied = JSON.parse(run(bash('git push', mainRoot), stateDir).stdout); + expect(denied.hookSpecificOutput).toMatchObject({ hookEventName: 'PreToolUse', permissionDecision: 'deny' }); expect(denied.hookSpecificOutput.permissionDecisionReason).toContain(worktreeRoot); - expect(run(bash(`cd ${worktreeRoot} && git push`, mainRoot))).toBe(''); - expect(run(bash('git status', mainRoot))).toBe(''); - expect(run({ session_id: SESSION, tool_name: 'Read', cwd: mainRoot, tool_input: { file_path: 'x' } })).toBe(''); + expect(run(bash(`cd ${worktreeRoot} && git push`, mainRoot), stateDir).stdout).toBe(''); + }); + + test('fails open with one stderr line when state cannot be written', () => { + const blocker = join(sandbox, 'state-as-file'); + writeFileSync(blocker, 'not a directory\n'); + const { stdout, stderr } = run(bash('ls', worktreeRoot), blocker); + expect(stdout).toBe(''); + expect(stderr.match(/bashMainCheckoutGuard:/g)?.length).toBe(1); }); }); diff --git a/.claude/skills/worktrees/SKILL.md b/.claude/skills/worktrees/SKILL.md index d80e4aee8..37949ef4e 100644 --- a/.claude/skills/worktrees/SKILL.md +++ b/.claude/skills/worktrees/SKILL.md @@ -7,7 +7,7 @@ description: Use when starting feature work that needs isolation, when dispatchi This repo has its own worktree tooling on top of git. Plain `git worktree add` gives you an isolated checkout. The repo scripts give you an isolated **runtime** — separate Postgres databases (local + test), separate MinIO buckets, a separate Redis logical DB, and separate ports for Web / Admin / Superadmin / API — plus the gitignored artifacts a fresh checkout does not carry. That's the difference between "I can edit files" and "I can run a full dev stack and the test suites alongside the main one." -Always use the scripts. The raw `git worktree add` / `git worktree remove` are in the `bashMainCheckoutGuard` hook's guarded set (see below) — there is no case for them. +Always use the scripts. The `bashMainCheckoutGuard` hook denies a raw `git worktree add/remove/move/prune` (see below) — there is no case for them. ## The three commands @@ -94,17 +94,14 @@ When you dispatch a subagent to do code work in a worktree, **the subagent does The same applies to your own session: the Bash tool's cwd can reset to the main checkout between calls (after a session restart, or after a command that `cd`'d outside the repo). A bare `bun add`, `git commit`, or `git push` then lands on main. -The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. +The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) enforces two rules. (1) `git worktree add/remove/move/prune` is denied in every session — use the scripts. (2) Once this session has worked inside a registered worktree of this repository, a mutating command (`git commit/push/merge/rebase/cherry-pick/revert/am`, `bun add/remove/update`, `bun install `, `bun run db:migrate`, `prisma migrate dev`) that would land in the main checkout — on any branch — is denied with the `cd && …` form to run instead. A leading `cd`, `git -C`, or `bun --cwd` moves where a command lands; naming the main checkout by its absolute path is an explicit choice and is allowed. -Consequences worth knowing: - -- A `cd` earlier in the command moves the effective directory for everything after it in the same shell; a `cd` inside a subshell `( )`, or one ending in `|` / a single `&`, does not (`(cd ) && git push`, `cd | cat; git push`, `cd & git push` are all denied; `{ cd ; } && git push` passes). `git -C ` applies only to its own segment; `bun --cwd ` may appear anywhere in its segment and applies to that segment. So `cd && git push` passes, `git -C status && git push` is denied, and `cd && cd .. && git push` is denied. `--git-dir`, `--work-tree`, `GIT_DIR`, and `GIT_WORK_TREE` pointing at main are denied even after a `cd` into a worktree. -- A `cd` to a path that does not exist, is not a git checkout, or contains a variable makes the directory unresolvable, and unresolvable means denied. `cd ;git push` and `cd &&git push` (no spaces) tokenize like the spaced forms. -- Symlinks are resolved before the decision: a symlink to a registered worktree lifts, a symlink to the main checkout does not (main lifts only by its own absolute path). -- `REQUIRE_MAIN_BRANCH` is an architect decision: while the main checkout sits on a feature branch the hook is inert, so a drifted commit lands on that branch. It is one constant in the hook, with a test either way, so it can be flipped in one edit. -- A session that has only ever worked in the main checkout is never touched. Background Bash calls inherit the session cwd like foreground ones. Per-session state lives under the OS temp dir and entries older than a day are pruned on SessionStart. +- A session that has only ever worked in the main checkout is only subject to rule (1). The main checkout may have any branch checked out. +- A worktree that has been destroyed, or one belonging to another repository, does not arm rule (2). Per-session state lives under the OS temp dir; entries older than a day are pruned on SessionStart. +- The hook recognises commands by their words, not with a full shell parser: it catches an agent that forgot where its cwd is, not one trying to get around it. - The hook only denies; it never rewrites a command, because PreToolUse hooks run in parallel and the user-level RTK hook already rewrites Bash commands via `updatedInput`. + ## When you're done with a worktree ```bash @@ -133,7 +130,7 @@ If Docker is unreachable at destroy time it says so; the next `worktree:create` | Mistake | Fix | |---|---| -| Using `git worktree add` directly | Use `bun run worktree:create` (`--skip-db` for docs-only) — the hook denies the raw command from a drifted main cwd anyway | +| Using `git worktree add` directly | Use `bun run worktree:create` (`--skip-db` for docs-only) — the hook denies the raw command | | `worktree:destroy` refused because the worktree is dirty | Commit or discard in the worktree, or `bun run worktree:destroy --force` when the changes are disposable | | Trusting a green "ready" from the older create | Current script: green means every env file sources, every artifact exists, and the push landed; yellow WITH GAPS lists what to rerun | | `worktree:create main ` for a branch that already exists | That is attach mode: `bun run worktree:create ` | diff --git a/AI/ENTRYPOINT.md b/AI/ENTRYPOINT.md index 3d37d4964..ae1d2dd47 100644 --- a/AI/ENTRYPOINT.md +++ b/AI/ENTRYPOINT.md @@ -47,7 +47,7 @@ concluding that something is missing — it is usually present and documented. - Do not run git commands unless the user explicitly asks. - **NEVER run `git stash` — for any reason, ever.** Not for "baseline verification", not for "let me test if this is from my change", not for "let me try and see". If you need to compare against HEAD or a clean state: use `git show HEAD:` to read a file at HEAD, `git diff` to see changes, or read the file content directly. Stash has destroyed in-flight work before. - **NEVER use `git -c =` to bypass any rule** — this includes (but is not limited to) bypassing the stash ban, signing requirements, hook execution (`--no-verify`), or any other guardrail. If a command requires bypassing config, stop and ask. -- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy [--force]` (refuses a dirty worktree without `--force`), `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. See `.claude/skills/worktrees/SKILL.md`. +- **ALL worktree lifecycles go through the repo scripts** — `bun run worktree:create ` (or `worktree:create ` to attach; `--skip-db` for docs-only work that needs no databases or Docker), `bun run worktree:destroy [--force]` (refuses a dirty worktree without `--force`), `bun run worktree:list`. NEVER `git worktree add`/`remove` by hand: the scripts provision and tear down the whole slot environment (Docker pre-flight, slot registry under `.worktrees/.slots/`, ports, `.env.local`/`.env.test` synced against the branch's examples, per-slot databases and buckets, dependency install, generated artifacts, schema push) — a hand-rolled worktree is a broken half-environment plus orphaned state. Create fails fast when Docker/Postgres is down and ends green **ready** only when every env file sources, every artifact exists, and the schema push landed; yellow **WITH GAPS** lists what to rerun. If all slots are in use, ask which worktree to destroy; verify it is merged and clean first. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) enforces two rules. (1) `git worktree add/remove/move/prune` is denied in every session — use the scripts. (2) Once this session has worked inside a registered worktree of this repository, a mutating command (`git commit/push/merge/rebase/cherry-pick/revert/am`, `bun add/remove/update`, `bun install `, `bun run db:migrate`, `prisma migrate dev`) that would land in the main checkout — on any branch — is denied with the `cd && …` form to run instead. A leading `cd`, `git -C`, or `bun --cwd` moves where a command lands; naming the main checkout by its absolute path is an explicit choice and is allowed. See `.claude/skills/worktrees/SKILL.md`. - **If something is broken, just fix it.** Don't stash-to-bisect, don't "let me see if reverting my changes makes it go away" — read the code, find the cause, fix the cause. ## 0.1. Understand the Why First diff --git a/docs/claude/SCRIPTS.md b/docs/claude/SCRIPTS.md index eab3c46fd..f3e0ec3ca 100644 --- a/docs/claude/SCRIPTS.md +++ b/docs/claude/SCRIPTS.md @@ -328,7 +328,7 @@ bun run worktree:list bun run worktree:destroy [--force] # --force: destroy despite uncommitted or untracked changes ``` -Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s; `stat -c`/`stat -f` chosen per platform), counts a registry entry as a claim even before its directory exists and prunes it only when the directory is missing, no lock is held, and it is older than 120s, prints the `worktree:destroy --force` cleanup line on any failure or signal after the worktree exists (and releases the slot before it), reclaims a dead-pid lock and its directory-less registry entry with a notice while skipping a live one, aborts when the database inventory query fails, requires `DATABASE_URL` for both sides unless `--skip-db`, and drops a leftover slot database only when no registered worktree references that slot. Destroy requires a worktree whose git common dir is this repository's `.git` (or a half-created directory whose `.git` file points under this repository's `.git/worktrees/` at a gitdir that is gone; a `.git` file whose gitdir still exists, here or elsewhere, gets a `git worktree repair` hint and nothing is deleted), normalises the name to its on-disk spelling, prints whether the branch is merged into the default branch, refuses a dirty worktree unless `--force`, requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) denies a Bash command when ALL of these hold: this session has been inside a linked worktree of THIS repository that still exists; the session cwd is now the main checkout; the main checkout is on the default branch (`origin/HEAD`, normally `main` — `REQUIRE_MAIN_BRANCH` in the hook); and some segment of the command runs one of `bun add/a/remove/rm/update`, `bun install/i ` (a bare `bun install`/`bun i`, with or without options, is allowed), `git commit/push/merge/rebase/cherry-pick/revert/am`, `git worktree add/remove/move/prune`, `prisma migrate dev`, `bun run db:migrate` (also as `bun --cwd db:migrate`, `bun run --cwd db:migrate`, `bun --cwd run db:migrate`) whose effective directory is not lifted. The effective directory follows any earlier `cd`/`pushd` in the command that runs in the same shell — a `cd` inside `( )`, or ending in `|` or a single `&`, does not move it (`{ }` does) — or the segment's own `git -C`, `bun --cwd` (anywhere in the segment), `env -C`/`--chdir`, `--git-dir`/`--work-tree`/`GIT_DIR`/`GIT_WORK_TREE`, with every target resolved through `realpath`. Lifted means: inside a worktree that `git worktree list` registers (named by any path, symlinks included), or this main checkout named by its own absolute path with `cd`, `git -C`, or `bun --cwd` (`env -C /abs/main` does not lift). A relative path into main, a symlink to main, an unregistered checkout or another repository, a non-checkout directory such as `/tmp`, a target containing a substitution or variable, or nesting deeper than six levels is never lifted. A script fed to a shell is walked as commands: a heredoc attached to `sh`/`bash`/`zsh`/`dash` (with or without `-s`), `echo`/`printf`/`cat < && …`. Full guide: `.claude/skills/worktrees/SKILL.md`. +Each worktree owns a slot (1–9), recorded in `.worktrees/.slots/` and as `WORKTREE_SLOT=` on the first line of its `.env.local` / `.env.test`: ports, `${PROJECT_NAME}_wt_` / `${PROJECT_NAME}_test_wt_` Postgres databases, MinIO buckets, a Redis logical DB, slot-specific env files synced against the branch's `.env.*.example` (multi-line quoted values included), its own `node_modules`, and the gitignored artifacts (route trees, Prisma client, SDK). Create fails fast when Docker/Postgres is down, refuses to fork onto a branch that already exists, warns about worktrees whose branch is merged (including branches checked out in a worktree), reclaims a slot lock whose owner is gone (dead pid, or no pid file and older than 60s; `stat -c`/`stat -f` chosen per platform), counts a registry entry as a claim even before its directory exists and prunes it only when the directory is missing, no lock is held, and it is older than 120s, prints the `worktree:destroy --force` cleanup line on any failure or signal after the worktree exists (and releases the slot before it), reclaims a dead-pid lock and its directory-less registry entry with a notice while skipping a live one, aborts when the database inventory query fails, requires `DATABASE_URL` for both sides unless `--skip-db`, and drops a leftover slot database only when no registered worktree references that slot. Destroy requires a worktree whose git common dir is this repository's `.git` (or a half-created directory whose `.git` file points under this repository's `.git/worktrees/` at a gitdir that is gone; a `.git` file whose gitdir still exists, here or elsewhere, gets a `git worktree repair` hint and nothing is deleted), normalises the name to its on-disk spelling, prints whether the branch is merged into the default branch, refuses a dirty worktree unless `--force`, requires a registered worktree (or a half-created one with a `.git` file — never `.locks`/`.slots`), validates the slot, refuses when the worktree's env files name more than one slot, skips the DB/bucket/Redis cleanup when another worktree shares the slot, and kills every listener on the slot's ports (reporting each pid; a survivor is a warning). Green **ready** means every step landed; yellow **WITH GAPS** lists what to rerun. The `bashMainCheckoutGuard` hook (`.claude/hooks/`, PreToolUse on Bash + SessionStart) enforces two rules. (1) `git worktree add/remove/move/prune` is denied in every session — use the scripts. (2) Once this session has worked inside a registered worktree of this repository, a mutating command (`git commit/push/merge/rebase/cherry-pick/revert/am`, `bun add/remove/update`, `bun install `, `bun run db:migrate`, `prisma migrate dev`) that would land in the main checkout — on any branch — is denied with the `cd && …` form to run instead. A leading `cd`, `git -C`, or `bun --cwd` moves where a command lands; naming the main checkout by its absolute path is an explicit choice and is allowed. Full guide: `.claude/skills/worktrees/SKILL.md`. --- From 0a00682c4559cad1a856c0464f9cea93361fd894 Mon Sep 17 00:00:00 2001 From: Aron Greenspan Date: Thu, 24 Sep 2026 00:18:59 -0300 Subject: [PATCH 8/8] chore(worktrees): guard reads migration script names from one constant Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/hooks/bashMainCheckoutGuard.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/bashMainCheckoutGuard.ts b/.claude/hooks/bashMainCheckoutGuard.ts index fdc3a1e5c..7a8b656c7 100644 --- a/.claude/hooks/bashMainCheckoutGuard.ts +++ b/.claude/hooks/bashMainCheckoutGuard.ts @@ -27,6 +27,7 @@ const GIT_WORKTREE_LIFECYCLE = new Set(['add', 'remove', 'move', 'prune']); const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree']); const BUN_MUTATING = new Set(['add', 'a', 'remove', 'rm', 'update']); const BUN_INSTALL = new Set(['install', 'i']); +const MIGRATION_SCRIPTS = new Set(['db:migrate']); const BUN_OPTIONS_WITH_VALUE = new Set([ '--filter', '-F', @@ -113,7 +114,7 @@ const bunLanding = (words: string[], cursor: Cursor): Omit | const mutating = BUN_MUTATING.has(subcommand ?? '') || (BUN_INSTALL.has(subcommand ?? '') && rest.length > 0) || - script === 'db:migrate' || + MIGRATION_SCRIPTS.has(script ?? '') || isPrismaMigrateDev(positionals); return mutating ? { ...here, lifecycle: false } : undefined; };