cortfritz

cortfritz

I wanted to share a small open-source thing I made for myself and figured it might be useful to others doing AI-agent work on Phoenix apps. It’s a Claude Code skill, MIT-licensed:

Fair warning up front: this is deliberately low-tech. It solves one narrow, mechanical problem (giving each worktree its own ports and Postgres DBs) and nothing more. It pairs naturally with Tidewave’s brand-new Spaces feature, which sits a level above this (more on that below).

The problem

Claude Code sessions are bound to the folder they start in, so if you want two agent sessions working on the same repo in parallel, the natural unit is a git worktree: one checkout per branch, one session per checkout.

For a plain library that’s enough. For a Phoenix app it isn’t: two checkouts both try to bind port 4000, and they both point at the same app_dev / app_test Postgres databases. The dev-server collision is obvious (:eaddrinuse). The test collision is the nasty one; it surfaces as confusing DBConnection.OwnershipError sandbox failures, because both test suites are fighting over the same database while the Ecto sandbox assumes it owns it.

So a bare worktree gives you code isolation but not runtime isolation, and the runtime part is exactly what bites you when an agent runs the test suite.

How it works

The whole thing hangs on one mechanism: a conditional import at the bottom of config/config.exs.

if File.exists?("config/#{config_env()}.local.exs") do
  import_config "#{config_env()}.local.exs"
end

with config/*.local.exs gitignored. Each worktree gets its own dev.local.exs / test.local.exs that override the Endpoint port(s) and the Repo database names.

The reason I like this: because those local files are untracked, “merge back to main minus the port change” happens automatically; the port change was never in git to begin with. There’s no cleanup step where you remember to revert ports before merging, because the ports only ever lived in an untracked file.

What the commands do

/worktree create <name>:

  1. Adds the import hook + gitignore entry if the project doesn’t have them yet (one-time, committed to main).
    1. Picks a free port block (main port + 100·n), checking sibling worktrees and live listeners.
    1. git worktree add ../<app>-<name> -b <name>.
    1. Writes the local config overrides (ports plus <app>_dev_<name> / <app>_test_<name>), including any extra listeners your dev config binds (HTTPS, mTLS, MQTT, …), since any one you miss dies with :eaddrinuse.
    1. Copies gitignored-but-required runtime files the new worktree won’t have (local secrets, mix phx.gen.cert TLS certs, …).
    1. mix setup + MIX_ENV=test mix ecto.create.
    1. Prints a handoff: quit the session, cd into the worktree, restart claude there.
      /worktree destroy <name> does the reverse, with guardrails. It refuses if there’s uncommitted work, verifies no *.local.exs was ever committed on the branch (they can hold secrets), drops both databases, merges the branch into main, then removes the worktree and branch.

Limitations

  • It assumes local Postgres + Ecto. No containers, no other databases.
    • The skill is prose instructions executed by Claude, not code. There’s no binary to audit; it’s a single markdown file that Claude reads alongside your mix.exs and config/dev.exs to fill in app names, ports, and Repo modules. That makes it easy to read and adapt, but it’s not a deterministic script.
    • The underlying pattern is agent-agnostic. Nothing here is specific to Claude or to AI at all; if you run parallel worktrees by hand, the same conditional-import + per-worktree DB trick works identically. The skill just automates the bookkeeping.
    • A few sharp edges, learned the hard way, that you’ll hit if you adapt the approach by hand: git worktree add only materializes tracked files, so gitignored secrets and phx.gen.cert certs have to be copied over, or boot/seeds fail with a confusing Cowboy :keyfile error. Every listener in dev config has to shift ports, not just the Endpoint, or you’ll get :eaddrinuse whenever the main dev server is also running. Don’t pipe setup commands through tail/grep, since the pipe masks failing exit codes and a broken setup looks like it succeeded. And port scanning uses find rather than a ../app-*/ glob, because zsh aborts on an unmatched glob when no sibling worktree exists yet.

How this fits with Tidewave Spaces

This is meant as a temporary stopgap to fill a gap for Tidewave, though the underlying pattern is general enough that it may stick around. Tidewave just launched Spaces, a unified control plane: one Tidewave tab can connect to the same app on different ports, e.g. across worktrees. Valim was explicit that Tidewave doesn’t manage worktrees for you yet, and built Spaces to sit on top of whatever worktree approach you already use. This skill just does the mechanical worktree + port + DB provisioning underneath that. Credit to the Tidewave folks (Dashbit / José Valim) for the real work here; this composes with it rather than competing.

Feedback welcome

I’d genuinely like to hear:

  • If you’re already running parallel agent sessions on a single Phoenix repo, what are you using for the worktree side? Plain worktrees with manual config, worktrunks, multiple checkouts, rift, containers, or something else, especially now that Spaces gives you a control plane to point at them.
    • Whether the conditional-import-of-*.local.exs pattern steps on anyone’s existing config conventions.
      PRs and issues welcome on the repo. Thanks for reading.

First 3 of 3 Posts Switch mode

Dmk

Dmk

I am running parallel agents using worktrees, but with a different design that I think is simpler.

Per-worktree Phoenix isolation with zero per-worktree config

The skill approach generates untracked dev.local.exs / test.local.exs files per worktree (driven by the agent) and pulls them in with a conditional import_config. The thing I wanted to avoid was having any per-worktree config file to author, gitignore, or accidentally commit.

The idea

Instead of writing per-worktree overrides, derive everything from one input (the worktree’s directory name) at config-eval time. The config is committed, identical in every worktree, and computes the differences itself. You run git worktree add and mix phx.server and it just works. There are no generated files and nothing to restart the agent for.

