cortfritz

cortfritz

Elixir Worktree Skill - a small Claude Code skill for parallel git-worktree Phoenix sessions (port + Postgres isolation)

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.

Most Liked

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

…is especially clever

Where Next?

Popular in Announcing Top

danschultzer
In short Plug n’ play OAuth 2.0 provider library. Just set up a resource owner schema with Ecto (your user schema), install the dependen...
New
dominicletz
Hi, I thought I had posted my library before but seems I hadn’t. The project is still in early stages but it’s growing and so I think it...
New
pkrawat1
Presenting Aviacommerce, open source e-commerce platform in Elixir Aviacommerce is an open source e-commerce platform in Elixir. We at...
New
devonestes
Introducing assertions, the library that helps you write really great test assertions! GitHub: GitHub - devonestes/assertions: Helpful a...
New
aesmail
Hello guys, I have finally made it. I created an admin interface for a framework. It’s been on my todo list for years and with the curre...
New
MRdotB
I needed to reuse React components from my Chrome extension in my Phoenix/LiveView backend. I noticed that for Svelte/Vue, there are live...
New
achempion
Hi, I would like to tell about my initiative to further maintain and develop Waffle project which is the fork of Arc library. The progre...
New
fuelen
Hey folks! Want to present a toolkit for writing command-line user interfaces. It provides a convenient interface for colorizing text...
New
wfgilman
I’ve cleaned up and open sourced three financial libraries I was using for my company. They are bindings for the APIs of these three comp...
New
New

Other popular topics Top

sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
sorentwo
Hello! tl;dr Announcing Oban, an Ecto based job processing library with a focus on reliability and historical observability. After spen...
985 42920 311
New
senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
jononomo
I am trying to figure out how Mix knows whether the environment is test, dev, or prod – where is this set? Thanks.
New
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
stefanluptak
Hello everybody, usually, I use a 29" ultra-wide monitor for VSCode which can easily accomodate explorer (files panel) + file with code ...
New
aalberti333
As the title describes, I’m trying to run Enum.map() over a list of key/value pairs, where the value is a map. My data looks like this: ...
New
boundedvariable
I am going through the kafka architecture. All the features what the kafka is providing are already in Erlang. I would like hear your opi...
New
komlanvi
Hi everyone, I was playing with phoenix liveView but I run into an issue. I have a form and want to validate each input text when the te...
New
Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New

We're in Beta

About us Mission Statement