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 and project hooks are supported.

Hook Types

Eventpre- — blockingpost- — background
switchpre-switchpost-switch
startpre-startpost-start
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 (use wt config state logs to find and manage log files). 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) without blocking worktree creation. Prefer post-start over pre-start unless a later step needs the work completed first.

HookPurpose
pre-switchRuns before branch resolution or worktree creation. {{ branch }} is the destination as typed (before resolution)
post-switchTriggers on all switch results: creating, switching to existing, or staying on current
pre-startRuns once when a new worktree is created, blocking post-start/--execute until complete: dependency install, env file generation
post-startRuns once when a new worktree is created, in the background: dev servers, long builds, file watchers, copying caches
pre-commitFormatters, linters, type checking — runs during wt merge before the squash commit
post-commitCI triggers, notifications, background linting
pre-mergeTests, security scans, build verification — runs after rebase, before merge to target
post-mergeDeployment, notifications, installing updated binaries. Runs in the target branch worktree if it exists, otherwise the primary worktree
pre-removeCleanup before worktree deletion: saving test artifacts, backing up state. Runs in the worktree being removed
post-removeStopping dev servers, removing containers, notifying external systems. Template variables reference the removed worktree

During wt merge, hooks run in this order: pre-commit → post-commit → pre-merge → pre-remove → post-remove + post-merge. As usual, post-* hooks run in the 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:
  npm ci
 pre-start build:
  cargo build --release
 pre-start env:
  echo 'PORT={{ branch | hash_port }}' > .env.local

 Allow and remember? [y/N]

Manage approvals with wt config approvals add and wt config 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.

Hook forms

Hooks take one of three forms, determined by their TOML shape.

A string is a single command:

pre-start = "npm install"

A table is multiple commands that run concurrently:

[post-start]
server = "npm run dev"
watch = "npm run watch"

A pipeline is a sequence of [[hook]] blocks run in order. Each block is one step; multiple keys within a block run concurrently. A failing step aborts the rest of the pipeline:

[[post-start]]
install = "npm ci"

[[post-start]]
build = "npm run build"
server = "npm run dev"

Here install runs first, then build and server run together.

Most hooks don't need [[hook]] blocks. Reach for them when there's a dependency chain — typically setup that must complete before later steps, like installing dependencies before running a build and dev server concurrently.

Table form for pre-* hooks is deprecated and its behavior will change in a future version — use [[hook]] blocks instead.

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-hooks. 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:

KindVariableDescription
active{{ branch }}Branch name
{{ worktree_path }}Worktree path
{{ worktree_name }}Worktree directory name
{{ commit }}Branch HEAD SHA
{{ short_commit }}Branch HEAD SHA (7 chars)
{{ upstream }}Branch upstream (if tracking a remote)
operation{{ base }}Base branch name
{{ base_worktree_path }}Base worktree path
{{ target }}Target branch name
{{ target_worktree_path }}Target worktree path
{{ pr_number }}PR/MR number (when creating via pr:N / mr:N)
{{ pr_url }}PR/MR web URL (when creating via pr:N / mr:N)
repo{{ repo }}Repository directory name
{{ repo_path }}Absolute path to repository root
{{ owner }}Primary remote owner path (may include subgroups)
{{ primary_worktree_path }}Primary worktree path
{{ default_branch }}Default branch name
{{ remote }}Primary remote name
{{ remote_url }}Remote URL
exec{{ cwd }}Directory where the hook command runs
{{ hook_type }}Hook type being run (e.g. pre-start, pre-merge)
{{ hook_name }}Hook command name (if named)
{{ args }}Tokens forwarded from the CLI — see Running Hooks Manually
user{{ vars.<key> }}Per-branch variables from wt config state vars

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
commit (during merge/squash)worktree being squashed= bare varsintegration target
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, where the hook runs in the source but worktree_path is the destination; post-remove, where the active worktree is gone so the hook runs in primary; and post-merge with removal, same — the active worktree is gone, so the hook runs in target.

