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:

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

Set status manually for any workflow:

wt config state marker set &quot;🚧&quot;                   # Current branch
wt config state marker set &quot;&quot; --branch feature  # Specific branch
git config worktrunk.state.feature.marker '{&quot;marker&quot;:&quot;💬&quot;,&quot;set_at&quot;: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 &quot;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.'&quot;

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:

  1. wt switch --create feature-auth runs the post-start hook, starting the dev server on a deterministic port ({{ branch | hash_port }} → 16460)
  2. The hook starts Caddy if needed and registers a route using the same port: feature-auth.myprojectlocalhost:16460
  3. *.localhost resolves to 127.0.0.1 via the OS
  4. 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 &quot;$(wt config state logs get --hook=user:post-start:server)&quot;

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 &quot;$(wt config state logs get --hook=&quot;$1&quot;)&quot;; }; 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/.