Skip to content

chore: support concurrent worktrees against shared Supabase - #4

Merged
ben-everly merged 49 commits into
mainfrom
feature/worktree-dev-support
Jun 19, 2026
Merged

chore: support concurrent worktrees against shared Supabase#4
ben-everly merged 49 commits into
mainfrom
feature/worktree-dev-support

Conversation

@ben-everly

@ben-everly ben-everly commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Why

Two local-dev frictions, both rooted in how this repo behaves across git worktrees:

  1. Port fallback breaks OAuth. When :3000 is taken, next dev bumps to :3001+, so the Google OAuth redirectTo no longer matches Supabase's redirect allow-list and login fails. Supabase can't wildcard a port, so the ports must be listed.
  2. All worktrees share one local Supabase stack (keyed by project_id = "sous"). New worktrees can't boot (gitignored .env / signing_keys.json), and destructive commands from a feature worktree disrupt every checkout.

What

  • Widen the auth redirect allow-list to localhost:3000-3009 so OAuth works on whichever port Next grabs.
  • Auto-link .env + supabase/signing_keys.json from the primary checkout into new worktrees - symlinked, so the JWT signing key matches the shared instance (a divergent key silently invalidates sessions). Runs via postinstall and npm run worktree:init.
  • Guard db:reset / db:stop from linked worktrees (override FORCE_SHARED_SUPABASE=1).
  • Exclude .claude/worktrees/ from eslint + prettier so a nested worktree isn't linted as source of this one.

Notes / follow-ups

  • playwright.config.ts hardwires baseURL: http://localhost:3000, so E2E only works when the dev server is on :3000 - not addressed here.
  • DB (pgTAP) and E2E tests clobber across concurrent worktrees (shared DB); unit tests are safe (Supabase mocked). Serialize DB-touching tests.

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.
@ben-everly ben-everly changed the title chore(worktrees): support concurrent worktrees against shared Supabase chore: support concurrent worktrees against shared Supabase Jun 12, 2026
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.
ben-everly and others added 20 commits June 17, 2026 20:24
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.
@ben-everly
ben-everly merged commit a6edd19 into main Jun 19, 2026
5 checks passed
@ben-everly
ben-everly deleted the feature/worktree-dev-support branch June 19, 2026 02:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant