wt hook
Run configured hooks.
Hooks are shell commands that run at key points in the worktree lifecycle — automatically during wt switch, wt merge, & wt remove, or on demand via wt hook <type>. Both user (~/.config/worktrunk/config.toml) and project (.config/wt.toml) hooks are supported.
Hook Types
| Event | pre- (blocking) | post- (background) |
|---|---|---|
| start | pre-start | post-start |
| switch | pre-switch | post-switch |
| commit | pre-commit | post-commit |
| merge | pre-merge | post-merge |
| remove | pre-remove | post-remove |
pre-* hooks block — failure aborts the operation. post-* hooks run in the background with output logged to .git/wt/logs/{branch}-{source}-{hook}-{name}.log. Use -v to see expanded command details for background hooks.
The most common starting point is post-start — it runs background tasks (dev servers, file copying, builds) when creating a worktree.
pre-switch
Runs before every wt switch — before branch resolution or worktree creation. {{ branch }} is the destination branch argument as the user typed it (before resolution).
[pre-switch]
# Pull if last fetch was more than 6 hours ago
pull = """
FETCH_HEAD="$(git rev-parse --git-common-dir)/FETCH_HEAD"
if [ "$(find "$FETCH_HEAD" -mmin +360 2>/dev/null)" ] || [ ! -f "$FETCH_HEAD" ]; then
git pull
fi
"""pre-start
Tasks that must complete before post-start hooks or --execute run: dependency installation, environment file generation.
[pre-start]
install = "npm ci"
env = "echo 'PORT={{ branch | hash_port }}' > .env.local"post-start
Dev servers, long builds, file watchers, copying caches.
[post-start]
copy = "wt step copy-ignored"
server = "npm run dev -- --port {{ branch | hash_port }}"post-switch
Triggers on all switch results: creating new worktrees, switching to existing ones, or staying on current.
[post-switch]
tmux = "[ -n \"$TMUX\" ] && tmux rename-window {{ branch | sanitize }}"pre-commit
Formatters, linters, type checking — runs during wt merge before the squash commit.
[pre-commit]
format = "cargo fmt -- --check"
lint = "cargo clippy -- -D warnings"post-commit
CI triggers, notifications, background linting.
[post-commit]
notify = "curl -s https://ci.example.com/trigger?branch={{ branch }}"pre-merge
Tests, security scans, build verification — runs after rebase, before merge to target.
[pre-merge]
test = "cargo test"
build = "cargo build --release"post-merge
Deployment, notifications, installing updated binaries. Runs in the target branch worktree if it exists, otherwise the primary worktree.
post-merge = "cargo install --path ."pre-remove
Cleanup tasks before worktree is deleted, saving test artifacts, backing up state. Runs in the worktree being removed, with access to worktree files.
[pre-remove]
archive = "tar -czf ~/.wt-logs/{{ branch }}.tar.gz test-results/ logs/ 2>/dev/null || true"post-remove
Stopping dev servers, removing containers, notifying external systems. Template variables reference the removed worktree, so cleanup scripts can identify resources to tear down.
[post-remove]
kill-server = "lsof -ti :{{ branch | hash_port }} -sTCP:LISTEN | xargs kill 2>/dev/null || true"
remove-db = "docker stop {{ repo }}-{{ branch | sanitize }}-postgres 2>/dev/null || true"
During wt merge, hooks run in this order: pre-commit → post-commit (background) → pre-merge → pre-remove → post-remove + post-merge (background). See wt merge for the complete pipeline.
Security
Project commands require approval on first run:
▲ repo needs approval to execute 3 commands:
○ pre-start install:
echo 'Installing dependencies...'
❯ Allow and remember? [y/N]
- Approvals are saved to user config (
~/.config/worktrunk/config.toml) - If a command changes, new approval is required
- Use
--yesto bypass prompts (useful for CI/automation) - Use
--no-verifyto skip hooks
Manage approvals with wt hook approvals add and wt hook approvals clear.
Configuration
Hooks can be defined in project config (.config/wt.toml) or user config (~/.config/worktrunk/config.toml). Both use the same format — a single command or multiple named commands:
# Single command (string)
pre-start = "npm install"
# Multiple commands (table)
[pre-merge]
test = "cargo test"
build = "cargo build --release"
For pre-* hooks, commands in a table run sequentially. For post-* hooks, they run concurrently in the background. Post-* hooks that need ordering guarantees can use pipeline ordering.
Project vs user hooks
| Aspect | Project hooks | User hooks |
|---|---|---|
| Location | .config/wt.toml | ~/.config/worktrunk/config.toml |
| Scope | Single repository | All repositories (or per-project) |
| Approval | Required | Not required |
| Execution order | After user hooks | First |
Skip all hooks with --no-verify. To run a specific hook when user and project both define the same name, use user:name or project:name syntax.
Template variables
Hooks can use template variables that expand at runtime:
| Variable | Description |
|---|---|
{{ branch }} | Active branch name |
{{ worktree_path }} | Active worktree path |
{{ worktree_name }} | Active worktree directory name |
{{ commit }} | Active branch HEAD SHA |
{{ short_commit }} | Active branch HEAD SHA (7 chars) |
{{ upstream }} | Active branch upstream (if tracking a remote) |
{{ base }} | Base branch name |
{{ base_worktree_path }} | Base worktree path |
{{ target }} | Target branch name |
{{ target_worktree_path }} | Target worktree path |
{{ cwd }} | Directory where the hook command runs |
{{ repo }} | Repository directory name |
{{ repo_path }} | Absolute path to repository root |
{{ primary_worktree_path }} | Primary worktree path |
{{ default_branch }} | Default branch name |
{{ remote }} | Primary remote name |
{{ remote_url }} | Remote URL |
{{ hook_type }} | Hook type being run (e.g. pre-start, pre-merge) |
{{ hook_name }} | Hook command name (if named) |
Bare variables (branch, worktree_path, commit) refer to the branch the operation acts on: the destination for switch/create, the source for merge/remove. base and target give the other side:
| Operation | Bare vars | base | target |
|---|---|---|---|
| switch/create | destination | where you came from | = bare vars |
| merge | feature being merged | = bare vars | merge target |
| remove | branch being removed | = bare vars | where you end up |
Pre and post hooks share the same perspective — {{ branch | hash_port }} produces the same port in post-start and post-remove. cwd is the worktree root where the hook command runs. It differs from worktree_path in three cases: pre-switch (hook runs in the source, worktree_path is the destination), post-remove (active worktree is gone, hook runs in primary), and post-merge with removal (same — active is gone, hook runs in target).
Some variables are conditional: upstream requires remote tracking; base/target are only in two-worktree hooks. Undefined variables error — use conditionals:
[pre-start]
# Rebase onto upstream if tracking a remote branch (e.g., wt switch --create feature origin/feature)
sync = "{% if upstream %}git fetch && git rebase {{ upstream }}{% endif %}"Migration from earlier versions
worktree_path changed meaning in two hook types:
- pre-switch (existing worktrees): previously the source worktree, now the destination. Use
{{ base_worktree_path }}or{{ cwd }}for the source. - post-merge: previously the merge target worktree, now the feature worktree (active). Use
{{ target_worktree_path }}or{{ cwd }}for where code landed.
New variables cwd, target_worktree_path, base, and base_worktree_path are available in more hook types than before.
Worktrunk filters
Templates support Jinja2 filters for transforming values:
| Filter | Example | Description |
|---|---|---|
sanitize | {{ branch | sanitize }} | Replace / and \ with - |
sanitize_db | {{ branch | sanitize_db }} | Database-safe identifier with hash suffix ([a-z0-9_], max 63 chars) |
hash_port | {{ branch | hash_port }} | Hash to port 10000-19999 |
The sanitize filter makes branch names safe for filesystem paths. The sanitize_db filter produces database-safe identifiers (lowercase alphanumeric and underscores, no leading digits, with a 3-character hash suffix to avoid collisions and reserved words). The hash_port filter is useful for running dev servers on unique ports per worktree:
[post-start]
dev = "npm run dev -- --host {{ branch }}.localhost --port {{ branch | hash_port }}"
Hash any string, including concatenations:
# Unique port per repo+branch combination
dev = "npm run dev --port {{ (repo ~ '-' ~ branch) | hash_port }}"
Variables are shell-escaped automatically — quotes around {{ ... }} are unnecessary and can cause issues with special characters.
Worktrunk functions
Templates also support functions for dynamic lookups:
| Function | Example | Description |
|---|---|---|
worktree_path_of_branch(branch) | {{ worktree_path_of_branch("main") }} | Look up the path of a branch's worktree |
The worktree_path_of_branch function returns the filesystem path of a worktree given a branch name, or an empty string if no worktree exists for that branch. This is useful for referencing files in other worktrees:
[pre-start]
# Copy config from main worktree
setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}"JSON context
Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express:
[pre-start]
setup = "python3 scripts/pre-start-setup.py"import json, sys, subprocess
ctx = json.load(sys.stdin)
if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']:
subprocess.run(['make', 'seed-db'])Running Hooks Manually
wt hook <type> runs hooks on demand — useful for testing during development, running in CI pipelines, or re-running after a failure.
wt hook pre-merge # Run all pre-merge hooks
wt hook pre-merge test # Run hooks named "test" from both sources
wt hook pre-merge user: # Run all user hooks
wt hook pre-merge project: # Run all project hooks
wt hook pre-merge user:test # Run only user's "test" hook
wt hook pre-merge project:test # Run only project's "test" hook
wt hook pre-merge --yes # Skip approval prompts (for CI)
wt hook pre-start --var branch=feature/test # Override template variable
The user: and project: prefixes filter by source. Use user: or project: alone to run all hooks from that source, or user:name / project:name to run a specific hook.
The --var KEY=VALUE flag overrides built-in template variables — useful for testing hooks with different contexts without switching to that context.
Pipeline Ordering
By default, all commands in a post-* hook run concurrently in the background. When one command depends on another — npm run build needs npm install to finish first — use a list instead of a table:
[hooks]
post-start = [
{ install = "npm install" },
{ build = "npm run build", lint = "npm run lint" }
]
The list runs steps in order. Each step is either a string (single command) or a map (named commands). Single-entry maps run one command; multi-entry maps run their commands concurrently. The TOML data structure encodes the execution model directly:
- String — single command
- Map (table) — concurrent commands
- List (array) — serial pipeline
The entire pipeline runs in the background as one process. Anonymous steps work too:
post-start = ["npm install", "npm run build"]How it works
Steps are chained with && in a compound shell command, so a failing step skips all later steps. A multi-entry map spawns its commands as background processes and waits for all to complete before the next step.
For the example above, the generated command is:
{ npm install; } && { { npm run build; } & { npm run lint; } & wait; }
Pre-* hooks ignore pipeline structure — all commands run serially regardless, since pre-* hooks are blocking by nature.
When to use pipelines
Most hooks don't need pipelines. A table of concurrent post-start commands is fine when they're independent:
[post-start]
server = "npm run dev -- --port {{ branch | hash_port }}"
copy = "wt step copy-ignored"
Pipelines matter when there's a dependency chain — typically setup steps that must complete before other tasks can start. Common pattern: install dependencies, then run build + dev server concurrently.
Designing Effective Hooks
post-start vs pre-start
Both run when creating a worktree. The difference:
| Hook | Execution | Best for |
|---|---|---|
post-start | Background, parallel | Long-running tasks that don't block worktree creation |
pre-start | Blocks until complete | Tasks the developer needs before working (dependency install) |
Many tasks work well in post-start — they'll likely be ready by the time they're needed, especially when the fallback is recompiling. If unsure, prefer post-start for faster worktree creation.
Background processes spawned by post-start outlive the worktree — pair them with post-remove hooks to clean up. See Dev servers and Databases for examples.
Copying untracked files
Git worktrees share the repository but not untracked files. wt step copy-ignored copies gitignored files between worktrees:
[post-start]
copy = "wt step copy-ignored"
Use pre-start instead if subsequent hooks need the copied files — for example, copying node_modules/ before pnpm install so the install reuses cached packages:
[pre-start]
copy = "wt step copy-ignored"
install = "pnpm install"Dev servers
Run a dev server per worktree on a deterministic port using hash_port:
[post-start]
server = "npm run dev -- --port {{ branch | hash_port }}"
[post-remove]
server = "lsof -ti :{{ branch | hash_port }} -sTCP:LISTEN | xargs kill 2>/dev/null || true"
The port is stable across machines and restarts — feature-api always gets the same port. Show it in wt list:
[list]
url = "http://localhost:{{ branch | hash_port }}"
For subdomain-based routing (useful for cookies/CORS), use .localhost subdomains which resolve to 127.0.0.1:
[post-start]
server = "npm run dev -- --host {{ branch | sanitize }}.localhost --port {{ branch | hash_port }}"Databases
Each worktree can have its own database. Docker containers get unique names and ports:
[post-start]
db = """
docker run -d --rm \
--name {{ repo }}-{{ branch | sanitize }}-postgres \
-p {{ ('db-' ~ branch) | hash_port }}:5432 \
-e POSTGRES_DB={{ branch | sanitize_db }} \
-e POSTGRES_PASSWORD=dev \
postgres:16
"""
[post-remove]
db-stop = "docker stop {{ repo }}-{{ branch | sanitize }}-postgres 2>/dev/null || true"
The ('db-' ~ branch) concatenation hashes differently than plain branch, so database and dev server ports don't collide.
Jinja2's operator precedence has pipe | with higher precedence than concatenation ~, meaning expressions need parentheses to filter concatenated values.
Generate .env.local with the connection string:
[pre-start]
env = """
cat > .env.local << EOF
DATABASE_URL=postgres://postgres:dev@localhost:{{ ('db-' ~ branch) | hash_port }}/{{ branch | sanitize_db }}
DEV_PORT={{ branch | hash_port }}
EOF
"""Progressive validation
Quick checks before commit, thorough validation before merge:
[pre-commit]
lint = "npm run lint"
typecheck = "npm run typecheck"
[pre-merge]
test = "npm test"
build = "npm run build"Target-specific behavior
Different actions for production vs staging:
post-merge = """
if [ {{ target }} = main ]; then
npm run deploy:production
elif [ {{ target }} = staging ]; then
npm run deploy:staging
fi
"""Python virtual environments
Use uv sync to recreate virtual environments (or python -m venv .venv && .venv/bin/pip install -r requirements.txt for pip-based projects):
[pre-start]
install = "uv sync"
For copying dependencies and caches between worktrees, see wt step copy-ignored.
See also
wt merge— Runs hooks automatically during mergewt switch— Runs pre-start/post-start hooks on--createwt config— Manage hook approvalswt config state logs— Access background hook logs
Command reference
wt hook - Run configured hooks
Usage: wt hook [OPTIONS] <COMMAND>
Commands:
show Show configured hooks
pre-switch Run pre-switch hooks
pre-start Run pre-start hooks
post-start Run post-start hooks
post-switch Run post-switch hooks
pre-commit Run pre-commit hooks
post-commit Run post-commit hooks
pre-merge Run pre-merge hooks
post-merge Run post-merge hooks
pre-remove Run pre-remove hooks
post-remove Run post-remove hooks
approvals Manage command approvals
Options:
-h, --help
Print help (see a summary with '-h')
Global Options:
-C <path>
Working directory for this command
--config <path>
User config file path
-v, --verbose...
Verbose output (-v: hooks, templates; -vv: debug report)
Subcommands
wt hook approvals
Manage command approvals.
Project hooks require approval on first run to prevent untrusted projects from running arbitrary commands.
Examples
Pre-approve all commands for current project:
wt hook approvals add
Clear approvals for current project:
wt hook approvals clear
Clear global approvals:
wt hook approvals clear --globalHow approvals work
Approved commands are saved to ~/.config/worktrunk/approvals.toml. Re-approval is required when the command template changes or the project moves. Use --yes to bypass prompts in CI.
Command reference
wt hook approvals - Manage command approvals
Usage: wt hook approvals [OPTIONS] <COMMAND>
Commands:
add Store approvals in approvals.toml
clear Clear approved commands from approvals.toml
Options:
-h, --help
Print help (see a summary with '-h')
Global Options:
-C <path>
Working directory for this command
--config <path>
User config file path
-v, --verbose...
Verbose output (-v: hooks, templates; -vv: debug report)