chore: support concurrent worktrees against shared Supabase - #4
Merged
Conversation
All worktrees share one local Supabase stack (keyed by project_id), and Next dev falls back to ports 3001+ when 3000 is taken. Make that workable: - widen the auth redirect allow-list to localhost:3000-3009 so OAuth works on whichever port Next grabs (Supabase can't wildcard a port) - auto-link .env and signing_keys.json from the primary checkout into new worktrees (symlinked so the JWT signing key matches the shared instance); run via postinstall and `npm run worktree:init` - guard db:reset/db:stop from linked worktrees so a feature branch can't disrupt the shared stack (override with FORCE_SHARED_SUPABASE=1) - exclude .claude/worktrees from eslint and prettier so a nested worktree is not linted as source of this one
- link only .env explicitly instead of globbing every .env* — a future .env.production/.env.keys must not auto-fan-out across worktrees - resolve the primary checkout via `git worktree list --porcelain` instead of git-common-dir path surgery, removing a hidden caller-cwd dependency - exit cleanly outside a git checkout (postinstall runs in CI/tarball installs) rather than emitting `fatal:` under set -e - warn loudly on stderr when a real (non-symlink) file blocks a link - clarify worktree.sh: db:reset/db:stop are destructive (guarded); test:db/e2e only contend on the shared DB (serialize, not guarded) - exclude gitignored docs/superpowers from prettier
When 3000 is taken Next falls back to the next free port; record that OAuth is configured for 3000-3009 so sign-in keeps working.
Compare a pre-existing real file against the primary with cmp and warn only when they differ, instead of on every real file. Never overwrites.
Enforce shellcheck on scripts/*.sh in CI via the runner's preinstalled binary; add a lint:sh script that fetches it through npx so local runs need no manual install.
awk field-splitting truncated any worktree path containing a space, silently misidentifying primary vs linked and defeating the shared- Supabase guard. Strip the `worktree ` prefix instead; the main worktree is documented as listed first.
A missing signing_keys.json was a quiet stdout skip, but it's the one silent footgun: the app still boots while every session silently invalidates. Gate the link and emit a stderr WARNING naming the consequence and recovery steps instead.
db:keys minted a fresh key only when none existed, but in a worktree whose symlink to the primary's key is missing that produced a divergent key — the exact footgun the symlink design prevents. Gate the generate branch behind the shared-Supabase guard so keys are only created in the primary checkout (FORCE_SHARED_SUPABASE=1 overrides).
lint:sh ran shellcheck via `npx --yes shellcheck@4.1.0` while CI used the runner's preinstalled binary, so the two could lint against different versions. Declare shellcheck as a devDependency (lockfile-pinned, audited, Dependabot-tracked like eslint/prettier) and point both lint:sh and CI at it, so local and CI run one version with no manual install. CI re-fetches the binary per run since the wrapper downloads it outside npm's cache.
The shared-Supabase worktree model lived only in shell comments. Add a "Working with git worktrees" section (shared stack, worktree:init and its postinstall hook, the guarded db:reset/db:stop, the primary-must-stay-put caveat, and serialize-DB-tests) and list worktree:init in the Scripts table.
db:reset/test:db/test:e2e all hit the one stack every worktree shares, so running them from two checkouts at once clobbered each other's data. Run them under an flock on a repo-shared lockfile (the common git dir, so all worktrees contend on one file) — a second run waits instead. Degrades to unserialized with a warning where flock is absent (stock macOS).
Drop the postinstall hook that ran worktree-init on every npm install. Linking gitignored secrets (.env, signing_keys.json) into a worktree is now the explicit `npm run worktree:init` step instead of an implicit install-time side effect. Update the README and drop the now-stale postinstall rationale from the script comment.
Running db:start (hence db:keys) in an un-linked worktree hit the guard's generic "run from primary / FORCE=1" message — but the right fix is to link the primary's key via worktree:init, not generate a divergent one. Give db:keys its own guidance; leave reset/stop on the primary/FORCE line.
A real file byte-identical to the primary passed cmp -s and was left silently in place — a copy that drifts on the next key rotation, the exact silent session-invalidation the symlink design prevents. Warn on any real (non-symlink) file instead; still never overwrites.
The guard (destructive-op gate) and worktree-init's link logic are the highest-blast-radius, most-churned scripts and had only shellcheck. Add bats suites: the guard's allow/refuse/FORCE/db:keys-message behavior, and link's symlink/warn-don't-overwrite/missing-key cases (in a throwaway repo, never the real checkout). Wire test:sh into CI and AGENTS.md.
Resolve the primary structurally — dirname of the shared common git dir, which lives in the primary by construction — instead of trusting git to list the main worktree first. Simpler, space-safe without parsing, and free of the ordering assumption the guard relied on. --path-format=absolute (git 2.31+) avoids the relative `.git` returned from the primary.
A blocking flock gave no output while another worktree held the lock, so the documented "waits rather than clobbering" looked like a hang. Probe non-blocking first and print a waiting message before blocking.
db:stop tore down the shared stack without the lock, so a primary db:stop could yank the DB out from under a worktree's in-flight test:db/test:e2e. Wrap it in with-supabase-lock like db:reset, so stop waits for in-flight DB work instead of breaking it.
flock without -s is an exclusive lock, not a "shared lock" (that's the term for flock -s, which wouldn't serialize writers). Also add db:stop to the serialized list now that it takes the lock.
Use the same git 2.31+ idiom as primary_worktree instead of open-coding --git-common-dir + a cwd-relative-path fixup, so both scripts locate the common git dir one way. The lock script stays standalone.
supabase start could race a concurrent db:stop/db:reset from another worktree on the shared stack. Route it through with-supabase-lock.sh like its siblings, and correct the lock script comment and README, which had omitted db:start (and db:stop) from the serialized set.
git <2.31 silently ignores --path-format=absolute: rev-parse exits 0, echoes the flag back, and returns a relative common dir, so the lock and primary_worktree lookups quietly key per-cwd and worktrees stop sharing state. Assert the result is absolute and abort with a clear message instead.
worktree.bats added a worktree to the developer's real checkout and read its scripts; mirror worktree-init.bats and build a disposable repo instead, so a killed run can't leave a stray worktree registered against the real repo.
When flock is absent (stock macOS), the wrapper previously ran the command unserialized after a quiet note — the exact condition that lets two worktrees corrupt the one shared stack. Refuse by default and require ALLOW_UNSERIALIZED_SUPABASE=1 to override, mirroring the FORCE_SHARED_SUPABASE escape hatch.
The --path-format=absolute git-2.31+ assertion was copied verbatim into both worktree.sh and with-supabase-lock.sh. Extract common_git_dir() as the single resolver-and-asserter; the lock wrapper now sources worktree.sh and derives the lock path from it instead of re-checking.
dirname of the common git dir only lands on the primary's root under the conventional in-root .git layout; with --separate-git-dir or a bare repo it points elsewhere, so the guard could misjudge which checkout is primary. Ask git for its first --porcelain worktree entry instead, which is the main worktree regardless of layout.
Note that worktree:init links only .env (edits fan out to every worktree) and deliberately skips .env.local, and that a dev server above port 3009 gets an unallowlisted redirect, so Google sign-in fails until a port in range frees up.
The guard also fires when a signing-key symlink dangles after the primary moves, not just when it was never linked. Word the remedy as link "or relink" so it reads right in both cases.
A contended wait previously printed one line then blocked silently. Now each run records its worktree and command in a sibling holder file, so a waiter can name who's blocking it, and on a terminal a throbber spins until the lock lands (erased on acquire). The real flock stays in the foreground so fd 9's lock survives into exec; off a TTY it falls back to a single static line so CI logs and pipes stay clean.
db:start ran key generation before acquiring the lock, so two concurrent runs in the primary could both pass the signing_keys.json existence check and append duplicate keys to the shared file. Wrap db:keys and supabase start in one locked region instead.
The blocking flock acquires were unguarded, so under set -e a failed acquire (EINTR, closed fd) aborted before exec, silently dropping the user's command. Report the failure and exit explicitly instead.
Move the guard/lock/act logic for db:keys, db:start, db:stop, and db:reset out of JSON-embedded shell strings into scripts/db-*.sh, so the ordering each command depends on is readable and reviewable. Behavior is unchanged: stop/reset still guard before locking, and db-start self-acquires the shared lock via a one-time re-exec (with-supabase-lock.sh now exports WITH_SUPABASE_LOCK to bound it), keeping keys-then-start as one locked unit without the npm re-entry the old bash -c chain incurred.
db:start already minted the key under the lock, but a direct npm run db:keys took none, so two concurrent runs in the primary could both pass the existence check and append a divergent key. Self-acquire the lock before minting, reusing the WITH_SUPABASE_LOCK re-exec idiom; the existence check at the top doubles as the under-lock re-check on the replayed second pass, and the key-present fast path still skips the lock entirely.
… interrupt Close fd 9 in the spinner subshell (9>&-) so the decorative animation is structurally unable to hold the shared lock open, and trap INT/TERM around the blocking flock so an interrupt mid-wait kills the spinner and erases its line instead of orphaning it and leaving the terminal mid-line. Also reword the holder-file comment to admit the recorded holder is best-effort and diagnostic only.
The README promises re-running worktree:init after relocating the primary, which relies on ln -sfn replacing a now-dangling symlink. Pin that path: pre-create .env as a stale symlink, run init, assert it is relinked to the current primary and resolves.
Reframe the dev-server port note from a passive description of the silent sign-in failure into symptom-first guidance: lead with what the developer sees (sign-in does nothing) and what to do (check the port, free one in 3000-3009, restart). A runtime warning at the OAuth call site remains a follow-up.
The shared-stack hazard is strictly cross-worktree, but the lock wrapper refused every DB command without flock — blocking a fresh single checkout on stock macOS from db:start before any worktree exists. Skip the lock (and its flock requirement) entirely when worktree_count is 1, and keep the exclusive lock and hard refusal once a second worktree is added.
A silent exit 0 read as "links created" to the person least familiar with the setup. Print a one-line stderr message before bailing.
The animated spinner required a backgrounded subshell, fd-9 juggling to keep it off the lock, and an interrupt trap to clean it up — intricate machinery for a decorative wait. A single static line announces the contended lock just as well on both terminals and CI logs, and the lock mechanism itself is untouched.
… port A dev server bumped past the 3000-3009 range Supabase allowlists got a redirect GoTrue silently drops to site_url, leaving the sign-in button spinning forever with no error. Detect an out-of-range localhost port before calling signInWithOAuth and surface an actionable message instead.
Minting seeds an empty `[]` array then appends the key, so an interrupted run left a stub that `-f` accepted as a real key — db:keys skipped re-minting and worktree:init linked it, silently invalidating every session. A has_signing_key helper now requires a populated array, so the stub falls through to re-mint and worktree:init warns instead of linking.
The holder label was never cleared, so a waiter could be told it was blocked on a worktree and command that had already finished. Run the wrapped command as a child instead of exec'ing it so an EXIT trap can remove the label on release; fd 9 stays open here, so the lock is still held for the command's whole run.
The worktree-count skip sampled the count once at startup, so a worktree added while a lone checkout ran an unlocked stack command could race it. Drop the skip and always take the lock when flock is available — closing that window and serializing concurrent runs within one checkout too. A flock-less machine now refuses unless ALLOW_UNSERIALIZED_SUPABASE=1 opts into a single-worktree unserialized run. Removes the now-unused worktree_count helper.
Fix the worktree.bats header, which claimed the file tested only the guard though it now also covers signing-key detection and the lock. Trim derivable filler and a stale "most-churned" claim from the others. No behavior change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Two local-dev frictions, both rooted in how this repo behaves across git worktrees:
:3000is taken,next devbumps to:3001+, so the Google OAuthredirectTono longer matches Supabase's redirect allow-list and login fails. Supabase can't wildcard a port, so the ports must be listed.project_id = "sous"). New worktrees can't boot (gitignored.env/signing_keys.json), and destructive commands from a feature worktree disrupt every checkout.What
localhost:3000-3009so OAuth works on whichever port Next grabs..env+supabase/signing_keys.jsonfrom the primary checkout into new worktrees - symlinked, so the JWT signing key matches the shared instance (a divergent key silently invalidates sessions). Runs viapostinstallandnpm run worktree:init.db:reset/db:stopfrom linked worktrees (overrideFORCE_SHARED_SUPABASE=1)..claude/worktrees/from eslint + prettier so a nested worktree isn't linted as source of this one.Notes / follow-ups
playwright.config.tshardwiresbaseURL: http://localhost:3000, so E2E only works when the dev server is on:3000- not addressed here.