Skip to content

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.

Eventpre- — blockingpost- — background
switchpre-switchpost-switch
createpre-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); wt hook <type> --foreground runs one inline instead, so its output arrives in the terminal. Use -v to see the template variables for background hooks; wt hook <type> --dry-run previews the commands.

The most common creation hook 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 in the source worktree before switching — creating, switching to existing, or staying on current
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 before any Worktrunk commit (wt step commit, wt step squash, and the commit wt merge makes)
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, the blocking hooks run in this order: pre-commit → pre-merge → pre-remove. The post-* hooks all start together once the merge finishes, each in the worktree it is anchored on — post-merge, post-switch and post-remove in the destination, post-commit in the worktree the commit was made in. A merge that removes that worktree reports post-commit as skipped instead of running it — the worktree is gone by the time the hook would start. Use pre-remove for work that must finish there, or --no-remove to keep the worktree. See wt merge for the complete pipeline.

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]
  • Approvals are saved to ~/.config/worktrunk/approvals.toml
  • If a command changes, new approval is required
  • Declining skips every project command for that operation — including any already approved — and continues without them; saved approvals are unaffected
  • Use --yes to bypass prompts — useful for CI and automation
  • Use --no-hooks to skip hooks — accepted by the commands that run them (wt switch, wt merge, wt remove, wt step commit, wt step squash), not by wt hook

Manage approvals with wt config approvals add and wt config approvals clear.

Hooks can be defined in project config (.config/wt.toml) or user config (~/.config/worktrunk/config.toml). Both use the same format. The project config is read from the worktree the command ran in.

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.

Templates are syntax-checked before the pipeline starts and rendered as each step runs, so a step can store per-branch vars that later steps read via {{ vars.<key> }}. Because an earlier step can still change those values, a preview stands the reference in for its value instead of resolving it: wt hook <type> --dry-run and wt hook show --expanded render {{ vars.thing | default('none') }} as {{ vars.thing }} — the reference is defined, so the default never fires — while every other variable expands. A filter that transforms its input still runs, against the placeholder text, and its output is shell-escaped like any other value: {{ vars.thing | upper }} previews as '{{ VARS.THING }}'.

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.

AspectProject hooksUser hooks
Location.config/wt.toml~/.config/worktrunk/config.toml
ScopeSingle repositoryAll repositories (or per-project)
ApprovalRequiredNot required
Execution orderpre-*: after user hooks. post-*: alongside thempre-*: first. post-*: alongside project hooks

To run a specific hook when user and project both define the same name, use user:name or project:name syntax.

A pre-* hook blocks the command, so both sources run as one pipeline: user commands first, and a failure there skips the project’s. A post-* hook runs in the background, where each source is its own detached pipeline — they start together, neither waits for the other, and a failure in one leaves the other running. Order within a source is still yours to set with [[hook]] blocks; across post-* sources there is none. Two post-* hooks that write the same file, or run git in the same worktree, will race, so put commands that depend on each other in one source.

Hooks can use template variables that expand at runtime:

KindVariableDescription
active{{ branch }}Branch name; unset in a detached worktree
{{ worktree_path }}Worktree path
{{ worktree_name }}Worktree directory name
{{ commit }}Branch HEAD SHA
{{ short_commit }}Branch HEAD SHA, abbreviated per core.abbrev
{{ upstream }}Branch upstream (if tracking a remote)
operation{{ base }}Base branch name (switch/create only)
{{ base_worktree_path }}Base worktree path
{{ target }}Target branch name
{{ target_worktree_path }}Target worktree path (when target has a worktree)
{{ pr_number }}PR/MR number (switch and create hooks; when switching via pr:N / mr:N)
{{ pr_url }}PR/MR web URL (switch and create hooks; when switching 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)
{{ remote_repo }}Repository name from the primary remote URL, without .git
{{ 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

The repo variables (repo, repo_path, owner, remote_repo, primary_worktree_path, default_branch, remote, remote_url) are constant across the whole repository — default_branch is the same in every worktree. The active variables (branch, worktree_path, worktree_name, commit, short_commit, upstream) vary per worktree.

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

All 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 equals worktree_path except in three cases:

  • pre-switch: hook runs in the source worktree; worktree_path is the destination when that worktree already exists — a switch that creates one has no destination directory yet, so worktree_path stays on the source (use pre-start to work in the new worktree)
  • post-remove: the active worktree is gone, so the hook runs in the primary worktree
  • post-merge: the hook runs in the target branch’s worktree (the primary worktree if the target has none), including under --no-remove, where the merged worktree worktree_path names is still on disk

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 --base origin/feature)
sync = "{% if upstream %}git fetch && git rebase {{ upstream }}{% endif %}"

A detached worktree is on no branch, so branch is undefined there — as are the base and target names derived from it, whether by a manual wt hook or by an operation whose source or destination worktree is detached (base in a pre-switch fired from one, target in a removal that lands in one) — and the same {% if branch %} guard applies. This matches wt list --format=json, which reports branch: null for the same worktree.

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') }}"

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
dirname{{ repo_path | dirname }}Strip the last path component (/a/b/c/a/b)
basename{{ repo_path | basename }}Keep only the last path component (/a/b/cc)
codename(n){{ branch | codename(2) }}Deterministic friendly words

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 codename(n) filter produces deterministic friendly names from an input string: codename(1) returns a noun, codename(2) returns adjective-noun, and higher counts add more adjectives. The pool is large (~1.26M combinations for codename(2)), so it usually stands alone as a worktree leaf — the worktree-path recipes show it both alone and under a branch-named parent directory.

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 dirname and basename filters traverse paths. They’re useful for bare repos in a hidden directory like myproject/.git, where {{ repo }} resolves to .git:

# Place worktrees as siblings of the bare repo, named `<wrapper>.<branch>`
worktree-path = "{{ repo_path }}/../{{ repo_path | dirname | basename }}.{{ branch | sanitize }}"

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.

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

Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can’t express. Variables that are unset in a template are absent from the JSON too, so read the optional ones with a default — branch has none in a detached worktree:

[pre-start]
setup = "python3 scripts/pre-start-setup.py"
import json, sys, subprocess
ctx = json.load(sys.stdin)
if ctx.get('branch', '').startswith('feature/') and 'backend' in ctx['repo']:
subprocess.run(['make', 'seed-db'])

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"

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

--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 %}).

  • Eliminate cold starts: wt step copy-ignored in post-start shares build caches and dependencies; use a [[post-start]] pipeline when a later hook depends on the copy
  • Dev server per worktree: wt step tether in post-start runs the dev server and kills its whole process group when the worktree is removed, with optional subdomain routing
  • Database per worktree: a post-start pipeline stores container name, port, and connection string as per-branch vars that later hooks reference
  • Progressive validation: quick lint/typecheck in pre-commit, expensive tests and builds in pre-merge
  • Target-specific hooks: branch on {{ target }} in post-merge for per-environment deploys
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
--config-set <toml>
Override config with inline TOML, e.g. --config-set list.full=true (repeatable)
-v, --verbose...
Verbose output (-v: info logs + hook/alias template variables on stderr; -vv: also debug
logs and raw subprocess output written to .git/wt/logs/). Set WORKTRUNK_VERBOSE=0|1|2 to
apply the same level everywhere — including shell completion, which no flag can reach
-y, --yes
Skip approval prompts