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

handnot2
Samly can be used to enable SAML 2.0 Single Sign On in a Plug/Phoenix application. This library uses Erlang esaml to provide plug enabl...
New
mspanc
I am pleased to announce an initial release of the Membrane Framework - an Elixir-based framework with special focus on processing multim...
New
mbuhot
Leverage Open Api 3.0 (Swagger) to document, test, validate and explore your Plug and Phoenix APIs. Generate and serve a JSON Open API ...
New
msaraiva
Surface is an experimental library built on top of Phoenix LiveView and its new LiveComponent API that aims to provide a more declarative...
564 44265 214
New
tfwright
After working on it for a couple of months and using it in production for most of that time, today I’ve released LiveAdmin, a LiveView ba...
New
brainlid
LangChain is short for Language Chain. An LLM, or Large Language Model, is the “Language” part. This library makes it easier for Elixir a...
New
Flo0807
Hello everyone! I am excited to share our heart project Backpex with you. After building several Phoenix applications, we realized that...
New

Other popular topics Top

vonH
In asking this question I am more interested about the expressiveness of the language itself and less concerned about the availability of...
New
JakeBecker
TL;DR: I’ve just released an implementation of Microsoft’s IDE-independent Language Server Protocol for Elixir. It adds language support ...
1144 55125 245
New
gshaw
What is the idiomatic way of matching for not nil in Elixir? E.g., First way: defp halt_if_not_signed_in(conn, signed_in_account) when...
New
AstonJ
Seen any cool LiveView demos, sample apps or examples? Please post them here! :003:
New
Patoshizzle
After calling mix ecto.create I get this error: 17:00:32.162 [error] GenServer #PID&lt;0.412.0&gt; terminating ** (Postgrex.Error) FATAL...
New
TunkShif
This post is an instruction guide to help you setup your Neovim for Elixir development from scratch. It includes general information on h...
274 42716 114
New

We're in Beta

About us Mission Statement