The whole thing

In config/dev.exs:

# One input: the worktree name. Override with GIT_WORKTREE, else use the
# checkout's directory name. (My layout is a monorepo, so cwd is server/ and
# I take the parent dir. Adjust to taste.)
worktree_name = System.get_env("GIT_WORKTREE") || Path.basename(Path.dirname(File.cwd!()))

# Database: one per worktree
config :brightsite, BrightSite.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "brightsite_dev_#{worktree_name}",
  pool_size: 10

# Port: pure function of the name, no probing
port = 4000 + :erlang.phash2(worktree_name, 1000)

config :brightsite, BrightSiteWeb.Endpoint,
  http: [ip: {0, 0, 0, 0}, port: port],
  url: [host: "localhost.localdomain", port: port],
  # ...

IO.puts("Using database: brightsite_dev_#{worktree_name}")
IO.puts("Using port: #{port}")

:erlang.phash2(name, 1000) maps any worktree name into a stable 0 to 999 offset, so 4000 + offset is deterministic per worktree and survives restarts. There is no sibling-probing or live-listener checking. The name is the allocation.

config/test.exs mirrors it so concurrent worktrees (and their in-progress migrations) don’t share a test DB, while keeping CI’s partitioning:

worktree_name = System.get_env("GIT_WORKTREE") || Path.basename(Path.dirname(File.cwd!()))

config :brightsite, BrightSite.Repo,
  database: "brightsite_test_#{worktree_name}#{System.get_env("MIX_TEST_PARTITION")}",
  pool: Ecto.Adapters.SQL.Sandbox,
  pool_size: System.schedulers_online() * 2

That’s the core. Any extra dev listener just derives from the same offset, e.g. LiveDebugger:

live_debugger_port = 4007 + :erlang.phash2(worktree_name, 1000)
config :live_debugger, port: live_debugger_port

The one non-obvious bug: cookies

Ports isolate the server, but cookies are scoped by domain, not port. Two worktrees both on *.localhost.localdomain will happily clobber each other’s session cookie, and the overwritten token, minted against a different worktree’s database, resolves to no user and bounces you to the login page. Confusing as hell the first time.

Fix: namespace the cookie names by worktree too.

config :brightsite, :session_cookie_key, "_brightsite_#{worktree_name}_key"
config :brightsite, :auth_cookie_name, "_brightsite_#{worktree_name}_auth"
config :brightsite, :auth_cookie_domain, ".localhost.localdomain"
config :brightsite, :auth_cookie_secure, false  # browsers reject Secure cookies over plain HTTP

Workflow

git worktree add ../my-feature        # tracked files only
cd ../my-feature/server
mix deps.get && (cd assets && npm install)
mix ecto.create && mix ecto.migrate
mix phx.server
# => Using database: brightsite_dev_my-feature
# => Using port: 4317

There’s no config to write, nothing to gitignore, and nothing to copy. The config is the same in every worktree. The values differ because they’re computed.

What I like about deriving instead of generating:

  • No per-worktree artifacts. Nothing untracked to manage, nothing to accidentally commit. The skill’s whole import_config hook plus gitignore dance exists to keep overrides out of git; here there are no overrides.
  • Deterministic and agent-independent. It’s :erlang.phash2 at boot, not prose an agent executes. A human in a fresh worktree gets correct isolation with zero steps.
  • Covers the cookie collision that port-only isolation leaves latent.
cortfritz

cortfritz OP

looks great!

cortfritz

cortfritz OP

…is especially clever

— All posts loaded —

Where Next? Top

Trending in Announcing Top

bluzky
You may know https://ui.shadcn.com/, a UI component library for React. I really love it’s design style and components. I’ve built some co...
387 15136 120
New
woylie
Flop is an Elixir library that applies filtering, ordering and pagination parameters to your Ecto queries. offset-based pagination with...
New
restlessronin
The repo is at GitHub - cyberchitta/openai_ex: Community maintained Elixir library for OpenAI API · GitHub. Docs are at OpenaiEx User Gu...
152 11030 135
New
JesseHerrick
Hey, I’m Jesse and I’m the main contributor behind Dexter, a full-featured, lightning-fast Elixir LSP optimized for large codebases. It s...
New
shahryarjb
The Chelekom project is a library of Phoenix and LiveView components generated via Mix tasks to fit developer needs seamlessly. One of i...
New
woylie
Phoenix components for pagination, sortable tables and filter forms with Flop and (optionally) Ecto. pagination cursor pagination sorta...
New
kip
Please say hi to a new lib, Astro that aims to deliver easy-to-consume astronomy calculations of practical use. For now it only calculat...
New

Other Trending Topics Top

akoutmos
@hugobarauna and I (Alex Koutmos) have been hard at work on writing a book on Nerves that takes you from simply blinking LEDs to building...
New
spammy
I’m looking to build a personal workflow to quickly deploy web applications written in elixir/phoenix, for local consumption (ie not on t...
New
alexslade
Fly’s CEO posted this recently - Turn And Face The Strange · The Fly Blog It says that Fly is going all-in on sprites, which is a worry ...
New
Herve37
We’re evaluating API mocking tools for OpenAPI-based projects and would love to hear what other teams are using. We’re particularly inte...
New
bartblast
Hey folks, I just published a post about Hologram’s funding and where the project goes next - the short version: Curiosum as Main Spons...
New
matt-savvy
Is there a word for the ~> symbol used in Version strings? Do you also just call it a Squiggle Arrow™ ?!
New

We're in Beta

About us Mission Statement