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>:
- Adds the import hook + gitignore entry if the project doesn’t have them yet (one-time, committed to main).
-
- Picks a free port block (
main port + 100·n), checking sibling worktrees and live listeners.
- Picks a free port block (
-
git worktree add ../<app>-<name> -b <name>.
-
- 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.
- Writes the local config overrides (ports plus
-
- Copies gitignored-but-required runtime files the new worktree won’t have (local secrets,
mix phx.gen.certTLS certs, …).
- Copies gitignored-but-required runtime files the new worktree won’t have (local secrets,
-
mix setup+MIX_ENV=test mix ecto.create.
-
- Prints a handoff: quit the session,
cdinto the worktree, restartclaudethere.
/worktree destroy <name>does the reverse, with guardrails. It refuses if there’s uncommitted work, verifies no*.local.exswas ever committed on the branch (they can hold secrets), drops both databases, merges the branch intomain, then removes the worktree and branch.
- Prints a handoff: quit the session,
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.exsandconfig/dev.exsto fill in app names, ports, and Repo modules. That makes it easy to read and adapt, but it’s not a deterministic script.
- 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
-
- 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 addonly materializes tracked files, so gitignored secrets andphx.gen.certcerts have to be copied over, or boot/seeds fail with a confusing Cowboy:keyfileerror. Every listener in dev config has to shift ports, not just the Endpoint, or you’ll get:eaddrinusewhenever the main dev server is also running. Don’t pipe setup commands throughtail/grep, since the pipe masks failing exit codes and a broken setup looks like it succeeded. And port scanning usesfindrather than a../app-*/glob, because zsh aborts on an unmatched glob when no sibling worktree exists yet.
- A few sharp edges, learned the hard way, that you’ll hit if you adapt the approach by hand:
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.exspattern steps on anyone’s existing config conventions.
PRs and issues welcome on the repo. Thanks for reading.
- Whether the conditional-import-of-
Most Liked
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_confighook plus gitignore dance exists to keep overrides out of git; here there are no overrides. - Deterministic and agent-independent. It’s
:erlang.phash2at 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
…is especially clever
Popular in Announcing
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance








