Extending Worktrunk

Worktrunk has three extension mechanisms.

Hooks run shell commands at lifecycle events — creating a worktree, merging, removing. They're configured in TOML and run automatically.

Aliases define reusable commands invoked as wt <name>.

Custom subcommands are standalone executables. Drop wt-foo on PATH and it becomes wt foo. No configuration needed.

HooksAliasesCustom subcommands
TriggerAutomatic (lifecycle events)Manual (wt <name>)Manual (wt <name>)
Defined inTOML configTOML configAny executable on PATH
Template variablesYesYesNo
Shareable via repo.config/wt.toml.config/wt.tomlDistribute the binary
LanguageShell commandsShell commandsAny

Hooks and aliases share the TOML config file, the template engine (variables, filters, and functions), the [[block]] pipeline syntax (blocks run in order, keys within a block run concurrently), and the approval model: user config is trusted; project config requires approval on first run. When both sources define the same name, both run — user first.

Hooks

Hooks are shell commands that run at key points in the worktree lifecycle. Ten hooks cover five events:

Eventpre- (blocking)post- (background)
switchpre-switchpost-switch
startpre-startpost-start
commitpre-commitpost-commit
mergepre-mergepost-merge
removepre-removepost-remove

pre-* hooks block — failure aborts the operation. post-* hooks run in the background.

[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 configuration reference — TOML forms, template variables and filters, and built-in recipes (dev server per worktree, database per worktree, progressive validation). Tips & Patterns has more.

Aliases

[aliases] defines commands invoked as wt <name>.

[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=staging
wt open

wt <name> resolves to a built-in first, then an alias, then a custom subcommand.

Templates

Aliases use the same template engine as hooks — same variables, same filters, same functions, and the same --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 since there's no operation in progress — but any of them can still be bound with --KEY=VALUE.

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-branch
wt s feature/api
wt 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 }}.

Inspecting and previewing

wt config alias show deploy
wt config alias dry-run deploy
wt config alias dry-run deploy -- --env=staging

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, 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

wt commands that change the parent shell's directory — wt switch, wt merge (leaving the removed source), wt remove of the current worktree — still do so 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.

Recipe: rebase every worktree onto its upstream

[aliases]
up = '''
git fetch --all --prune && wt step for-each -- sh -c '
  git rev-parse --verify @{u} >/dev/null 2>&1 || exit 0
  g=$(git rev-parse --git-dir)
  test -d "$g/rebase-merge" -o -d "$g/rebase-apply" && exit 0
  git rebase @{u} --no-autostash || git rebase --abort
''''

wt up fetches all remotes, then iterates every worktree: skip if no upstream, skip if mid-rebase, otherwise rebase and auto-abort on conflict.

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:

# .config/wt.toml
[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 — 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

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

Any executable named wt-<name> on PATH becomes available as wt <name> — the same pattern git uses for git-foo. Built-in commands and configured aliases take precedence — wt foo resolves to the alias if foo is configured, otherwise to wt-foo.

wt sync origin              # runs: wt-sync origin
wt -C /tmp/repo sync        # -C is forwarded as the child's working directory

Arguments pass through verbatim, stdio is inherited, and the child's exit code propagates unchanged. Custom subcommands don't have access to template variables.

Examples

Reference: hooks vs. aliases

Hooks and aliases share a template-variable model and a smart-routing rule for --KEY=VALUE shorthand (bind if the template references the key, else forward to {{ args }}), so a pattern learned on one surface mostly transfers to the other. A few things differ.

Interface differences
AxisHooksAliases
Invocationwt hook <type> [args...] — nested under the hook built-inwt <name> [args...] — top-level
Bare positionalsFilter names (wt hook pre-merge test build runs only test and build)Forwarded to {{ args }}
Reach {{ args }} from positionalsMust use -- (wt hook pre-merge -- extra)Any bare positional lands there
Approval skip flagPost-subcommand --yes / -y supported (wt hook pre-merge --yes)Only the global form (wt -y <alias>); post-alias --yes falls through to {{ args }}
Source discriminationuser: / project: / user:name / project:name filter syntaxRun user first, then project; no filter syntax
Force-bind escape--var KEY=VALUE (deprecated — prefer --KEY=VALUE — but still force-binds)None — smart routing is the only path
--helpwt hook --help lists hook types; wt hook <type> --help shows flags and arguments for that typeThe 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
Inspectionwt hook show [type] [--expanded]wt config alias show <name> / wt config alias dry-run <name>
StdinAll template variables as JSON (parse with json.load(sys.stdin))Inherits parent stdin — pipes pass through; interactive TUIs (e.g. wt switch) keep the tty
Template-context extrashook_type, hook_name, per-type operation vars (base, target, pr_number, …)args on top of the shared base variables