Files
max-sixty__worktrunk/docs/content/hook.md
Caleb Cox aa9d8c43df feat: add remote_repo variable (#3745)
Add a `remote_repo` variable that returns the repo name from the remote
URL. Unlike `repo`, it stays consistent even if the clone was renamed.

Feel free to reject, or suggest other names for the variable. But this
change would improve my workflow. I hope you don't mind my submitting a
PR before opening an issue. Thanks for an amazing developer tool!

AI Disclosure 🤖: I used Claude Code to generate the changes, but
reviewed every line and made adjustments.

---------

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
2026-08-14 00:48:54 -07:00

385 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
+++
title = "wt hook"
description = "Run configured hooks."
weight = 17
[extra]
group = "Commands"
+++
<!-- ⚠️ AUTO-GENERATED from `wt hook --help-page` — edit src/cli/mod.rs to update -->
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`](@/config.md#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.
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`](@/merge.md#pipeline) for the complete pipeline.
# Security
Project commands require approval on first run:
{% terminal() %}
<span class="y">▲ <b>repo</b> needs approval to execute <b>3</b> commands:</span>
<span class="d">○</span> pre-start <b>install</b>:
<span style='background:var(--bright-white,#fff)'> </span> <span class="d"><span class="b">npm</span> ci</span>
<span class="d">○</span> pre-start <b>build</b>:
<span style='background:var(--bright-white,#fff)'> </span> <span class="d"><span class="b">cargo</span> build <span class="c">--release</span></span>
<span class="d">○</span> pre-start <b>env</b>:
<span style='background:var(--bright-white,#fff)'> </span> <span class="d"><span class="b">echo</span> <span class="g">'PORT={{ branch | hash_port }}'</span> <span class="c">></span> .env.local</span>
<span class="c">❯</span> Allow and remember? <b>[y/N]</b>
{% 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:
```toml
pre-start = "npm install"
```
A table is multiple commands that run concurrently:
```toml
[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:
```toml
[[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](@/config.md#wt-config-state-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](@/config.md#user-project-specific-settings)) |
| 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](#running-hooks-manually) |
| user | `{{ vars.<key> }}` | Per-branch variables from [`wt config state vars`](@/config.md#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:
```toml
[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}`:
```toml
[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:
```toml
# 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:
```toml
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):
```toml
# 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`:
```toml
# 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:
```toml
[post-start]
dev = "npm run dev -- --host {{ branch }}.localhost --port {{ branch | hash_port }}"
```
Hash any string, including concatenations:
```toml
# 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:
```toml
[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:
```toml
[pre-start]
setup = "python3 scripts/pre-start-setup.py"
```
```python
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`](@/step.md#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees:
```toml
[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") %}
<span class=c>◎</span> <span class=c>Running pre-merge <b>project:test</b></span>
<span style='background:var(--bright-white,#fff)'> </span> <span class=d><span style='color:var(--blue,#00a)'>cargo</span></span><span class=d> test</span>
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
<span class=c>◎</span> <span class=c>Running pre-merge <b>project:lint</b></span>
<span style='background:var(--bright-white,#fff)'> </span> <span class=d><span style='color:var(--blue,#00a)'>cargo</span></span><span class=d> clippy</span>
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](@/tips-patterns.md#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](@/tips-patterns.md#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](@/tips-patterns.md#database-per-worktree): a `post-start` pipeline stores container name, port, and connection string as [per-branch vars](@/config.md#wt-config-state-vars) that later hooks reference
- [Progressive validation](@/tips-patterns.md#progressive-validation): quick lint/typecheck in `pre-commit`, expensive tests and builds in `pre-merge`
- [Target-specific hooks](@/tips-patterns.md#target-specific-hooks): branch on `{{ target }}` in `post-merge` for per-environment deploys
## See also
- [`wt merge`](@/merge.md) — Runs hooks automatically during merge
- [`wt switch`](@/switch.md) — Runs pre-start/post-start hooks on `--create`
- [`wt config approvals`](@/config.md#wt-config-approvals) — Manage approvals
- [`wt config state logs`](@/config.md#wt-config-state-logs) — Access background hook logs
## Command reference
{% terminal() %}
wt hook - Run configured hooks
Usage: <b><span class=c>wt hook</span></b> <span class=c>[OPTIONS]</span> <span class=c>&lt;COMMAND&gt;</span>
<b><span class=g>Commands:</span></b>
<b><span class=c>show</span></b> Show configured hooks
<b><span class=c>pre-switch</span></b> Run pre-switch hooks
<b><span class=c>post-switch</span></b> Run post-switch hooks
<b><span class=c>pre-start</span></b> Run pre-start hooks
<b><span class=c>post-start</span></b> Run post-start hooks
<b><span class=c>pre-commit</span></b> Run pre-commit hooks
<b><span class=c>post-commit</span></b> Run post-commit hooks
<b><span class=c>pre-merge</span></b> Run pre-merge hooks
<b><span class=c>post-merge</span></b> Run post-merge hooks
<b><span class=c>pre-remove</span></b> Run pre-remove hooks
<b><span class=c>post-remove</span></b> Run post-remove hooks
<b><span class=g>Options:</span></b>
<b><span class=c>-h</span></b>, <b><span class=c>--help</span></b>
Print help (see a summary with &#39;-h&#39;)
<b><span class=g>Global Options:</span></b>
<b><span class=c>-C</span></b><span class=c> &lt;path&gt;</span>
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)
<b><span class=c>-v</span></b>, <b><span class=c>--verbose</span></b><span class=c>...</span>
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
<b><span class=c>-y</span></b>, <b><span class=c>--yes</span></b>
Skip approval prompts
{% end %}
<!-- END AUTO-GENERATED -->