Sitelet https://worktrunk.dev/extending/
Skip to content

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.

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

.config/wt.toml
[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 are configured under [aliases], in project or user config:

.config/wt.toml
[aliases]
deploy = "fly deploy --config=fly.{{ env }}.toml --app=myproject-{{ 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.

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.

{{ args }} renders as a space-joined, shell-escaped string, ready to splice into a command:

~/.config/worktrunk/config.toml
[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 }}.

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.

  • wt config alias show <name> prints the template.
  • wt config alias dry-run <name> [-- args...] prints the rendered command.
wt config alias show deploy
wt config alias dry-run deploy
wt config alias dry-run deploy -- --env=staging

[[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.

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

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.

An alias that calls a wt command may want to pass it a template for that command to expand — each worktree’s own branch, or the worktree wt switch is about to create:

~/.config/worktrunk/config.toml
[aliases]
show-branches = "wt step for-each -- echo '{% raw %}{{ branch }}{% endraw %}'"
echo-target = "wt switch {{ args }} --no-cd --execute echo -- '{% raw %}{{ worktree_path }}{% endraw %}'"

An alias body renders once, at dispatch, so a bare {{ branch }} would reach the nested command already resolved to the invoking worktree’s branch. {% raw %}…{% endraw %} passes it through unrendered instead. Quote it — the deferred text contains spaces, and the alias body is a shell command line.

Repo-level variables like {{ default_branch }} are identical in every worktree, so they need no deferral.

Recipe: rebase every worktree onto its upstream

Section titled “Recipe: rebase every worktree onto its upstream”
~/.config/worktrunk/config.toml
[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.

--no-autostash overrides a global rebase.autostash or merge.autostash, whose conflicting pop would leave conflict markers behind and still exit 0.

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:

.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 sh -- -c \
'if [ "$#" -gt 0 ]; then exec "$@"; fi' worktrunk-move {{ args }}
else
git stash push --include-untracked --quiet
wt switch --create {{ to }} --execute sh -- -c \
'git stash pop --index; if [ "$#" -gt 0 ]; then exec "$@"; fi' worktrunk-move {{ 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 the explicit sh -c step pops it in the new worktree with the staged/unstaged split intact. Anything after -- is forwarded as argv and 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.

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:

~/.config/worktrunk/config.toml
[aliases]
hook-log = '''
tail -f "$(wt config state logs --format=json | jq -r --arg branch {{ branch }} --arg name {{ name | sanitize_hash }} --arg kind {{ kind }} '
.hook_output[]
| select([.branch, .hook_type, .name] == [$branch, $kind, $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 }}. branch in the JSON is the branch name itself, while name is the hook name as it appears on disk; sanitize_hash applies the same transformation to {{ name }}, so the alias resolves the right log even when the hook name contains characters like /.

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

  • worktrunk-sync: rebases stacked worktree branches in the dependency order inferred from git history. Install with cargo install worktrunk-sync, then run as wt 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 with cargo install workz, drop its wt-workz adapter on PATH, then run as wt workz.

Aside from the differences below, hooks and aliases behave the same.

Interface differences
AxisHooksAliases
Invocationwt hook <type> [args...] (nested under the hook built-in)wt <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
--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 like 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