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

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.
4 Likes