Extending Worktrunk
Worktrunk has three extension mechanisms.
Hooks are shell commands that run automatically at lifecycle events (switching, starting, committing, merging, removing). Defined in TOML.
Aliases are reusable shell commands invoked as wt <name>. Defined in TOML.
Custom subcommands are standalone executables invoked as wt <name>. Drop wt-foo on PATH and it becomes wt foo.
| Hooks | Aliases | Custom subcommands | |
|---|---|---|---|
| Trigger | Automatic (lifecycle events) | Manual (wt <name>) | Manual (wt <name>) |
| Defined in | TOML config | TOML config | Any executable on PATH |
| Template variables | Yes | Yes | No |
| Shareable via repo | .config/wt.toml | .config/wt.toml | Distribute the binary |
| Language | Shell commands | Shell commands | Any |
Hooks and aliases live in the same TOML config and share the template engine. User config is trusted; project config requires approval on first run. When both define the same name, both run (user first).
Ten hooks cover five lifecycle events — switch, start, commit, merge, remove — each with a blocking pre- variant (failure aborts the operation) and a background post- variant. wt hook maps each hook to its timing and typical uses.
[pre-start]deps = "npm ci"
[post-start]server = "npm run dev -- --port {{ branch | hash_port }}"
[pre-merge]test = "npm test"See wt hook for the full reference and built-in recipes (dev server per worktree, database per worktree, progressive validation). Tips & Patterns has more.
Aliases
Section titled “Aliases”Aliases are configured under [aliases]:
[aliases]deploy = "fly deploy --config=fly.{{ env }}.toml --app=myapp-{{ branch }}"open = "open http://localhost:{{ branch | hash_port }}"since-main = "git log --oneline {{ default_branch }}..HEAD"wt deploy --env=stagingwt openwt <name> resolves to a built-in first, then an alias, then a custom subcommand.
Templates
Section titled “Templates”Aliases use the same template engine as hooks: variables, filters, functions, and --KEY=VALUE smart routing (bind if the template references KEY, else forward to {{ args }}). For example, wt deploy --env=staging sets {{ env }}.
Alias templates add {{ args }} for positional CLI arguments. Operation-context variables (target, base, pr_number) aren’t auto-populated, but can still be bound with --KEY=VALUE.
Positional arguments
Section titled “Positional arguments”{{ args }} renders as a space-joined, shell-escaped string, ready to splice into a command:
[aliases]s = "wt switch {{ args }}"wt s some-branchwt s feature/apiwt s 'has a space'For indexing ({{ args[0] }}), looping, and counting, see Passing values.
Tokens after -- forward unconditionally, bypassing any binding. Writing wt deploy -- --branch=foo forwards the literal --branch=foo to {{ args }} even though the template references {{ branch }}.
An alias that forwards {{ args }} to a wt command — like co = "wt switch {{ args }}" or cm = "wt step commit {{ args }}" — inherits that command’s argument and flag completion, so wt co <Tab> completes branches the same way wt switch <Tab> does.
Inspecting and previewing
Section titled “Inspecting and previewing”wt config alias show <name>prints the template.wt config alias dry-run <name> [-- args...]prints the rendered command.
wt config alias show deploywt config alias dry-run deploywt config alias dry-run deploy -- --env=stagingMulti-step pipelines
Section titled “Multi-step pipelines”[[aliases.NAME]] defines a pipeline using the same [[block]] semantics as hooks: blocks run in order, keys within a block run concurrently, and a step failure aborts the remainder.
[[aliases.release]]test = "cargo test"
[[aliases.release]]build = "cargo build --release"package = "cargo package --no-verify"
[[aliases.release]]publish = "cargo publish {{ args }}"Every step sees the same {{ args }} and bound variables. wt release -- --dry-run forwards --dry-run to publish without affecting earlier steps.
Changing directory
Section titled “Changing directory”wt switch, wt merge (when it leaves the removed source), and wt remove of the current worktree change the parent shell’s directory even when invoked from an alias; the Worktrunk shell integration propagates the change through. Other shell state doesn’t persist: the alias runs in a subshell, so cd, export, and similar commands only affect that subshell.
Deferring expansion to a nested wt command
Section titled “Deferring expansion to a nested wt command”A wt step for-each alias that prints the same branch in every worktree is rendering {{ branch }} too early. An alias body renders once at dispatch, in the invoking worktree, so a bare {{ branch }} is baked to that worktree’s branch before for-each iterates. (wt config alias dry-run <name> shows the rendered body, with the value already baked in.)
{% raw %}…{% endraw %} defers the variable: it survives the dispatch render as a literal {{ branch }}, and for-each expands it per worktree. One catch for for-each: the deferred {{ branch }} has spaces, so the alias body’s sh -c splits it into {{, branch, }} before for-each sees it (Failed to expand for-each argument: syntax error). Give for-each its own sh -c '…' to keep the value one token:
[aliases]show-branches = "wt step for-each -- sh -c 'echo {% raw %}{{ branch }}{% endraw %}'"wt show-branches prints each worktree’s own branch.
wt switch --execute defers the same way, without the extra wrapper: its --execute '…' argument is already a single quoted string, so only {% raw %} is needed. Here {{ worktree_path }} expands against the worktree being created, not the one the alias ran from:
[aliases]echo-target = "wt switch {{ args }} --no-cd --execute 'echo {% raw %}{{ worktree_path }}{% endraw %}'"A repo-level variable like {{ default_branch }} needs no deferral: it is identical in every worktree, so a bare {{ default_branch }} is already correct everywhere.
Recipe: rebase every worktree onto its upstream
Section titled “Recipe: rebase every worktree onto its upstream”[aliases]up = '''git fetch --all --prune; wt step for-each -- sh -c ' git rev-parse --verify -q @{u} >/dev/null || exit 0 g=$(git rev-parse --git-dir) rebasing() { test -d "$g/rebase-merge" || test -d "$g/rebase-apply"; } rebasing && exit 0 git diff --quiet HEAD || { git merge --ff-only --no-autostash @{u}; exit 0; } git rebase @{u} --no-autostash || { rebasing || exit 0; git rebase --abort; }''''wt up fetches every remote, then brings each worktree up to date with its upstream: skip if there is no upstream or a rebase is already in progress, fast-forward if a tracked file is modified or staged, otherwise rebase, aborting on conflict. It rebases onto git-native @{u} rather than a {{ … }} template, so git resolves each worktree’s own upstream and there is nothing to defer.
A sweep finds each worktree in whatever state you left it, so most of the script is guards:
git fetch --allexits non-zero when any single remote fails. With&&, one remote with lapsed credentials is enough to skip the whole sweep, so even worktrees whose refs fetched fine go unrebased. With;the sweep runs on what did fetch, and the fetch error still prints.git rebaserefuses to run in a worktree with a modified or staged tracked file, whether or not there is anything to rebase — so a worktree you are editing would fail the sweep.git merge --ff-onlyis the piece of the rebase git will still do there: it advances a branch that is simply behind, and otherwise changes nothing — a diverged branch, or an incoming file that collides with your edits, leaves the worktree exactly as it was, with the reason printed. Untracked files trigger neither the refusal nor thegit diffguard, so a worktree carrying only new files still rebases — unless one of them has the same name as a file the incoming commits add, which git declines to overwrite.rebasingtells the two waysgit rebasefails apart. Conflicting partway leaves a rebase in progress, which the abort winds back. Refusing to start — a tracked file modified under you, an untracked file in the way of an incoming one, apre-rebasehook saying no — leaves nothing to abort and nothing to clean up, so the sweep moves on; an unconditionalgit rebase --abortthere would answerfatal: no rebase in progressand exit 128 in place of git’s own message.- Both arms pass
--no-autostash, because a globalrebase.autostashormerge.autostashbreaks what each arm relies on. An autostash that pops with conflicts leaves the markers in the worktree and still exits 0, so the sweep would report success on a worktree it had just left in conflict — and an autostashed tree is momentarily clean, so a fast-forward that should have refused goes through and the collision lands on the pop instead.
So the sweep exits non-zero only when it leaves a worktree needing attention — an abort that itself failed. Everything git declines to do, it declines atomically, and the sweep carries on to the next worktree with git’s reason in the output. That matters where the alias is a hook step, since a failing step stops the rest of the pipeline.
Recipe: move or copy in-progress changes to a new worktree
Section titled “Recipe: move or copy in-progress changes to a new worktree”wt switch --create lands you in a clean worktree. To carry staged, unstaged, and untracked changes along, pair it with git stash:
[aliases]move-changes = '''if git diff --quiet HEAD && test -z "$(git ls-files --others --exclude-standard)"; then wt switch --create {{ to }} --execute="{{ args }}"else git stash push --include-untracked --quiet wt switch --create {{ to }} --execute="git stash pop --index; {{ args }}"fi'''Run with wt move-changes --to=feature-xyz. The guard skips the stash when nothing is in flight; otherwise git stash push captures everything and --execute pops it in the new worktree with the staged/unstaged split intact. Anything after -- runs in the new worktree after pop. For example, wt move-changes --to=feature-xyz -- claude opens Claude there.
To copy instead of move, add git stash apply --index --quiet right after the push.
Recipe: tail a specific hook log
Section titled “Recipe: tail a specific hook log”wt config state logs --format=json emits structured entries (branch, source, hook_type, name, path). Pipe through jq to resolve one entry, then wrap in an alias for quick access:
[aliases]hook-log = '''tail -f "$(wt config state logs --format=json | jq -r --arg name "{{ name | sanitize_hash }}" --arg kind "{{ kind }}" ' .hook_output[] | select(.branch == "{{ branch | sanitize_hash }}" and .hook_type == $kind and .name == $name) | .path' | head -1)"'''Run with wt hook-log --kind=post-start --name=server to tail the log for the server hook on the current branch. --kind picks the hook type; the branch is pulled from the current worktree via {{ branch }}. sanitize_hash rewrites branch and name to filesystem-safe forms with a hash suffix that keeps distinct originals unique (the same transformation Worktrunk applies on disk), so the alias resolves the right log even when either contains characters like /.
Custom subcommands
Section titled “Custom subcommands”
Any executable named wt-<name> on PATH becomes available as wt <name>, the same pattern git uses for git-foo. Built-in commands and aliases take precedence.
wt sync origin # runs: wt-sync originwt -C /tmp/repo sync # -C is forwarded as the child's working directoryArguments pass through verbatim, stdio is inherited, and the child’s exit code propagates unchanged.
Examples
Section titled “Examples”worktrunk-sync: rebases stacked worktree branches in the dependency order inferred from git history. Install withcargo install worktrunk-sync, then run aswt sync.workz: provisions the current worktree with a collision-free port range plus its own database and Docker Compose project, merged into.env.local, so parallel worktrees don’t clash. Install withcargo install workz, drop itswt-workzadapter onPATH, then run aswt workz.
Reference: hooks vs. aliases
Section titled “Reference: hooks vs. aliases”Aside from the differences below, hooks and aliases behave the same.
Interface differences
| Axis | Hooks | Aliases |
|---|---|---|
| Invocation | wt hook <type> [args...] (nested under the hook built-in) | wt <name> [args...] (top-level) |
| Bare positionals | Filter names (wt hook pre-merge test build runs only test and build) | Forwarded to {{ args }} |
Reach {{ args }} from positionals | Must use -- (wt hook pre-merge -- extra) | Any bare positional lands there |
| Approval skip flag | Post-subcommand --yes / -y supported (wt hook pre-merge --yes) | Only the global form (wt -y <alias>); post-alias --yes falls through to {{ args }} |
| Source discrimination | user: / project: / user:name / project:name filter syntax | Run user first, then project; no filter syntax |
| Force-bind escape | --var KEY=VALUE (deprecated in favor of --KEY=VALUE, but still force-binds) | None; smart routing is the only path |
--help | wt hook --help lists hook types; wt hook <type> --help shows flags and arguments for that type | The template body is the documentation: wt <alias> --help redirects to wt config alias show / dry-run. wt --help and wt step --help list configured aliases alongside built-in commands |
| Inspection | wt hook show [type] [--expanded] | wt config alias show <name> / wt config alias dry-run <name> |
| Stdin | All template variables as JSON (parse with json.load(sys.stdin)) | Inherits parent stdin (pipes pass through; interactive TUIs like wt switch keep the tty) |
| Template-context extras | hook_type, hook_name, per-type operation vars (base, target, pr_number, …) | args on top of the shared base variables |
