grove-worktree
v0.6.0
Published
A grove of git worktrees: every branch gets its own running stack, on its own ports.
Maintainers
Readme
The problem
You are on feat/login. Someone reports a bug on main. You stash, switch, wait
for the stack to come back, fix it, switch back, and wait again.
So you make a second worktree - and everything collides. Two apps want :3000.
Two databases want :5432. You start renumbering by hand, and that number
ripples through six env files across three layers.
Why "grove"
A grove is a stand of trees growing together. That is the product: several git worktrees, each one fully alive at the same time.
The idea
Ports only collide on the host. A Docker network is a separate address space,
so two worktrees can both run web:3000 and db:5432 and never meet. Compose
already gives you one network per project name; what it does not give you is a
way back in. That is the proxy's only job.
flowchart TB
browser["Browser"]
traefik["traefik<br/>the only published port<br/>on the whole machine"]
subgraph A ["network: feat-a"]
weba["web :3000"]
apia["api :4000"]
dba["db :5432"]
weba --> apia --> dba
end
subgraph B ["network: fix-b"]
webb["web :3000"]
apib["api :4000"]
dbb["db :5432"]
webb --> apib --> dbb
end
browser --> traefik
traefik -- "Host: web.feat-a..." --> weba
traefik -- "Host: web.fix-b..." --> webbIdentical port numbers in both, and they never meet. The only thing that has to be unique is the hostname, and that comes from the worktree name.
The hostname is the routing key, so no port ever appears in a URL. Everything
inside the stack - DATABASE_URL, service-to-service calls - is byte-identical
in every worktree. Only browser-facing URLs vary, and they come from ${WT_NAME}.
And when there is no Docker at all, the same idea reduces to its useful core:
grove leases a distinct host port per worktree and hands it to the process
through a config file or the environment. See
Stacks Docker doesn't run.
Install
npm install -g grove-worktreeThe package is grove-worktree; the command is grove. One binary,
deliberately - every alias is another thing that can be shadowed on someone's
PATH.
Quick start
cd your-repo
grove install # agent skill, /setup-grove command, session hooks, .gitignoreThen, in Claude Code:
/setup-groveThe agent fans out to read your repo - launch profiles, bind sites, dev-server
proxies, build-time bakes, orchestrators, hardcoded addresses in clients and
tests - writes worktree.toml, boots it, and then checks what each process
actually bound rather than trusting the config. It finishes by running two
worktrees at once, because one working stack proves nothing.
It stops and asks you twice, on the two things the code cannot answer: whether to make a source change, when a port is a literal only you can move; and whether a new worktree should start from a copy of your data, if it finds a database with any. You review the diff and commit it - to your default branch, since a worktree inherits its branch's files.
After that, day to day:
grove new feat/thing # branch, worktree, deps, ports, started
cd ../your-repo-feat-thingOpen a second terminal, do the same for another branch, and both run at once.
Or just ask your agent. The skill grove install wrote is the whole
interface - "spin up two worktrees, one for the checkout bug and one for the
search rewrite, and run the e2e suite against both" is a complete instruction.
It creates them, starts both stacks, and reads each one's URLs out of its own
manifest, so nothing you say has to mention a port.
Commands
| | |
|---|---|
| grove new <branch> | branch, worktree, hydrate, start - one call |
| grove new --seed-from <wt> | the same, starting from another worktree's data |
| grove up [services...] | start and block until healthy; idempotent |
| grove down [services...] | stop, keeping volumes, data and leases |
| grove restart [services...] | stop and start again - only what you name |
| grove rm <worktree> | delete a worktree and everything it owns |
| grove gc | reclaim containers, volumes and leases nothing owns |
| grove run <cmd...> | run with this worktree's environment injected |
| grove status | this worktree: services, ports, health |
| grove ls / grove ls --all | this repo's worktrees / every one on the machine |
| grove logs [services...] | container or captured process logs |
| grove hydrate | re-copy gitignored files from the main worktree |
| grove seed | what a new worktree could start from; --from <wt> copies it |
| grove adapt <step> | migrate a repo: evidence -> decide -> render -> validate |
| grove install | agent skill, slash command, hooks |
| grove doctor | check the environment and the migration |
Every command takes --json. Human output can change freely; --json is a
contract.
Start only what you need
Full stacks are expensive and several may be running at once.
grove up # everything
grove up api db # those, plus their dependencies
grove up --group backend # a named set from worktree.toml
grove up api --no-deps # exactly apiWhat you leave out is not-started - a distinct state from unhealthy. The
stack still reports ready, and nothing will try to "repair" a service you left
out on purpose. Adding one later (grove up web) extends the set without
restarting what is already running.
Starting from another worktree's data
A new worktree gets an empty database. When the data comes from the repo - migrations plus a seed script that run on boot - that is exactly right, and this section is not for you.
It is for the other case: the data got there by restoring a dump, and no new worktree can reproduce it. That is the reason people give up on isolation and go back to one shared database, where a migration on one branch breaks everyone else's.
grove new feat/thing --seed-from mainpg_dump in one container piped straight into pg_restore in the other, before
the application starts. Nothing lands on disk in between, so a database larger
than your free space still copies. Postgres, MySQL/MariaDB and Mongo are
recognised by image; the credentials come from the running container rather than
from a re-parse of the compose file.
Afterwards they are still two databases. The copy shares a starting point, not a schema: migrating one branch cannot reach the other.
It costs what the data weighs. The copy dominates the time grove new takes
and scales with the size of the database, not the repo. Run interactively, grove
measures the source first and offers the copy with the size attached, so the wait
is a choice:
main has data worth copying: db (260 MB)
The new worktree would start from the same data instead of an empty database.
The copy is what dominates how long this takes - expect minutes on a large one.
Copy it? [y/N]An agent is never asked - a prompt nobody is reading is a hang - so --json
without --seed-from simply does not copy. To make it the default for everyone,
name the source in worktree.toml:
[seed]
from = "main"--no-seed overrides that and skips it.
A named source is obeyed as given. --seed-from copies even when the database
looks empty: the size check exists to decide whether to interrupt you, never to
overrule you. A command that reports success and quietly did not do the thing is
the worst outcome available.
To see what is there without copying anything:
grove seed # or --jsonmain - what a new worktree could start from
db (postgres) 260 MB, about 1,400,000 rows
copy it with: grove new <branch> --seed-from mainThat command exists for the agent as much as for you. grove new cannot prompt
an agent - a question nobody is reading is a hang - so the asking happens once,
during /setup-grove. The agent runs grove seed --json, and if it finds a
database with data in it, puts the question to you with the size attached: should
every new worktree start from a copy? Your answer becomes the [seed] line, or
no line at all. It decides nothing on its own and does not ask again afterwards,
because the repo now has the answer.
grove seed --from <worktree> does the copy into a worktree that already exists,
replacing its data.
Everything is scoped to your worktree
down, restart and rm cannot reach another worktree. Containers are
addressed by the Compose project name - which is your worktree's slug - and
host processes by that worktree's own ledger. This is asserted by tests, not
merely believed.
grove ls --all shows every worktree on the machine with its ports: the command
for "who is holding that port?"
Setup
Two files next to your compose file. docker-compose.yml is never modified.
docker-compose.worktree.yml - an overlay, applied with -f base -f overlay:
services:
api:
ports: !reset [] # cancel the base file's published ports
networks:
internal: { aliases: [api.internal] }
wt-proxy: {}
labels:
- traefik.enable=true
- traefik.http.routers.${WT_NAME}-api.rule=Host(`api.${WT_NAME}.${WT_DOMAIN}`)
- traefik.http.services.${WT_NAME}-api.loadbalancer.server.port=4000
db:
ports: !override ["${WT_PORT_DB}:5432"] # leased host port; !reset would publish nothing
networks: [internal]
networks:
internal:
wt-proxy:
external: true
name: ${WT_PROXY_NETWORK}worktree.toml - what each service is, and how to tell it is up:
[project]
name = "sample-app"
compose = ["docker-compose.yml", "docker-compose.worktree.yml"]
[[services]]
name = "api"
layer = "backend"
subdomain = "api" # becomes api.<worktree>.localtest.me
port = 4000
health = "/healthz"
[[services]]
name = "db"
layer = "data"
host_port = true # leased, so you can open it in a GUI client
health = { exec = ["pg_isready", "-h", "127.0.0.1", "-U", "app"] }
[groups]
backend = ["api", "db"] # grove up --group backend
[env]
DATABASE_URL = "postgres://app:app@${WT_HOST_DB}/app"
# Gitignored files git will not bring to a new worktree.
[hydrate]
copy = [".env", "apps/*/.env.local"]
link = ["node_modules", "apps/*/node_modules"]
run = ["pnpm install --frozen-lockfile"]Copy what you may edit per worktree; link what is large and identical. grove
decides between link and run by hashing the lockfiles: identical means one
node_modules is safe to share, different means the branch changed its
dependencies and it installs instead.
A complete working pair lives in examples/sample-app.
Unknown keys are rejected. A misspelled option that parses fine and does nothing is the most expensive kind of typo, so there is no such thing here.
Stacks Docker doesn't run
Not every repo is containerised. An orchestrator that launches its own child processes, a compiled server, a dev server you start yourself - these bind host ports directly, so there is no Docker network to hide identical ports inside and the proxy has nothing to route to.
What still collides is the port, and that part grove can own:
[project]
name = "my-app"
compose = [] # nothing containerised at all - that's allowed
[[services]]
name = "server"
runtime = "host" # not a container
start = "./scripts/dev-server"
health = { tcp = true }
[[services]]
name = "api-grpc"
runtime = "host" # no `start` - just reserve the port
health = { tcp = true }
# Hand the leases back through a file the app already reads.
[render]
"config/ports.generated.json" = """
{ "ports": { "apiGrpc": ${WT_PORT_API_GRPC} } }
"""
# ...or through the environment, for anything read from there.
[env]
SERVER_URL = "https://localhost:${WT_PORT_SERVER}"grove up renders the config, starts the process with that environment, records
its pid and waits for it to answer - the same contract a container gets. down
stops it and everything it spawned; logs shows its captured output; a rendered
file that changes restarts the process that reads it.
A service with no start is a port reservation grove watches but does not
own: never unhealthy, never blocking the stack from ready. Only what grove
launches is what grove reports on.
The manifest
.wt/manifest.json, inside the worktree so a relative read always resolves.
Written on success and on failure, with logs attached, so a consumer learns
which layer broke and why.
{
"schemaVersion": 1,
"worktree": "fix-billing",
"status": "ready",
"scope": ["api", "db"],
"baseUrl": "http://web.fix-billing.localtest.me:8081",
"services": [
{ "name": "api", "status": "ready", "url": "...", "internalUrl": "http://api.internal:4000" },
{ "name": "db", "status": "ready", "hostAddress": "localhost:23229" },
{ "name": "web", "status": "not-started" }
]
}status: ready means everything in scope is ready. not-started means the
service was never asked for - it is not a failure, and consumers must not try to
repair it. That distinction is the whole reason partial startup is safe.
Agent integration
grove install writes:
.claude/skills/grove/- the daily-use skill, plus the reference the migration reads:discovery.md(finding every port and whether grove can move it),config.md(writingworktree.toml),verify.md(the listener audit and the two-worktree test).claude/commands/setup-grove.md- the/setup-grovemigration commandSessionStart/SessionEndhooks, merged into.claude/settings.jsonrather than overwriting it.wt/in.gitignore, and anAGENTS.mdsection if that file exists
SessionStart tells each session its own addresses:
grove: worktree `feat-login` (branch feat/login), stack ready.
Addresses for THIS worktree - use these, never a hardcoded port:
web http://web.feat-login.localtest.me:8081
api http://api.feat-login.localtest.me:8081
db localhost:21750
Also running: fix-billing (23906 20694 21008 +12).
Those belong to other worktrees. Do not use their ports, or stop them.The hook command is resolved at install time and verified to be this package. A hook pointing at a binary that is not on PATH fails silently, which is worse than not installing one at all.
SessionEnd does nothing unless you set [hooks] on_session_end = "down": that
hook has no turn to render a question into, so only reversible actions belong
there.
Development
DESIGN.md - how to build and test it, the traps this project paid for by running into them, and why each decision went the way it did.
License
MIT
