diff --git a/.claude/hooks/bashMainCheckoutGuard.ts b/.claude/hooks/bashMainCheckoutGuard.ts new file mode 100644 index 000000000..7a8b656c7 --- /dev/null +++ b/.claude/hooks/bashMainCheckoutGuard.ts @@ -0,0 +1,295 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +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 }; +}; + +const GIT_MUTATING = new Set(['commit', 'push', 'merge', 'rebase', 'cherry-pick', 'revert', 'am']); +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', + '--linker', + '--backend', + '--omit', + '--registry', + '--config', + '-c', + '--env-file', +]); +const PASS_THROUGH = new Set(['rtk', 'proxy', 'env', 'command', 'sudo', 'time', 'nohup', 'bunx', 'npx']); + +export type Landing = { + command: string; + directory?: string; + absolute: boolean; + lifecycle: boolean; +}; + +const unquote = (token: string) => token.replace(/^(['"])(.*)\1$/s, '$2'); + +const withoutHeredocBodies = (command: string) => + command.replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n\s*\2(?=\n|$)/g, '$3'); + +const maskQuoted = (command: string) => + command.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, (quoted) => + quoted.replace(/[\s;&|(){}]/g, (ch) => `\u0001${ch.charCodeAt(0)}\u0002`), + ); + +const unmask = (text: string) => text.replace(/\u0001(\d+)\u0002/g, (_, code) => String.fromCharCode(Number(code))); + +const expandHome = (path: string) => (path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(1)) : path); + +const realOrResolved = (path: string) => (existsSync(path) ? realpathSync(path) : resolve(path)); + +type Cursor = { directory?: string; absolute: boolean }; + +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 }; +}; + +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 gitLanding = (words: string[], cursor: Cursor): Omit | undefined => { + let here = cursor; + let 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; + } + 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: string[]) => words.join(' ').match(/(^|\s)prisma migrate dev(\s|$)/) !== null; + +const bunLanding = (words: string[], cursor: Cursor): Omit | undefined => { + let here = cursor; + const positionals: string[] = []; + for (let i = 1; i < words.length; i += 1) { + 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]); + } + const [subcommand, ...rest] = positionals; + const script = subcommand === 'run' ? rest[0] : subcommand; + const mutating = + BUN_MUTATING.has(subcommand ?? '') || + (BUN_INSTALL.has(subcommand ?? '') && rest.length > 0) || + MIGRATION_SCRIPTS.has(script ?? '') || + isPrismaMigrateDev(positionals); + return mutating ? { ...here, lifecycle: false } : 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; +}; + +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; + } + return landings; +}; + +type Checkout = { root: string; mainRoot: string }; + +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 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 parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +}; + +const isWithin = (path: string, root: string) => path === root || path.startsWith(`${root}/`); + +export const registeredWorktrees = (mainRoot: string): string[] => { + try { + const listing = execFileSync('git', ['-C', mainRoot, 'worktree', 'list', '--porcelain'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return listing + .split('\n') + .filter((line) => line.startsWith('worktree ')) + .map((line) => line.slice('worktree '.length).trim()) + .filter((path) => existsSync(path)) + .map((path) => realpathSync(path)) + .filter((path) => path !== realpathSync(mainRoot)); + } catch { + return []; + } +}; + +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 => { + 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, + rememberWorktree: (sessionId, worktreeRoot) => { + 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, { throwIfNoEntry: false })?.mtimeMs ?? Date.now()) < cutoff) rmSync(path, { force: true }); + } + }, + }; +}; + +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'); + +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 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): string | undefined => { + if (!input.cwd || !input.session_id) return undefined; + const checkout = findCheckout(input.cwd); + if (!checkout) 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 lifecycle = landings.filter((landing) => landing.lifecycle); + if (lifecycle.length > 0) return lifecycleDenial(lifecycle); + + if (landings.length === 0) return undefined; + 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 = () => { + const input = JSON.parse(readFileSync(0, 'utf8')) as HookInput; + 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({ + hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason }, + })}\n`, + ); +}; + +if (import.meta.main) { + try { + main(); + } catch (error) { + 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 new file mode 100644 index 000000000..f8944c91e --- /dev/null +++ b/.claude/hooks/tests/bashMainCheckoutGuard.test.ts @@ -0,0 +1,304 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + realpathSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +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 foreignWorktree: string; +let state: SessionState; + +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, agentId?: string) => ({ + hook_event_name: 'PreToolUse', + session_id: sessionId, + agent_id: agentId, + tool_name: 'Bash', + cwd, + tool_input: { command }, +}); + +const visit = (cwd: string, sessionId = SESSION) => guard(bash('ls', cwd, sessionId), state); +const fromMain = (command: string) => guard(bash(command, mainRoot), state); +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, 'apps/api'), { 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 }); + + 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'); +}); + +beforeEach(() => { + state = fileSessionState(mkdtempSync(join(sandbox, 'state-'))); +}); + +afterAll(() => { + rmSync(sandbox, { recursive: true, force: true }); +}); + +describe('landingsIn', () => { + test('the guarded set', () => { + for (const command of [ + 'bun 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 --no-pager push', + 'git -c core.editor=true rebase origin/main', + 'git merge feature', + 'git cherry-pick abc', + 'git revert abc', + 'git am patch.mbox', + 'bunx prisma migrate dev', + 'bun x prisma migrate dev', + 'bun run db:migrate', + 'bun run --cwd packages/db db:migrate', + 'rtk git push', + 'FOO=1 git commit -m x', + 'git add -A && git push origin HEAD', + 'bun install; git commit -m x', + '(git push)', + 'bun run --filter @template/db db:migrate', + ]) { + expect(isMutating(command), command).toBe(true); + } + }); + + test('outside the set', () => { + for (const command of [ + 'bun install', + 'bun i --frozen-lockfile', + '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('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('git worktree add/remove/move/prune are lifecycle landings', () => { + for (const command of [ + 'git worktree add ../x', + 'git worktree remove x', + 'git worktree move x y', + 'git worktree prune', + ]) { + expect(landingsIn(command, mainRoot)[0]?.lifecycle, command).toBe(true); + } + }); + + 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('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('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 linked worktrees, not the main checkout', () => { + expect(registeredWorktrees(mainRoot)).toEqual([realpathSync(worktreeRoot)]); + }); +}); + +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(); + }); + + 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('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'); + } + }); + + 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(`cd ${mainRoot} && git push`)).toBeUndefined(); + expect(fromMain(`git -C ${mainRoot} push`)).toBeUndefined(); + expect(fromMain('git status')).toBeUndefined(); + }); + + 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('cd ../.. && git push', worktreeRoot), 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('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('state is per session and per subagent', () => { + visit(worktreeRoot, 'session-b'); + expect(fromMain('git push')).toBeUndefined(); + guard(bash('ls', worktreeRoot, SESSION, 'agent-1'), state); + expect(fromMain('git push')).toBeUndefined(); + expect(guard(bash('git push', mainRoot, SESSION, 'agent-1'), state)).toContain(worktreeRoot); + }); +}); + +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(); + }); +}); + +describe('session state', () => { + test('prune drops entries older than the cutoff', () => { + const dir = mkdtempSync(join(sandbox, 'prune-')); + const pruned = fileSessionState(dir); + pruned.rememberWorktree('old', worktreeRoot); + const [oldFile] = readdirSync(dir); + pruned.rememberWorktree('fresh', worktreeRoot); + const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + utimesSync(join(dir, oldFile), twoDaysAgo, twoDaysAgo); + pruned.prune(24 * 60 * 60 * 1000); + expect(readdirSync(dir).length).toBe(1); + }); +}); + +describe('hook binary', () => { + const run = (input: Record, stateDir: string) => { + 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); + return { stdout: result.stdout.toString().trim(), stderr: result.stderr.toString() }; + }; + + test('records on SessionStart, prunes stale state, emits a PreToolUse deny, and is silent otherwise', () => { + const stateDir = mkdtempSync(join(sandbox, 'bin-state-')); + 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), 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), stateDir).stdout); + expect(denied.hookSpecificOutput).toMatchObject({ hookEventName: 'PreToolUse', permissionDecision: 'deny' }); + expect(denied.hookSpecificOutput.permissionDecisionReason).toContain(worktreeRoot); + 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/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..37949ef4e 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 `bashMainCheckoutGuard` hook denies a raw `git worktree add/remove/move/prune` (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 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 [--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`. -## 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. +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)`. ## 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; 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 (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 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. -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 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 @@ -80,44 +83,62 @@ 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/`, 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. + +- 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 -bun run worktree:destroy +bun run worktree:destroy [--force] ``` -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. +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). -**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 — slot allocation, env generation, DB/bucket setup, install, schema push | -| `scripts/worktree/destroy.sh` | Cleanup logic — DB drop, bucket removal, Redis flush, port kill, worktree remove | +| `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, 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 | 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 | +| `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` | | 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 @@ -127,12 +148,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..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), `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 [--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/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..f3e0ec3ca 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) @@ -48,13 +49,23 @@ 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/ - ├── 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 +318,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 [--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) 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`. + +--- + ## Writing New Scripts **Shell scripts**: Place in `scripts//`, make executable (`chmod +x`). diff --git a/scripts/db/minio-remove.sh b/scripts/db/minio-remove.sh new file mode 100755 index 000000000..1bc6a87eb --- /dev/null +++ b/scripts/db/minio-remove.sh @@ -0,0 +1,51 @@ +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 + +bucket_exists() { + mc ls "local/${1}" >/dev/null 2>&1 +} + +failed=0 +for bucket in "$@"; do + 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: removed ${bucket}" + fi +done +exit "$failed" diff --git a/scripts/setup/sync-env.sh b/scripts/setup/sync-env.sh index 21caf6f70..b92f9c605 100755 --- a/scripts/setup/sync-env.sh +++ b/scripts/setup/sync-env.sh @@ -1,12 +1,36 @@ #!/bin/bash -set -e +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 file with example - creates if missing, adds missing keys sync_env() { - local env_file="$1" - local example_file="$2" + 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" @@ -14,24 +38,50 @@ sync_env() { return 0 fi - # Add missing keys + 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 + entry=() + missing=0 + } + while IFS= read -r line || [ -n "$line" ]; do - [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue - if [[ "$line" =~ ^([A-Z_][A-Z0-9_]*)= ]]; then - key="${BASH_REMATCH[1]}" - if ! grep -q "^${key}=" "$env_file"; then - grep "^${key}=" "$example_file" >> "$env_file" - echo "Added $key to $env_file" + 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 + [[ "$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 } -# 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 ed9188c91..638014b6f 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,364 @@ 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 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 + 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 -# --------------------------------------------------------------------------- -# 1. Scan existing worktrees for allocated slots + warn about merged branches -# --------------------------------------------------------------------------- -echo -e "${BLUE}Scanning existing worktrees...${NC}" +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 + 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 + +info "Scanning worktrees for allocated slots..." + +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)" -USED_SLOTS=" " +prune_slot_registry + +SLOT_OWNER=("" "" "" "" "" "" "" "" "" "") 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)" +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 + claim "${entry%% *}" "${entry#* }" +done < <(registry_entries) 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 - fi + [ -n "$wt_path" ] && [ -d "$wt_path" ] || continue + wt_branch="$(git -C "$wt_path" branch --show-current 2>/dev/null || true)" + 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 -done < <(git -C "$ROOT_DIR" worktree list --porcelain | grep '^worktree ' | sed 's/^worktree //') + for i in $wt_slots; do + 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)) + fi + 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) -# --------------------------------------------------------------------------- SLOT="" +SLOT_LOCK="" +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 (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 - if ! echo "$USED_SLOTS" | grep -q " ${i} "; then SLOT="$i"; break; fi + 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 - 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" +register_slot "$SLOT" "$WORKTREE_DIR" +SLOT_REGISTERED=1 + 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 +CREATED=1 -# --------------------------------------------------------------------------- -# 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}" +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 + copy_env_without_marker "$src" "$WORKTREE_DIR/${src#"$ROOT_DIR"/}" +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/^/ /' + +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}" +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" 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 + [ -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_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_redis_db "$f" "$REDIS_DB" +done + +GAPS="" -# --------------------------------------------------------------------------- -# 6. Create Postgres databases -# --------------------------------------------------------------------------- -echo -e "${BLUE}Creating Postgres databases on $PG_CONTAINER...${NC}" -if "$DOCKER" ps --format '{{.Names}}' | grep -qx "$PG_CONTAINER"; then +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 + 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 - "$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}\"" + 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." + 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}" + 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" + 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" 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 -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 + +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" -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 " Run 'bun run worktree:list' to see available worktrees." + 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 fi -if echo "$NAME" | grep -qE '(\.\.|/)'; then - echo -e "${RED}Error: Name cannot contain '..' or path separators${NC}" - exit 1 +if [[ "$NAME" =~ (^\.|\.\.|/) ]]; then + die "Error: Name cannot start with '.' or 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)" +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 + 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 + 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 + 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 -[ -z "$PROJECT_NAME" ] && PROJECT_NAME="$(basename "$ROOT_DIR")" -PG_CONTAINER="${PROJECT_NAME}_postgres" -REDIS_CONTAINER="${PROJECT_NAME}_redis" +[ "$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" +} + +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):" + print_first_lines "$DIRTY" + else + echo -e "${RED}Error: $NAME has uncommitted or untracked changes:${NC}" >&2 + print_first_lines "$DIRTY" >&2 + die "Commit or discard them, or pass --force to destroy anyway." + fi + fi +fi -# --- 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)" +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 - 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}" - - # --- 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}" + 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 - echo -e "${YELLOW}Warning: $PG_CONTAINER not running — skipping DB drop.${NC}" + SHARED_SLOT=0 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}" - - 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 + DOCKER="$(resolve_docker || true)" + 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 + 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 - # --- 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}" + info "Removing MinIO buckets for slot ${SLOT}..." + 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." + + 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 - 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 + PORTS="$(slot_ports "$SLOT")" + info "Killing processes on ports: ${PORTS}..." + for PORT in $PORTS; do + kill_port_listeners "$PORT" done + unregister_slot "$SLOT" "$WORKTREE_DIR" fi -# --- 5. Remove the git worktree -------------------------------------------- -echo -e "${BLUE}Removing git worktree...${NC}" -git -C "$ROOT_DIR" worktree remove "$WORKTREE_DIR" --force 2>/dev/null || { - echo -e "${YELLOW}git worktree remove failed; cleaning up manually.${NC}" +info "Removing git worktree..." +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 -echo -e "${GREEN}Worktree '$NAME' destroyed${NC}" -[ -n "$SLOT" ] && echo -e "${GREEN}Slot $SLOT freed${NC}" +ok "Worktree '$NAME' destroyed" +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 new file mode 100755 index 000000000..bf056c730 --- /dev/null +++ b/scripts/worktree/lib.sh @@ -0,0 +1,599 @@ +#!/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 +} + +file_mtime() { + 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)" + common="$(cd "$1" && cd "$common" && pwd -P)" + dirname "$common" +} + +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" +} + +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 pattern + line="$(env_line "$1" "$2")" + [ -n "$line" ] || return 0 + 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%%\'*}" ;; + *) value="${value%%[[:space:]]#*}"; value="${value%"${value##*[![:space:]]}"}" ;; + esac + [ -n "$value" ] && printf '%s\n' "$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)" + echo "${name:-template}" +} + +valid_slot() { + [[ "$1" =~ ^[1-9]$ ]] +} + +resolve_docker() { + local candidate + 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 + fi + done + return 1 +} + +container_state() { + "$DOCKER" inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true +} + +container_on_port() { + local names + names="$("$DOCKER" ps --filter "publish=$1" --format '{{.Names}}' 2>/dev/null || true)" + printf '%s\n' "${names%%$'\n'*}" +} + +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}" +} + +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 quoted now + line="$(env_line "$file" "$key")" + if [ -n "$line" ]; then + case "$line" in export*) prefix="export " ;; esac + 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" "$(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:-}'." +} + +ensure_env_var() { + set_env_var "$1" "$2" "$3" +} + +replace_env_var_if_present() { + 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 + [ -n "$(env_value "$f" "$key")" ] && return 0 + done + 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 "$(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 "$(env_key_pattern WORKTREE_SLOT)" "$file" || true; } > "$tmp" + mv "$tmp" "$file" +} + +rewrite_database_url() { + 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" 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 +} + +linked_worktrees() { + local p root + root="$(real_dir "$ROOT_DIR")" + while IFS= read -r p; do + [ -n "$p" ] || continue + p="$(real_dir "$p")" + [ "$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() { + 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" ] +} + +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 + [ -f "$f" ] && echo "$f" + done + return 0 +} + +slots_dir() { + echo "$ROOT_DIR/.worktrees/.slots" +} + +registry_file() { + echo "$(slots_dir)/$1" +} + +registry_raw() { + local f + 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" > "$(registry_file "$1")" +} + +unregister_slot() { + 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 + [ -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_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 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. $(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")" + [ -d "$owner" ] && continue + [ -d "$(lock_root)/slot-$slot" ] && continue + [ -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 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")" + if [ -n "$marker" ] && [[ " $slots " == *" $marker "* ]]; then + keep="$marker" + reason="(its WORKTREE_SLOT marker)" + fi + 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 $reason, removing $slot." + rm -f "$(registry_file "$slot")" + done + done < <(registry_duplicates) +} + +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 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 + url="$(env_value "$f" DATABASE_URL)" + [ -n "$url" ] && [[ "$url" =~ $pattern ]] && return 0 + done < <(worktree_env_files "$wt") + return 1 +} + +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 + echo "$wt" + return 0 + fi + done < <(linked_worktrees) + return 1 +} + +kill_port_listeners() { + local port="$1" pid 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" + 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_pid() { + [ -f "$1/pid" ] && tr -d '\r\n' < "$1/pid" || true +} + +lock_is_stale() { + 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 + [ "$(file_age "$lock")" -gt "$STALE_LOCK_SECONDS" ] +} + +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)" + 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 +} + +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 + 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..e4a8f2e37 100755 --- a/scripts/worktree/list.sh +++ b/scripts/worktree/list.sh @@ -1,44 +1,57 @@ #!/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")" +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")" - 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 + if [ "$(real_dir "$wt_path")" = "$MAIN_REAL" ]; 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" -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) + +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#*:} — the next create keeps the slot its WORKTREE_SLOT marker names, else the newest entry." +done < <(registry_duplicates) echo