Git worktree with local subdomains
tl;dr: git worktrees let you run several coding agents in parallel. Use worktrunk to give each worktree its own dev server with a subdomain and https.
Note: examples below use a docker compose setup. Adjust as needed to your stack.
Prerequisites:
Worktrunk
Worktrunk (wt) is a cli wrapper around git worktrees with hooks mechanism. The main config file is .config/wt.toml in the root of your project.
The workflow with worktrunk is described here, and looks like this:
# create new worktree "feature" from "main" branch
wt switch -c "feature"
# edit code... then merge changes into "main" branch
wt merge
# or
wt remove "feature"
Copying .env and other untracked files
Problem: when creating a git worktree, only tracked files are copied. But running a project often requires files from .gitignore. E.g. .env, or your coding agent's local config, or other common patterns.
Use the wt step copy-ignored command with flag --require-include and create file .worktreeinclude.
Track .config/wt.toml and .worktreeinclude files in git.
# .config/wt.toml
[[pre-start]]
copy = "wt step copy-ignored --require-include"
# .worktreeinclude
.env
.claude/settings.local.json
Running wt switch -c "feature" now will bring .env and the other specified files into the worktree folder.
Running the project and cleanup on removal
Problem: after removing a worktree, a project's processes and artifacts can be left behind.
Read about dev server and wt step tether for an example of a dev server that automatically starts with a new worktree and stops when the worktree is removed.
Adjust command to starting a project and to turning it off. E.g. when I work with docker, I start the project manually when it's needed, so as not to waste RAM unnecessarily. And pre-remove hook is used for cleaning up artifacts: containers, images, volumes.
# .config/wt.toml
[[pre-commit]]
lint = "ruff check --fix ."
[[pre-remove]]
remove-containers = "cd {{worktree_path}} && docker compose --env-file .env --env-file .env.local -f docker-compose.yml -f docker-compose.local.yml down -v --rmi local"
[aliases]
up = "docker compose --env-file .env --env-file .env.local -f docker-compose.yml -f docker-compose.local.yml up --build -d"
The wt up alias starts the project. And when work on the worktree is done, call wt merge. At this point linters are run. And if checks pass successfully and there are no conflicts with the main branch, code changes get merged into the main branch. The running project gets stopped, and volumes and images are removed.
Hint: the pre-commit hook is a good place to call linters and other validations.
Worktree-specific variables
Problem: a typical web project needs to listen on a port. Each worktree needs a new port and new variables. To avoid touching the original
.env, write new values into .env.local.
# .config/wt.toml
[[post-start]]
env-overlay = """
cat > {{worktree_path}}/.env.local <<EOF
CORS_ALLOWED_ORIGINS=https://{{ branch | sanitize | replace('_', '-') }}.{{ repo }}.localhost:8080
GATEWAY_PORT={{ (repo ~ '-' ~ branch) | hash_port }}
EOF
"""
hash_port provides a stable port within a single worktree. It's better to compute the port from repo + branch rather than just branch, so that branch "main" in project "A" and branch "main" in project "B" get different ports.
Note the replace('_', '-'). Branch names can contain special characters. Formally, domains can only use letters, digits and "-". So keep an eye on which special characters appear in branch names (and repo), and either avoid using them or do the replace.
Hint: docker-compose.yml doesn't automatically use variables from .env.local during interpolation.
# docker-compose.yml
services:
gateway:
ports:
# GATEWAY_PORT needs to be interpolated from .env.local
- "${GATEWAY_PORT:?set GATEWAY_PORT}:80"
So when running the command you should explicitly specify the file with variables:
docker compose --env-file .env --env-file .env.local up
Subdomain with https per worktree
Problem: I don't want to search the logs for ports or manually type the URL of the running project every time.
The documentation already describes a ready-to-use config. A subdomain from caddy + wt open alias remove the need to think about the port or the URL-building logic. wt open just opens the page.
I made a couple of changes to the original config:
- Port basis: repo + branch
- https. Don't forget to run the caddy trust command.
# .config/wt.toml
[[post-start]]
proxy = """
curl -sf --max-time 0.5 http://localhost:2019/config/ >/dev/null || caddy start
curl -sf http://localhost:2019/config/apps/http/servers/wt >/dev/null || \
curl -sfX PUT http://localhost:2019/config/apps/http/servers/wt \
-H 'Content-Type: application/json' \
-d '{"listen":[":8080"],"automatic_https":{"disable_redirects":true},"tls_connection_policies":[{}],"routes":[]}'
curl -sf -X DELETE http://localhost:2019/id/wt:{{ repo }}:{{ branch | sanitize }} >/dev/null || true
curl -sfX PUT http://localhost:2019/config/apps/http/servers/wt/routes/0 \
-H 'Content-Type: application/json' \
-d '{"@id":"wt:{{ repo }}:{{ branch | sanitize }}","match":[{"host":["{{ branch | sanitize | replace('_', '-') }}.{{ repo }}.localhost"]}],"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"127.0.0.1:{{ (repo ~ '-' ~ branch) | hash_port }}"}],"headers":{"request":{"set":{"X-Forwarded-Proto":["https"]}}}}]}'
"""
[aliases]
open = '''python -m webbrowser "https://{{ branch | sanitize | replace('_', '-') }}.{{ repo }}.localhost:8080"'''
up = "docker compose --env-file .env --env-file .env.local -f docker-compose.yml -f docker-compose.local.yml up --build -d"
[list]
url = "https://{{ branch | sanitize | replace('_', '-') }}.{{ repo }}.localhost:8080"
Hints:
- Again, note the
replace('_', '-'). - If project name is long or complicated, change
{{ repo }}to something simpler. wt listseems to truncate long lines, so sometimes url shows up in a form, like:8080. A bit confusing, but it's still clickable.- Use port 8080 instead of 80 and 443, so that caddy doesn't need permission to use ports 80 and 443 without sudo.
Manually running the proxy
Problem: wt switch -c "feature" sets up caddy and creates .env.local. But what about the main worktree? Those hooks aren't triggered there. Or what to do after reboot?!
I couldn't come up with anything better than manually calling the hooks. Usually I work in new short-lived worktrees, so it's rarely needed. But it's useful when I need to run "main" for comparison.
[aliases]
host = "wt hook post-start proxy env-overlay -y"
See also
- use the configuration skill to adjust configs for your needs.
- wt tries to generate commit messages automatically. You can disable this and write them manually.
- don't want claude code to be a co-author of your code changes? Use the
includeCoAuthoredByparameter in ~/.claude/settings.json.
Send feedback