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
Section titled “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); 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.
| 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 before any Worktrunk commit (wt step commit, wt step squash, and the commit wt merge makes) |
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, 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.
Security
Section titled “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]- 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 — accepted by the commands that run them (wt switch,wt merge,wt remove,wt step commit,wt step squash), not bywt hook
Manage approvals with wt config approvals add and wt config approvals clear.
Configuration
Section titled “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
Section titled “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 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.
Project vs user hooks
Section titled “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 | pre-*: after user hooks. post-*: alongside them | pre-*: 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.
Template variables
Section titled “Template variables”Hooks can use template variables that expand at runtime:
| Kind | Variable | Description |
|---|---|---|
| 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:
| 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 destination when that worktree already exists — a switch that creates one has no destination directory yet, soworktree_pathstays on the source (usepre-startto work in the new worktree)post-remove: the active worktree is gone, so the hook runs in the primary worktreepost-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 worktreeworktree_pathnames 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') }}"Worktrunk filters
Section titled “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 — 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 matchworktree-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 combinationdev = "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
Section titled “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 worktreesetup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}"JSON context
Section titled “JSON context”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, subprocessctx = json.load(sys.stdin)if ctx.get('branch', '').startswith('feature/') and 'backend' in ctx['repo']: subprocess.run(['make', 'seed-db'])Copying untracked files
Section titled “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
Section titled “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 hookswt hook pre-merge test # Run hooks named "test" from both sourceswt hook pre-merge test build # Run hooks named "test" and "build"wt hook pre-merge user: # Run all user hookswt hook pre-merge project: # Run all project hookswt hook pre-merge user:test # Run only user's "test" hookwt hook pre-merge --yes # Skip approval prompts (for CI)wt hook pre-start --branch=feature/test # Override a template variablewt 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 teststest auth::tests::test_jwt_decode ... oktest auth::tests::test_jwt_encode ... oktest auth::tests::test_token_refresh ... oktest auth::tests::test_token_validation ... oktest 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.23swt hook post-start◎ Running post-start: project @ ~/acmePassing values
Section titled “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
Section titled “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
Section titled “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
Section titled “Command reference”wt hook - Run configured hooksUsage: wt hook [OPTIONS] <COMMAND>Commands:show Show configured hookspre-switch Run pre-switch hookspost-switch Run post-switch hookspre-start Run pre-start hookspost-start Run post-start hookspre-commit Run pre-commit hookspost-commit Run post-commit hookspre-merge Run pre-merge hookspost-merge Run post-merge hookspre-remove Run pre-remove hookspost-remove Run post-remove hooksOptions:-h, --helpPrint 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 debuglogs and raw subprocess output written to .git/wt/logs/). Set WORKTRUNK_VERBOSE=0|1|2 toapply the same level everywhere — including shell completion, which no flag can reach-y, --yesSkip approval prompts