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

Eventpre- (blocking)post- (background)
startpre-startpost-start
switchpre-switchpost-switch
commitpre-commitpost-commit
mergepre-mergepost-merge
removepre-removepost-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]

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

AspectProject hooksUser hooks
Location.config/wt.toml~/.config/worktrunk/config.toml
ScopeSingle repositoryAll repositories (or per-project)
ApprovalRequiredNot required
Execution orderAfter user hooksFirst

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:

VariableDescription
{{ 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:

OperationBare varsbasetarget
switch/createdestinationwhere you came from= bare vars
mergefeature being merged= bare varsmerge target
removebranch being removed= bare varswhere 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:

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:

FilterExampleDescription
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:

FunctionExampleDescription
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:

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:

HookExecutionBest for
post-startBackground, parallelLong-running tasks that don't block worktree creation
pre-startBlocks until completeTasks 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

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 --global

How 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)