Worktrees
How sail derives one compose project per git worktree, shifts every host port by a deterministic hash of the worktree name, syncs those ports into the local dot-env overrides, and cleans up stacks whose worktree is gone.
Sail is worktree-native: no flags, no per-worktree compose overrides. Every
command starts by answering three questions about where it was run — which
worktree the app root lives in, which compose project that maps to, and which
port offset to apply. node ace sail:info prints those answers (projectName,
worktree, portOffset), and --json gives them to you as fields.
The compose project name
The app name comes from package.json, last path segment only, so @acme/shop
becomes shop; without a readable package.json sail falls back to the app
root's directory name. It is then sanitized into something compose accepts —
lowercased, every run of characters outside [a-z0-9_-] collapsed to a single
-, leading _/- and trailing - stripped, empty result replaced by app.
So My App becomes my-app, @scope/my-app becomes scope-my-app, and
UPPER_CASE.name becomes upper_case-name.
In the main checkout the project name is that bare app name (shop) and the
offset is 0 — the textbook ports, so a teammate who never opens a worktree
sees exactly what the README says. In a linked worktree it becomes
sanitize(appName + '-' + worktree.slug): a worktree checked out at
../shop-worktrees/feature-login runs the project shop-feature-login.
Docker prefixes named volumes and the default network with the project name, so
sail-postgres becomes shop-feature-login_sail-postgres. Each worktree gets
its own database files, Redis dump and bucket contents; dropping one worktree's
data never touches the main checkout's.
The worktree name is its directory basename, not the branch. Two worktrees
on different branches in identically-named directories (under different parents)
resolve to the same project and the same ports — name worktree directories after
what you are doing in them. Note that the project name is built from the
worktree's slug while the offset hashes its raw name (which is what
@adonisjs/core hashes for the serve port, so the app and its services shift
together). Directory names that differ only in case or punctuation —
Feature Login and feature-login — therefore share a compose project but not
a port offset; avoid that pair.
The port offset
The offset is the SHA-1 of the worktree's name, reduced to a number between
0 and 999. That range is the same one @adonisjs/core uses for the
worktree-aware serve port, so the app and its services shift together. Every
host port the stack publishes is basePort + offset:
| service | env var | base | feature-login (+403) |
|---|---|---|---|
| postgres | SAIL_POSTGRES_PORT | 5432 | 5835 |
| mysql | SAIL_MYSQL_PORT | 3306 | 3709 |
| redis | SAIL_REDIS_PORT | 6379 | 6782 |
| mailpit (smtp) | SAIL_MAILPIT_SMTP_PORT | 1025 | 1428 |
| mailpit (ui) | SAIL_MAILPIT_UI_PORT | 8025 | 8428 |
| rustfs (s3) | SAIL_RUSTFS_PORT | 9000 | 9403 |
| rustfs (console) | SAIL_RUSTFS_CONSOLE_PORT | 9001 | 9404 |
| seaweedfs (s3) | SAIL_SEAWEEDFS_PORT | 8333 | 8736 |
| seaweedfs (filer) | SAIL_SEAWEEDFS_FILER_PORT | 8888 | 9291 |
| garage (s3) | SAIL_GARAGE_PORT | 3900 | 4303 |
| minio (s3) | SAIL_MINIO_PORT | 9000 | 9403 |
| minio (console) | SAIL_MINIO_CONSOLE_PORT | 8900 | 9303 |
It is deterministic across machines. The offset is a pure function of a
string — no counters, no lock file, no "first free port" scan, nothing
persisted. A teammate whose worktree directory is also named feature-login
gets 5835 too.
The committed compose file is identical everywhere. Ports are written
as ${SAIL_POSTGRES_PORT:-5432} interpolations and sail injects the offset
SAIL_*_PORT variables into the environment of every docker compose call it
spawns — a bare docker compose up with no sail still works, on the base ports.
The range matches core's. PORT_RANGE is 1000 because that is the
range @adonisjs/core's worktree-aware serve uses for the HTTP port, so the
app port and the service ports shift together. (On a core without that patch the
app still serves on plain PORT; sail:share handles
both.)
Collisions
There is no collision avoidance, by design — the offset is a hash, not an allocation:
- two worktree names can hash to the same offset (roughly 1 in 1000);
- offsets are shared across apps: two projects with a worktree named
main-2land on the same numbers; - service ranges overlap — Postgres spans 5432–6431 and Redis 6379–7378, so one worktree's Postgres can sit on another worktree's Redis port;
- a worktree whose name hashes to
0collides with the main checkout.
None of this is silent: the sail:up preflight
turns each case into a loud, named failure instead of a cross-wired stack. The
fix is git worktree move — a different directory name re-hashes to a
different offset.
Detection and the git requirement
Sail asks git (git worktree list --porcelain -z) which worktree the app root
belongs to, resolving symlinks on the way, and treats two cases as "no
worktree": the repository's main checkout, and an app root outside a git
repository at all. The worktree name is matched verbatim — case-sensitive,
spaces included — so Feature-Login and feature-login are two different
worktrees as far as the offset is concerned.
The -z flag requires git ≥ 2.36. On older git the child process exits
non-zero, the error is swallowed, null is returned, and sail behaves exactly
as if you were in the main checkout: bare project name, offset 0, no warning.
Every worktree then races for the same containers and the same ports — run
node ace sail:info and check the worktree field if the ports look suspiciously
round.
Env syncing
Shifted ports are useless if the app does not know them. After a successful
sail:up — and on every sail:sync-env — sail writes the stack's resolved
connection env into two files in the app root:
.env.local— the override Adonis loads over.envin every environment excepttest;.env.test.local— the override the loader reads underNODE_ENV=test, where.env.localis skipped entirely.
The dot-env loader reads, in priority order:
.env.[NODE_ENV].local # e.g. .env.test.local
.env.local # skipped when NODE_ENV=test
.env.[NODE_ENV]
.envThat second file is why node ace test in a worktree does not silently hit the
main checkout's database: without it the loader falls through to .env, whose
DB_PORT=5432 points at whatever is listening on the base port. Both files are
created when missing and added to .gitignore (exact-line match, so an existing
entry is not duplicated).
Only connection coordinates are written — the enabled services' app env, offset applied, sorted, inside a marker block:
# sail:start
# Managed by @adonis-agora/sail — per-worktree service ports. Do not edit;
# re-run `node ace sail:sync-env` to refresh.
DB_DATABASE=app
DB_HOST=127.0.0.1
DB_PASSWORD=password
DB_PORT=5835
DB_USER=sail
REDIS_HOST=127.0.0.1
REDIS_PORT=6782
# sail:endRedis also writes an empty REDIS_PASSWORD (sail's Redis has no auth),
Mailpit adds SMTP_HOST / SMTP_PORT, and object storage adds
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, S3_BUCKET and
S3_ENDPOINT — under whatever names your config/drive.ts s3 disk reads
instead. Everything outside the markers is preserved byte for byte.
The sync is idempotent: the block is replaced in place when present,
appended when absent, and when the result equals what is on disk nothing is
written at all (reported as unchanged). Safe in a loop, in a script, in an
agent's retry.
A file containing varlock( is ciphertext; appending plaintext next to it would
corrupt it, so sail skips that file — per file, so an encrypted .env.local
still leaves .env.test.local synced. You get a warning pointing at
node ace sail:info --env; sail:up still succeeds, sail:sync-env exits 1. See
varlock.
sail:sync-env
sail:up runs the same sync, so most days you never call this one. It is the
standalone, docker-free primitive for when the ports on disk drift from the
ports sail would compute:
node ace sail:sync-env # or --jsonReach for it after a git worktree add into a checkout whose .env.local came
along for the ride, after renaming the worktree directory (the name is the hash
input, so the offset moves), after renaming the app in package.json (which
moves the project name, and therefore which stack the app talks to), after
enabling a new service with sail:install, or after any manual edit that
clobbered the managed block.
It touches no containers and never starts docker, so it is safe while the stack is down; it fails with a hint when no services are enabled yet. Per-flag detail lives in commands.
The sail:up port preflight
Before calling docker compose up, sail:up probes the host ports the stack
wants with a short TCP connect to 127.0.0.1:<port> each — a closed port is
simply free, never an error. Ports the stack's own running containers already
publish (read from docker compose ps) are subtracted, so re-running up on
a live stack stays a silent success.
Anything left is foreign. Sail maps those ports to container names via
docker ps --format json and fails before touching compose:
Cannot start: host port(s) already in use: :5621 (held by shop-main-postgres-1)
Stop the other stack (`node ace sail:down` in that worktree), pick a worktree
with free ports, or run `node ace sail:prune` for orphansThe alternative is worse than a failed command: the app would connect straight through to another worktree's database and migrate it. Three details:
- the holder lookup is best-effort — a port held by a non-docker process still
fails the preflight, just without a name (
:5621); - reading the stack's own published ports is fail-closed: if
docker compose pserrors, every taken port counts as foreign; - the probe is scoped to the services your
compose.ymldeclares, so an unrelated MySQL on3306never blocks a postgres-only stack; when the compose file cannot be read there is nothing to probe and the preflight is skipped.
sail:prune
Deleting a worktree directory does not stop its containers — git worktree remove knows nothing about docker — and even a worktree you sail:down-ed
first leaves its named volumes on disk. sail:prune finds the leftovers:
node ace sail:prune --dry-run # list stacks and volumes, change nothing
node ace sail:prune # stop the stacks
node ace sail:prune --volumes # and delete their dataIt lists every compose project on the daemon, stopped ones included
(docker compose ls --all --format json, both the JSON-array and the NDJSON
shape) and keeps one only when both hold:
- It belongs to this app — the project name equals the sanitized app name
or starts with
appName-. Forshop, that matchesshopandshop-feature-loginbut notshopify; other apps' projects are never candidates, however abandoned they look. - Every compose file it reports is gone from disk. A project reporting no config files at all is left alone: with no path there is nothing to verify, and guessing would mean stopping a live stack.
Matches are stopped with a project-scoped, file-less
docker compose --project-name <name> down --remove-orphans — the compose file
is gone, so it cannot be passed — plus --volumes when asked. Failures are
reported per project and set a non-zero exit code.
Volumes are found separately, from their com.docker.compose.project label,
because they outlive containers. A volume is an orphan when its project is an
orphan stack, or when no container of that project is left, its project
matches none of the worktrees git worktree list still knows (computed the
same way sail:up names them), and it is one of sail's own volumes
(sail-postgres, sail-redis, …) — the project name alone cannot tell a
shop worktree named api from another app called shop-api. When git
cannot list the worktrees, only the orphan stacks' volumes count.
Volumes are deleted only with --volumes, the same opt-in as
sail:down --volumes: a worktree re-created under the same name picks its old
data back up otherwise. Without it, prune ends with
Kept N volume(s) of deleted worktrees: … — re-run with --volumes to delete them;
--dry-run lists them either way.
prune derives the app name from the checkout you run it in, so renaming the
app in package.json makes stacks created under the old name stop being
"managed" — clean those up once with docker compose --project-name <old> down.
Rules of thumb
- Never hardcode
5432(or6379, or8025) outside the main checkout. The number is a function of your directory name. - Ask, don't guess.
node ace sail:infois the source of truth,--jsonfor machines. See agents. - One stack per worktree.
sail:downwhen you are done with a worktree,sail:prunewhen you deleted the directory first. - Let sail own the env block. Edit around
# sail:start/# sail:end, never inside — the nextsail:uprewrites it. - Name worktree directories meaningfully. The name is the hash input, the project suffix, and what shows up in preflight failures.
Services
The services sail can run — images, ports, credentials, healthchecks and the env keys each one owns, the four object storage backends — plus the anatomy and merge rules of the generated compose.yml.
Serving
sail:serve runs the app in the background once per worktree — started by whoever comes first, reused by everyone else, stopped by whoever finishes.