A `post-*` pipeline step that fails aborts the rest of the pipeline, but the detached runner's stderr is a log file and the command that spawned it has already exited 0 — so the abort, the skipped steps, and the exit code were all invisible. The only record was `runner.log`. The runner now appends one record per aborted pipeline to `.git/wt/hook-failures.jsonl`, and the next foreground `wt` in that repo drains it and warns, naming the failed step, its exit code, the steps the abort skipped, and the log to read. Closes #3858
22 KiB
+++ title = "wt hook" description = "Run configured hooks." weight = 17
[extra] group = "Commands" +++
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
| Event | pre- — blocking |
post- — background |
|---|---|---|
| switch | pre-switch |
post-switch |
| create | pre-start |
post-start |
| 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 (use wt config state logs to find and manage log files). Use -v to see the template variables for background hooks; wt hook <type> --dry-run previews the commands.
A post-* step that fails aborts the rest of its pipeline. The command that spawned it has already exited by then, so the failure is reported on the next wt command in that repository, naming the step that failed, the steps its abort skipped, and the log to read:
▲ Background post-merge hook for main failed: user:sync exited 1; skipped push
↳ Output @ ~/code/myproject/.git/wt/logs/main/user/post-merge/sync.log
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.
| Hook | Purpose |
|---|---|
pre-switch |
Runs in the source worktree before switching — creating, switching to existing, or staying on current |
post-switch |
Triggers on all switch results: creating, switching to existing, or staying on current |
pre-start |
Runs once when a new worktree is created, blocking post-start/--execute until complete: dependency install, env file generation |
post-start |
Runs once when a new worktree is created, in the background: dev servers, long builds, file watchers, copying caches |
pre-commit |
Formatters, linters, type checking — runs during wt merge before the squash commit |
post-commit |
CI triggers, notifications, background linting |
pre-merge |
Tests, security scans, build verification — runs after rebase, before merge to target |
post-merge |
Deployment, notifications, installing updated binaries. Runs in the target branch worktree if it exists, otherwise the primary worktree |
pre-remove |
Cleanup before worktree deletion: saving test artifacts, backing up state. Runs in the worktree being removed |
post-remove |
Stopping 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. See wt merge for the complete pipeline.
Security
Project commands require approval on first run:
{% terminal() %} ▲ 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] {% end %}
- 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
--yesto bypass prompts — useful for CI and automation - Use
--no-hooksto skip hooks
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. The project config is read from the worktree the command ran in.
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.
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 leaves them alone: wt hook <type> --dry-run and wt hook show --expanded render {{ vars.<key> }} as itself while every other variable expands.
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.
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-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:
| Kind | Variable | Description |
|---|---|---|
| active | {{ branch }} |
Branch name |
{{ 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 (post-switch, pre-start, post-start; when creating via pr:N / mr:N) |
|
{{ pr_url }} |
PR/MR web URL (post-switch, pre-start, post-start; 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) | |
{{ 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:
| Operation | Bare vars | base |
target |
|---|---|---|---|
| switch/create | destination | where you came from | = bare vars |
| commit (during merge/squash) | worktree being squashed | = bare vars | integration target |
| merge | feature being merged | = bare vars | merge target |
| remove | branch being removed | = bare vars | where 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_pathis the destinationpost-remove: the active worktree is gone, so the hook runs in the primary worktreepost-mergewith removal: the active worktree is gone, so the hook runs in the target worktree
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:
| Filter | Example | Description |
|---|---|---|
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/c → c) |
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:
# Friendly branch-derived worktree names, e.g. myproject.malleable-opah
worktree-path = "{{ repo_path }}/../{{ repo }}.{{ branch | codename(2) }}"
When you want both a friendly name and the original branch identity in the path, put the branch name in a parent directory:
worktree-path = "{{ repo_path }}/../worktrees/{{ branch | sanitize }}/{{ branch | codename(2) }}"
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.
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'])
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.
{{ terminal(cmd="wt hook pre-merge # Run all pre-merge hooks|||wt hook pre-merge test # Run hooks named WT_QUOT__test__WT_QUOT from both sources|||wt hook pre-merge test build # Run hooks named WT_QUOT__test__WT_QUOT and WT_QUOT__build__WT_QUOT|||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 WT_QUOT__test__WT_QUOT 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 WT_OPEN args WT_CLOSE") }}
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.
{% terminal(cmd="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 {% end %}
{% terminal(cmd="wt hook post-start") %} ◎ Running post-start: project @ ~/acme {% end %}
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
- Eliminate cold starts:
wt step copy-ignoredinpost-startshares build caches and dependencies; use a[[post-start]]pipeline when a later hook depends on the copy - Dev server per worktree:
wt step tetherinpost-startruns the dev server and kills its whole process group when the worktree is removed, with optional subdomain routing - Database per worktree: a
post-startpipeline 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 inpre-merge - Target-specific hooks: branch on
{{ target }}inpost-mergefor per-environment deploys
See also
wt merge— Runs hooks automatically during mergewt switch— Runs pre-start/post-start hooks on--createwt config approvals— Manage approvalswt config state logs— Access background hook logs
Command reference
{% terminal() %} 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
<b><span class=c>--config</span></b><span class=c> <path></span>
User config file path
<b><span class=c>--config-set</span></b><span class=c> <toml></span>
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 {% end %}