Some variables are conditional: upstream requires remote tracking; base only appears in switch/create hooks; target_worktree_path requires the target to have a worktree; pr_number/pr_url are populated for post-switch, pre-start, and post-start hooks when creating via pr:N or mr:N; vars keys may not exist. Undefined variables error — use conditionals or defaults for optional behavior:

[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 %}"

Run any hook-firing command with -v to see the resolved variables for the actual invocation — each hook prints a template variables: block showing every in-scope variable and its value ((unset) for conditional vars that didn't populate, like target_worktree_path during wt switch -). Aliases do the same under -v: wt -v <alias> prints the alias's in-scope variables before the pipeline runs.

Variables use dot access and the default filter for missing keys. JSON object/array values are parsed automatically, so {{ vars.config.port }} works when the value is {"port": 3000}:

[post-start]
dev = "ENV={{ vars.env | default('development') }} npm start -- --port {{ vars.config.port | default('3000') }}"

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 48 chars)
sanitize_hash{{ branch | sanitize_hash }}Filesystem-safe name with hash suffix for uniqueness
hash{{ branch | hash }}3-character base36 digest of the input
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 sanitize_hash filter produces a filesystem-safe name and appends a 3-character hash suffix when sanitization changed the input, so distinct originals never collide — already-safe names pass through unchanged. The hash filter is the bare 3-character base36 digest, useful for composing your own truncate-with-collision-avoidance recipes when an output budget is tight (e.g., Unix socket paths capped at 107 bytes):

# Truncated branch slug + hash: collisions remain disambiguated even when prefixes match
worktree-path = "/tmp/{{ (branch | sanitize)[:20] }}_{{ branch | sanitize | hash }}"

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'])

Copying untracked files

One specific command worth calling out: wt step copy-ignored. Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees:

[post-start]
copy = "wt step copy-ignored"

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 test build   # Run hooks named "test" and "build"
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 --yes        # Skip approval prompts (for CI)
wt hook pre-start --branch=feature/test    # Override a template variable
wt hook pre-merge -- --extra args     # Forward tokens into {{ args }}

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.

wt hook pre-merge
 Running pre-merge project:test
  cargo test
    Finished test [unoptimized + debuginfo] target(s) in 0.12s
     Running unittests src/lib.rs (target/debug/deps/worktrunk-abc123)

running 18 tests
test auth::tests::test_jwt_decode ... ok
test auth::tests::test_jwt_encode ... ok
test auth::tests::test_token_refresh ... ok
test auth::tests::test_token_validation ... ok

test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s
 Running pre-merge project:lint
  cargo clippy
    Checking worktrunk v0.1.0
    Finished dev [unoptimized + debuginfo] target(s) in 1.23s
wt hook post-start
◎ Running post-start: project @ ~/acme

Passing values

--KEY=VALUE binds KEY whenever {{ KEY }} appears in any command of the hook — the same smart-routing rule wt <alias> uses. Built-in variables can be overridden: --branch=foo sets {{ branch }} inside hook templates (the worktree's actual branch doesn't move). Hyphens in keys become underscores: --my-var=x sets {{ my_var }}.

Any --KEY=VALUE whose key isn't referenced by a hook template forwards into {{ args }} as a literal --KEY=VALUE token. Tokens after -- also forward into {{ args }} verbatim. {{ args }} renders as a space-joined, shell-escaped string; index with {{ args[0] }}, loop with {% for a in args %}…{% endfor %}, count with {{ args | length }}.

The long form --var KEY=VALUE is deprecated but still supported. It force-binds regardless of whether any hook template references KEY — useful when a template only references the key conditionally (e.g. {% if override %}…{% endif %}).

Recipes

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
  post-switch  Run post-switch hooks
  pre-start    Run pre-start hooks
  post-start   Run post-start 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

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: info logs + hook/alias template variable & output; -vv: debug logs +
          diagnostic report + trace.log/output.log under .git/wt/logs/)

  -y, --yes
          Skip approval prompts