worktrunk 0.37.0

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
+++
title = "Extending Worktrunk"
description = "Three ways to add custom behavior: hooks for lifecycle automation, aliases for reusable commands, and external subcommands for standalone tools."
weight = 21

[extra]
group = "Reference"
+++

Worktrunk has three extension mechanisms.

**[Hooks](#hooks)** run shell commands at lifecycle events — creating a worktree, merging, removing. They're configured in TOML and run automatically.

**[Aliases](#aliases)** define reusable commands invoked via `wt step <name>`. Same template variables as hooks, but triggered manually.

**[External subcommands](#external-subcommands)** are standalone executables. Drop `wt-foo` on `PATH` and it becomes `wt foo`. No configuration needed.

| | Hooks | Aliases | External subcommands |
|---|---|---|---|
| **Trigger** | Automatic (lifecycle events) | Manual (`wt step <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

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

| Event | `pre-` (blocking) | `post-` (background) |
|-------|-------------------|---------------------|
| **switch** | `pre-switch` | `post-switch` |
| **start** | `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.

### Configuration

Hooks live in two places:

- **User config** (`~/.config/worktrunk/config.toml`) — personal, applies everywhere, trusted
- **Project config** (`.config/wt.toml`) — shared with the team, requires [approval]@/hook.md#wt-hook-approvals on first run

Three formats, from simplest to most expressive.

A single command as a string:

```toml
pre-start = "npm ci"
```

A named table runs commands concurrently for `post-*` hooks and serially for `pre-*`:

```toml
[post-start]
server = "npm start"
watcher = "npm run watch"
```

An array of tables is a pipeline — blocks run in order, commands within a block run concurrently:

```toml
[[post-start]]
install = "npm ci"

[[post-start]]
server = "npm start"
build = "npm run build"
```

### Template variables

Hook commands are templates. Variables expand at execution time:

```toml
[post-start]
server = "npm run dev -- --port {{ branch | hash_port }}"
env = "echo 'PORT={{ branch | hash_port }}' > .env.local"
```

Core variables include `branch`, `worktree_path`, `commit`, `repo`, `default_branch`, and context-dependent ones like `target` during merge. Filters like `sanitize`, `hash_port`, and `sanitize_db` transform values for specific uses.

See [`wt hook`](@/hook.md#template-variables) for the full variable and filter reference.

### Common patterns

```toml
# .config/wt.toml

# Install dependencies when creating a worktree
[pre-start]
deps = "npm ci"

# Run tests before merging
[pre-merge]
test = "npm test"
lint = "npm run lint"

# Dev server per worktree on a deterministic port
[post-start]
server = "npm run dev -- --port {{ branch | hash_port }}"
```

See [Tips & Patterns](@/tips-patterns.md) for more recipes: dev server per worktree, database per worktree, tmux sessions, Caddy subdomain routing.

## Aliases

Aliases are custom commands invoked via `wt step <name>`. They share the same template variables and approval model as hooks.

```toml
[aliases]
deploy = "make deploy BRANCH={{ branch }}"
open = "open http://localhost:{{ branch | hash_port }}"
```

{{ terminal(cmd="wt step deploy|||wt step deploy --dry-run|||wt step deploy --env=staging") }}

An `up` alias that fetches all remotes and rebases each worktree onto its upstream:

```toml
[aliases]
up = '''
git fetch --all --prune && wt step for-each -- '
  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
''''
```

When both user and project config define the same alias name, both run — user first, then project. Project-config aliases require approval, same as project hooks.

Alias names that collide with built-in step commands (`commit`, `squash`, `rebase`, etc.) are shadowed by the built-in.

### 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, wrap it with git's stash plumbing:

```toml
# .config/wt.toml
[aliases]
move-changes = '''
if git diff --quiet HEAD && test -z "$(git ls-files --others --exclude-standard)"; then
  wt switch --create {{ to }}
else
  git stash push --include-untracked --quiet
  wt switch --create {{ to }} --execute='git stash pop --index'
fi
'''
```

Run with `wt step move-changes --to=feature-xyz`. The leading guard avoids touching a pre-existing stash when nothing is in flight; otherwise, `git stash push --include-untracked` captures everything, `wt switch --create` makes the new worktree, and `git stash pop --index` (via `--execute`) restores the changes there with the staged/unstaged split intact.

To copy instead of move (source keeps its changes too), add `git stash apply --index --quiet` right after the push. For staged-only flows, swap the stash for `git diff --cached` written to a tempfile and applied with `git apply --index` in the new worktree — that handles files where staged and unstaged hunks overlap on the same lines, where `git stash --staged` falls short.

Because an inner `wt switch --create` inside an alias [propagates its `cd` to the parent shell](@/step.md#aliases), the alias drops you in the new worktree directly.

### 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:

```toml
[aliases]
hook-log = '''
tail -f "$(wt config state logs --format=json | jq -r --arg name "{{ name | sanitize_hash }}" '
  .hook_output[]
  | select(.branch == "{{ branch | sanitize_hash }}" and .hook_type == "post-start" and .name == $name)
  | .path
' | head -1)"
'''
```

Run with `wt step hook-log --name=<hook-name>` (e.g., `wt step hook-log --name=server`) to tail the current worktree's `post-start` hook of that name. The `sanitize_hash` filter produces a filesystem-safe name with a hash suffix that keeps distinct originals unique — the same transformation Worktrunk applies on disk — so the alias resolves the right log even for branch and hook names containing characters like `/`.

See [`wt step` — Aliases](@/step.md#aliases) for the full reference.

## External subcommands

<span class="badge-experimental"></span>

Any executable named `wt-<name>` on `PATH` becomes available as `wt <name>` — the same pattern git uses for `git-foo`. Built-in commands always take precedence.

{{ terminal(cmd="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. External subcommands don't have access to template variables.

If nothing matches — no built-in, no nested subcommand, no `wt-<name>` on `PATH` — wt prints a "not a wt command" error with a typo suggestion.