Files
worktrunk-bot bd89acf778 fix(hooks): surface a background hook failure on the next command
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
2026-08-21 01:49:48 +00:00

22 KiB
Raw Permalink Blame History

+++ 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 --yes to bypass prompts — useful for CI and automation
  • Use --no-hooks to 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_path is the destination
  • post-remove: the active worktree is gone, so the hook runs in the primary worktree
  • post-merge with 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-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

See also

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> &lt;path&gt;</span>
      User config file path

  <b><span class=c>--config-set</span></b><span class=c> &lt;toml&gt;</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 %}