Tips & Patterns
Practical recipes for common Worktrunk workflows.
Shell alias for new worktree + agent
Create a worktree and launch Claude in one command:
alias wsc='wt switch --create --execute=claude'
wsc new-feature # Creates worktree, runs hooks, launches Claude
wsc feature -- 'Fix GH #322' # Runs `claude 'Fix GH #322'`
wt aliases
Compose with template filters and vars for branch-specific shortcuts:
# .config/wt.toml
[aliases]
# Open this worktree's dev server
open = "open http://localhost:{{ branch | hash_port }}"
# Test with branch-specific features from vars
test = "cargo test --features {{ vars.features | default('default') }}"
See Aliases for scoping, approval, and reference.
Per-branch variables
wt config state vars holds state per branch, accessible from templates ({{ vars.key }}) and the CLI. Some uses:
- Coordinate state across pipeline steps — see Database per worktree below for a full recipe
- Stick a branch to an environment —
wt config state vars set env=staging, then{{ vars.env | default('dev') }}in hooks - Parametrize aliases per branch — see
wtaliases above
See wt config state vars for storage format, JSON support, and reference.
Dev server per worktree
Each worktree runs its own dev server on a deterministic port. The hash_port filter generates a stable port (10000-19999) from the branch name:
# .config/wt.toml
[post-start]
server = "npm run dev -- --port {{ branch | hash_port }}"
[list]
url = "http://localhost:{{ branch | hash_port }}"
[pre-remove]
server = "lsof -ti :{{ branch | hash_port }} -sTCP:LISTEN | xargs kill 2>/dev/null || true"
The URL column in wt list shows each worktree's dev server:
wt list
Branch Status HEAD± main↕ Remote⇅ URL Commit Age
@ main ? ^⇅ ⇡1 ⇣1 http://localhost:12107 41ee0834 4d
+ feature-api + ↕⇡ +54 -5 ↑4 ↓1 ⇡3 http://localhost:10703 6814f02a 30m
+ fix-auth ↕| ↑2 ↓1 | http://localhost:16460 b772e68b 5h
+ fix-typos _| | http://localhost:14301 41ee0834 4d
○ Showing 4 worktrees, 2 with changes, 2 ahead, 2 columns hidden
Ports are deterministic — fix-auth always gets port 16460, regardless of which machine or when. The URL dims if the server isn't running.
Database per worktree
Each worktree can have its own isolated database. A pipeline sets up names and ports as vars, then later steps and hooks reference them:
[[post-start]]
set-vars = """
wt config state vars set \
container='{{ repo }}-{{ branch | sanitize }}-postgres' \
port='{{ ('db-' ~ branch) | hash_port }}' \
db_url='postgres://postgres:dev@localhost:{{ ('db-' ~ branch) | hash_port }}/{{ branch | sanitize_db }}'
"""
[[post-start]]
db = """
docker run -d --rm \
--name {{ vars.container }} \
-p {{ vars.port }}:5432 \
-e POSTGRES_DB={{ branch | sanitize_db }} \
-e POSTGRES_PASSWORD=dev \
postgres:16
"""
[pre-remove]
db-stop = "docker stop {{ vars.container }} 2>/dev/null || true"
The first pipeline step derives values from the branch and stores them as vars. The second step references {{ vars.container }} and {{ vars.port }} — expanded at execution time, after the vars are set. post-remove reads the same vars to stop the container.
The ('db-' ~ branch) concatenation hashes differently than plain branch, so database and dev server ports don't collide. The sanitize_db filter produces database-safe identifiers (lowercase, underscores, no leading digits, with a short hash suffix).
The connection string is accessible anywhere — not just in hooks:
DATABASE_URL=$(wt config state vars get db_url) npm start
Eliminate cold starts
Use wt step copy-ignored to copy gitignored files (caches, dependencies, .env) between worktrees:
[post-start]
copy = "wt step copy-ignored"
When another hook depends on the copy — for example, copying node_modules/ before pnpm install so the install reuses cached packages — sequence them with a [[post-start]] pipeline:
[[post-start]]
copy = "wt step copy-ignored"
[[post-start]]
install = "pnpm install"
Use pre-start instead when an --execute command needs the copied files immediately.
All gitignored files are copied by default. To limit what gets copied, create .worktreeinclude with patterns — files must be both gitignored and listed. See wt step copy-ignored for details.
Manual commit messages
The commit.generation.command receives the rendered prompt on stdin and returns the commit message on stdout. To write commit messages by hand instead of using an LLM, point it at $EDITOR:
# ~/.config/worktrunk/config.toml
[commit.generation]
command = '''f=$(mktemp); printf '\n\n' > "$f"; sed 's/^/# /' >> "$f"; ${EDITOR:-vi} "$f" < /dev/tty > /dev/tty; grep -v '^#' "$f"'''
This comments out the rendered prompt (diff, branch name, stats) with # prefixes, opens your editor, and strips comment lines on save. A couple of blank lines at the top give you space to type; the prompt context is visible below for reference.
To keep the LLM as default but use the editor for a specific merge, add a worktrunk alias:
# ~/.config/worktrunk/config.toml
[aliases]
mc = '''WORKTRUNK_COMMIT__GENERATION__COMMAND='f=$(mktemp); printf "\n\n" > "$f"; sed "s/^/# /" >> "$f"; ${EDITOR:-vi} "$f" < /dev/tty > /dev/tty; grep -v "^#" "$f"' wt merge'''
Then wt mc opens an editor for the commit message while plain wt merge continues to use the LLM.
Track agent status
Custom emoji markers show agent state in wt list. The Claude Code plugin and OpenCode plugin set these automatically:
+ feature-api ↑ 🤖 ↑1 ./repo.feature-api
+ review-ui ? ↑ 💬 ↑1 ./repo.review-ui
🤖— Agent is working💬— Agent is waiting for input
Set status manually for any workflow:
wt config state marker set "🚧" # Current branch
wt config state marker set "✅" --branch feature # Specific branch
git config worktrunk.state.feature.marker '{"marker":"💬","set_at":0}' # Direct
See Claude Code Integration for plugin installation.
Monitor CI across branches
wt list --full --branches
Shows PR/CI status for all branches, including those without worktrees. CI indicators are clickable links to the PR page.
LLM branch summaries
With summary = true and commit.generation configured, wt list --full shows an LLM-generated one-line summary for each branch. The same summaries appear in the wt switch picker (tab 5).
# ~/.config/worktrunk/config.toml
[list]
summary = true
See LLM Commits for details.
JSON API
wt list --format=json
Structured output for dashboards, statuslines, and scripts. See wt list for query examples.
Reuse default-branch
Worktrunk maintains useful state. Default branch detection, for instance, means scripts work on any repo — no need to hardcode main or master:
git rebase $(wt config state default-branch)
Task runners in hooks
Reference Taskfile/Justfile/Makefile in hooks:
[pre-start]
"setup" = "task install"
[pre-merge]
"validate" = "just test lint"Progressive validation
Split checks across hook types — quick feedback before each commit, expensive suites before merge:
[[pre-commit]]
lint = "npm run lint"
typecheck = "npm run typecheck"
[[pre-merge]]
test = "npm test"
build = "npm run build"
pre-commit runs on every squash commit during wt merge; pre-merge runs once per merge after the rebase, so it's the right place for the slow tests.
Target-specific hooks
Branch on {{ target }} to vary behavior per merge destination — for example, deploying to production from main and staging from a release branch:
post-merge = """
if [ {{ target }} = main ]; then
npm run deploy:production
elif [ {{ target }} = staging ]; then
npm run deploy:staging
fi
"""
{{ target }} is the branch being merged into. post-merge runs in the target's worktree (or the primary worktree if target has none), so deploy commands see the merged code.
Shortcuts
Special arguments work across all commands—see wt switch for the full list.
wt switch --create hotfix --base=@ # Branch from current HEAD
wt switch - # Switch to previous worktree
wt remove @ # Remove current worktree
Stacked branches
Branch from current HEAD instead of the default branch:
wt switch --create feature-part2 --base=@
Agent handoffs
Spawn a worktree with an agent CLI running in the background. Examples below use claude; for OpenCode, replace claude with 'opencode run'.
tmux (new detached session):
tmux new-session -d -s fix-auth-bug "wt switch --create fix-auth-bug -x claude -- \
'The login session expires after 5 minutes. Find the session timeout config and extend it to 24 hours.'"
Zellij (new pane in current session):
zellij run -- wt switch --create fix-auth-bug -x claude -- \
'The login session expires after 5 minutes. Find the session timeout config and extend it to 24 hours.'
This lets one agent session hand off work to another that runs in the background. Hooks run inside the multiplexer session/pane.
The worktrunk skill includes guidance for Claude Code (and other agent CLIs that load it) to execute this pattern. To enable it, request it explicitly ("spawn a parallel worktree for...") or add to your project instructions (CLAUDE.md or AGENTS.md):
When I ask you to spawn parallel worktrees, use the agent handoff pattern
from the worktrunk skill.Tmux session per worktree
Each worktree gets its own tmux session with a multi-pane layout.
# .config/wt.toml
[pre-start]
tmux = """
S={{ branch | sanitize }}
W={{ worktree_path }}
tmux new-session -d -s "$S" -c "$W" -n dev
# Create 4-pane layout: shell | backend / claude | frontend
tmux split-window -h -t "$S:dev" -c "$W"
tmux split-window -v -t "$S:dev.0" -c "$W"
tmux split-window -v -t "$S:dev.2" -c "$W"
# Start services in each pane
tmux send-keys -t "$S:dev.1" 'npm run backend' Enter
tmux send-keys -t "$S:dev.2" 'claude' Enter
tmux send-keys -t "$S:dev.3" 'npm run frontend' Enter
tmux select-pane -t "$S:dev.0"
echo "✓ Session '$S' — attach with: tmux attach -t $S"
"""
[pre-remove]
tmux = "tmux kill-session -t {{ branch | sanitize }} 2>/dev/null || true"
To create a worktree and immediately attach:
wt switch --create feature -x 'tmux attach -t {{ branch | sanitize }}'
Xcode DerivedData cleanup
Clean up Xcode's DerivedData when removing a worktree. Each DerivedData directory contains an info.plist recording its project path — grep for the worktree path to find and remove the matching build cache:
# ~/.config/worktrunk/config.toml
[post-remove]
clean-derived = """
grep -Fl {{ worktree_path }} \
~/Library/Developer/Xcode/DerivedData/*/info.plist 2>/dev/null \
| while read plist; do
derived_dir=$(dirname "$plist")
rm -rf "$derived_dir"
echo "Cleaned DerivedData: $derived_dir"
done
"""
This precisely targets only the DerivedData for the removed worktree, leaving caches for other worktrees and the main repository intact.
Subdomain routing with Caddy
Clean URLs like http://feature-auth.myproject.localhost without port numbers. Useful for cookies, CORS, and matching production URL structure.
Prerequisites: Caddy (brew install caddy)
# .config/wt.toml
[post-start]
server = "npm run dev -- --port {{ branch | hash_port }}"
proxy = """
curl -sf --max-time 0.5 http://localhost:2019/config/ || caddy start
curl -sf http://localhost:2019/config/apps/http/servers/wt || \
curl -sfX PUT http://localhost:2019/config/apps/http/servers/wt -H 'Content-Type: application/json' \
-d '{"listen":[":8080"],"automatic_https":{"disable":true},"routes":[]}'
curl -sf -X DELETE http://localhost:2019/id/wt:{{ repo }}:{{ branch | sanitize }} || 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 }}.{{ repo }}.localhost"]}],"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"127.0.0.1:{{ branch | hash_port }}"}]}]}'
"""
[pre-remove]
proxy = "curl -sf -X DELETE http://localhost:2019/id/wt:{{ repo }}:{{ branch | sanitize }} || true"
[list]
url = "http://{{ branch | sanitize }}.{{ repo }}.localhost:8080"
How it works:
wt switch --create feature-authruns thepost-starthook, starting the dev server on a deterministic port ({{ branch | hash_port }}→ 16460)- The hook starts Caddy if needed and registers a route using the same port:
feature-auth.myproject→localhost:16460 *.localhostresolves to127.0.0.1via the OS- Visiting
http://feature-auth.myproject.localhost:8080: Caddy matches the subdomain and proxies to the dev server
Monitor hook logs
Follow background hook output in real-time:
tail -f "$(wt config state logs get --hook=user:post-start:server)"
The --hook format is source:hook-type:name — e.g., project:post-start:build for project-defined hooks. Use wt config state logs get to list all available logs.
Create an alias for frequent use:
alias wtlog='f() { tail -f "$(wt config state logs get --hook="$1")"; }; f'
Bare repository layout
A bare repository has no working tree, so all branches — including the default — are linked worktrees at equal paths. No branch gets special treatment.
Cloning a bare repo into <project>/.git puts all worktrees under one directory:
git clone --bare <url> myproject/.git
cd myproject
With worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}", worktrees become subdirectories of myproject/:
myproject/
├── .git/ # bare repository
├── main/ # default branch
├── feature/ # feature branch
└── bugfix/ # bugfix branch
Configure worktrunk:
# ~/.config/worktrunk/config.toml
worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}"
Create the first worktree:
wt switch --create main
Now wt switch --create feature creates myproject/feature/.