Two feature branches running at once. Two projects running at once. Neither should have to negotiate with the other, and neither should have a setup step.
The problem, and the fix
pithy feature is what reserves a block of ports, an environment per feature is the cloud half, and local development is the single-project case.
Port collisions are the one thing that stops two worktrees running simultaneously — and since every project starts at the same base port, two projects as readily as two worktrees.
The fix is a registry every checkout reads before it assigns, held once per machine rather than once per checkout.
{
"/home/jo/code/acme": {
"main": { "block": 0, "base": 8787, "size": 20 },
"feature/12-auth": { "block": 1, "base": 8807, "size": 20 }
},
"/home/jo/code/other-app": {
"main": { "block": 2, "base": 8827, "size": 20 }
}
}It lives in the config directory — one file for the whole machine.
Why it moved there
It used to sit at the repo root. So every project on a machine kept its own registry, every one of them started empty, and every one handed out the first block — which meant two projects on their default branch pinned the same twenty ports, and the second refused to start with nothing able to say who had taken them.
A registry that is per checkout cannot answer a question about the machine.
The key is the checkout, not the project
Two unrelated projects can share a name, and sharing a name must never mean sharing ports.
Every worktree of one repository files under one key, resolved from the repository’s common directory — so a worktree finds its own root from anywhere.
Creating a feature
pithy feature create media-cli --issue 69Takes a short lock over the registry, reads it — seeing every block already in use on the machine — assigns the lowest free, non-overlapping block, writes its key, and unlocks. One atomic read-modify-write, so no two features can pick the same block.
Then it cuts the worktree and its branch, installs, pins one port per Worker in the worktree’s own dev config, and migrates and seeds the local backend.
Picking up a colleague’s branch
pithy feature syncNone of that state is in git — the dev config and the port reservation are both machine-local. So sync creates them on your machine, with your own free block.
That is precisely why ports are never committed: your teammate’s block may already be taken on your machine by one of your other worktrees, or by another project entirely.
It also handles the other everyday case: you added a Worker, and it takes the next free port from the block you already hold, leaving every existing Worker where it was.
Every step is idempotent, so running it when nothing is missing reports that nothing moved. --skip-data reconciles ports without touching the backend.
Adding a Worker is additive
Assignment is sticky. A Worker that already holds a port keeps it, and only genuinely new Workers are assigned.
Discovery is alphabetical, so a purely positional assignment would renumber every later Worker the moment somebody added one that sorts earlier — moving addresses out from under a running session.
A removed Worker releases its port back to the block.
A checkout that is gone frees its ports
At the repo root the registry died with the checkout, so cleanup happened for free. Outside it nothing would — so every allocation prunes any root no longer on disk.
Only a definite not there counts: a root that merely could not be reached keeps its blocks.
It cannot tell a deleted checkout from a moved one, and does not try. A moved repository’s blocks are freed, and it takes them back the next time a Pithy command runs there. Between those two moments another project can be handed one — which surfaces as a reported port conflict rather than two Workers on one port.
pithy doctor marks a row as not on disk, and that is the one line in its ports report you can act on: you renamed that directory.
It heals after a wipe
The registry sits outside every checkout, so no clone and no clean can take it. A wiped config directory, a new machine, or a relocated path still can — while the worktrees allocated from it live on.
So before allocating, a feature reclaims any block still pinned in an existing worktree’s own dev config. A lost registry cannot hand out a block a live feature is using.
That scan walks the worktrees of the repository the command was run in, and does not go looking through the others. So after a wipe each project re-registers its own the next time it runs, and a project that has not run since can be handed one of its blocks by one that has — which surfaces as a refusal rather than a silent double-bind, because every port is verified on both loopback families before binding.
Per-feature values never go in the generated variables file
That file is regenerated on every pithy dev, so a value typed into it is gone. And the sources it is generated from are keyed on the project and held on the machine — so every worktree of one project resolves the same ones.
A per-feature value put there would clobber every other feature’s. Shared secrets live in the dev secrets file; per-feature ports live in the worktree’s dev config.
Why one keyed registry rather than a file per branch
A single file shows every allocation on the machine in one read, makes add and remove a one-key mutation, and leaves no stale per-branch files to garbage-collect.
File-per-branch works but forces a scan-and-read-all to see what is taken — and it is exactly that scan a second project would have started over from.
Check it worked
- Two worktrees run at once on different ports
- Two projects run at once
pithy doctorlists your blocks and every other checkout’s- A renamed directory shows as not on disk
- Deleting the config directory and running again reclaims your live blocks