mod config;
mod hook;
mod list;
mod step;
pub(crate) use config::{
ApprovalsCommand, CiStatusAction, ConfigCommand, ConfigPluginsClaudeCommand,
ConfigPluginsCommand, ConfigPluginsOpencodeCommand, ConfigShellCommand, DefaultBranchAction,
HintsAction, LogsAction, MarkerAction, PreviousBranchAction, StateCommand, VarsAction,
};
pub(crate) use hook::HookCommand;
pub(crate) use list::ListSubcommand;
pub(crate) use step::StepCommand;
use clap::builder::styling::{AnsiColor, Color, Styles};
use clap::{Command, CommandFactory, Parser, Subcommand, ValueEnum};
use std::sync::OnceLock;
use worktrunk::config::{DEPRECATED_TEMPLATE_VARS, TEMPLATE_VARS};
use crate::commands::Shell;
pub(super) fn parse_key_val(s: &str) -> Result<(String, String), String> {
let (key, value) = s
.split_once('=')
.ok_or_else(|| format!("invalid KEY=VALUE: no `=` found in `{s}`"))?;
if key.is_empty() {
return Err("invalid KEY=VALUE: key cannot be empty".to_string());
}
if !TEMPLATE_VARS.contains(&key) && !DEPRECATED_TEMPLATE_VARS.contains(&key) {
return Err(format!(
"unknown variable `{key}`; valid variables: {} (deprecated: {})",
TEMPLATE_VARS.join(", "),
DEPRECATED_TEMPLATE_VARS.join(", ")
));
}
Ok((key.to_string(), value.to_string()))
}
pub(super) fn parse_vars_assignment(s: &str) -> Result<(String, String), String> {
let (key, value) = s
.split_once('=')
.ok_or_else(|| format!("invalid KEY=VALUE: no `=` found in `{s}`"))?;
if key.is_empty() {
return Err("invalid KEY=VALUE: key cannot be empty".to_string());
}
Ok((key.to_string(), value.to_string()))
}
fn help_styles() -> Styles {
Styles::styled()
.header(
anstyle::Style::new()
.bold()
.fg_color(Some(Color::Ansi(AnsiColor::Green))),
)
.usage(
anstyle::Style::new()
.bold()
.fg_color(Some(Color::Ansi(AnsiColor::Green))),
)
.literal(
anstyle::Style::new()
.bold()
.fg_color(Some(Color::Ansi(AnsiColor::Cyan))),
)
.placeholder(anstyle::Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan))))
.error(
anstyle::Style::new()
.bold()
.fg_color(Some(Color::Ansi(AnsiColor::Red))),
)
.valid(
anstyle::Style::new()
.bold()
.fg_color(Some(Color::Ansi(AnsiColor::Green))),
)
.invalid(
anstyle::Style::new()
.bold()
.fg_color(Some(Color::Ansi(AnsiColor::Yellow))),
)
}
const DEFAULT_COMMAND_NAME: &str = "wt";
const HELP_TEMPLATE: &str = "\
{before-help}{name} - {about-with-newline}
Usage: {usage}
{all-args}{after-help}";
fn shell_value_name() -> &'static str {
static CACHE: OnceLock<String> = OnceLock::new();
CACHE
.get_or_init(|| {
Shell::value_variants()
.iter()
.filter_map(|v| v.to_possible_value())
.map(|v| v.get_name().to_owned())
.collect::<Vec<_>>()
.join("|")
})
.as_str()
}
pub(crate) fn build_command() -> Command {
let cmd = apply_help_template_recursive(Cli::command(), DEFAULT_COMMAND_NAME);
let shell_name = shell_value_name();
cmd.mut_subcommand("config", |c| {
c.mut_subcommand("shell", |c| {
c.mut_subcommand("init", |c| c.mut_arg("shell", |a| a.value_name(shell_name)))
.mut_subcommand("install", |c| {
c.mut_arg("shell", |a| a.value_name(shell_name))
})
.mut_subcommand("uninstall", |c| {
c.mut_arg("shell", |a| a.value_name(shell_name))
})
})
})
}
const NESTED_COMMAND_PARENTS: &[&str] = &["step", "hook"];
pub(crate) fn suggest_nested_subcommand(cmd: &Command, unknown: &str) -> Option<String> {
for parent in NESTED_COMMAND_PARENTS {
if let Some(parent_cmd) = cmd.get_subcommands().find(|c| c.get_name() == *parent)
&& parent_cmd
.get_subcommands()
.any(|s| s.get_name() == unknown)
{
return Some(format!("wt {parent} {unknown}"));
}
}
None
}
fn apply_help_template_recursive(mut cmd: Command, path: &str) -> Command {
cmd = cmd.help_template(HELP_TEMPLATE).display_name(path);
for sub in cmd.get_subcommands_mut() {
let sub_cmd = std::mem::take(sub);
let sub_path = format!("{} {}", path, sub_cmd.get_name());
let sub_cmd = apply_help_template_recursive(sub_cmd, &sub_path);
*sub = sub_cmd;
}
cmd
}
pub(crate) fn version_str() -> &'static str {
static VERSION: OnceLock<String> = OnceLock::new();
VERSION.get_or_init(|| {
let git_version = env!("VERGEN_GIT_DESCRIBE");
let cargo_version = env!("CARGO_PKG_VERSION");
if git_version.contains("IDEMPOTENT") {
cargo_version.to_string()
} else {
git_version.to_string()
}
})
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub(crate) enum OutputFormat {
Table,
Json,
#[value(name = "claude-code")]
ClaudeCode,
}
#[derive(Parser)]
#[command(name = "wt")]
#[command(about = "Git worktree management for parallel AI agent workflows", long_about = None)]
#[command(version = version_str())]
#[command(disable_help_subcommand = true)]
#[command(styles = help_styles())]
#[command(arg_required_else_help = true)]
#[command(term_width = 0)]
#[command(after_long_help = "\
Getting started
wt switch --create feature # Create worktree and branch
wt switch feature # Switch to worktree
wt list # Show all worktrees
wt remove # Remove worktree; delete branch if merged
Run `wt config shell install` to set up directory switching.
Run `wt config create` to customize worktree locations.
Docs: https://worktrunk.dev
GitHub: https://github.com/max-sixty/worktrunk")]
pub(crate) struct Cli {
#[arg(
short = 'C',
global = true,
value_name = "path",
display_order = 100,
help_heading = "Global Options"
)]
pub directory: Option<std::path::PathBuf>,
#[arg(
long,
global = true,
value_name = "path",
display_order = 101,
help_heading = "Global Options"
)]
pub config: Option<std::path::PathBuf>,
#[arg(
long,
short = 'v',
global = true,
action = clap::ArgAction::Count,
display_order = 102,
help_heading = "Global Options"
)]
pub verbose: u8,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub(crate) enum Commands {
#[command(
after_long_help = r#"Worktrees are addressed by branch name; paths are computed from a configurable template. Unlike `git switch`, this navigates between worktrees rather than changing branches in place.
<!-- demo: wt-switch.gif 1600x900 -->
## Examples
```console
$ wt switch feature-auth # Switch to worktree
$ wt switch - # Previous worktree (like cd -)
$ wt switch --create new-feature # Create new branch and worktree
$ wt switch --create hotfix --base production
$ wt switch pr:123 # Switch to PR #123's branch
```
## Creating a branch
The `--create` flag creates a new branch from `--base` — the default branch unless specified. Without `--create`, the branch must already exist. Switching to a remote branch (e.g., `wt switch feature` when only `origin/feature` exists) creates a local tracking branch.
## Creating worktrees
If the branch already has a worktree, `wt switch` changes directories to it. Otherwise, it creates one:
1. Runs [pre-switch hooks](@/hook.md#hook-types), blocking until complete
2. Creates worktree at configured path
3. Switches to new directory
4. Runs [pre-start hooks](@/hook.md#hook-types), blocking until complete
5. Spawns [post-start](@/hook.md#hook-types) and [post-switch hooks](@/hook.md#hook-types) in the background
```console
$ wt switch feature # Existing branch → creates worktree
$ wt switch --create feature # New branch and worktree
$ wt switch --create fix --base release # New branch from release
$ wt switch --create temp --no-verify # Skip hooks
```
## Shortcuts
| Shortcut | Meaning |
|----------|---------|
| `^` | Default branch (`main`/`master`) |
| `@` | Current branch/worktree |
| `-` | Previous worktree (like `cd -`) |
| `pr:{N}` | GitHub PR #N's branch |
| `mr:{N}` | GitLab MR !N's branch |
```console
$ wt switch - # Back to previous
$ wt switch ^ # Default branch worktree
$ wt switch --create fix --base=@ # Branch from current HEAD
$ wt switch pr:123 # PR #123's branch
$ wt switch mr:101 # MR !101's branch
```
## Interactive picker
When called without arguments, `wt switch` opens an interactive picker to browse and select worktrees with live preview.
<!-- demo: wt-switch-picker.gif 1600x800 -->
**Keybindings:**
| Key | Action |
|-----|--------|
| `↑`/`↓` | Navigate worktree list |
| (type) | Filter worktrees |
| `Enter` | Switch to selected worktree |
| `Alt-c` | Create new worktree named as entered text |
| `Esc` | Cancel |
| `1`–`5` | Switch preview tab |
| `Alt-p` | Toggle preview panel |
| `Ctrl-u`/`Ctrl-d` | Scroll preview up/down |
**Preview tabs** — toggle with number keys:
1. **HEAD±** — Diff of uncommitted changes
2. **log** — Recent commits; commits already on the default branch have dimmed hashes
3. **main…±** — Diff of changes since the merge-base with the default branch
4. **remote⇅** — Ahead/behind diff vs upstream tracking branch
5. **summary** — LLM-generated branch summary; requires `[list] summary = true` and `[commit.generation]`
**Pager configuration:** The preview panel pipes diff output through git's pager. Override in user config:
```toml
[switch.picker]
pager = "delta --paging=never --width=$COLUMNS"
```
Available on Unix only (macOS, Linux). On Windows, use `wt list` or `wt switch <branch>` directly.
## Pull requests and merge requests
The `pr:<number>` and `mr:<number>` shortcuts resolve a GitHub PR or GitLab MR to its branch. For same-repo PRs/MRs, worktrunk switches to the branch directly. For fork PRs/MRs, it fetches the ref (`refs/pull/N/head` or `refs/merge-requests/N/head`) and configures `pushRemote` to the fork URL.
```console
$ wt switch pr:101 # GitHub PR #101
$ wt switch mr:101 # GitLab MR !101
```
Requires `gh` (GitHub) or `glab` (GitLab) CLI to be installed and authenticated. The `--create` flag cannot be used with `pr:`/`mr:` syntax since the branch already exists.
**Forks:** The local branch uses the PR/MR's branch name directly (e.g., `feature-fix`), so `git push` works normally. If a local branch with that name already exists tracking something else, rename it first.
## When wt switch fails
- **Branch doesn't exist** — Use `--create`, or check `wt list --branches`
- **Path occupied** — Another worktree is at the target path; switch to it or remove it
- **Stale directory** — Use `--clobber` to remove a non-worktree directory at the target path
To change which branch a worktree is on, use `git switch` inside that worktree.
## See also
- [`wt list`](@/list.md) — View all worktrees
- [`wt remove`](@/remove.md) — Delete worktrees when done
- [`wt merge`](@/merge.md) — Integrate changes back to the default branch
"#
)]
Switch {
#[arg(add = crate::completion::worktree_branch_completer())]
branch: Option<String>,
#[arg(long, help_heading = "Picker Options", conflicts_with_all = ["create", "base", "execute", "execute_args", "clobber"])]
branches: bool,
#[arg(long, help_heading = "Picker Options", conflicts_with_all = ["create", "base", "execute", "execute_args", "clobber"])]
remotes: bool,
#[arg(short = 'c', long, requires = "branch")]
create: bool,
#[arg(short = 'b', long, requires = "branch", add = crate::completion::branch_value_completer())]
base: Option<String>,
#[arg(short = 'x', long, requires = "branch")]
execute: Option<String>,
#[arg(last = true, requires = "execute")]
execute_args: Vec<String>,
#[arg(long, requires = "branch")]
clobber: bool,
#[arg(long, overrides_with = "cd")]
no_cd: bool,
#[arg(long, overrides_with = "no_cd", hide = true)]
cd: bool,
#[arg(short, long, help_heading = "Automation")]
yes: bool,
#[arg(long = "no-verify", action = clap::ArgAction::SetFalse, default_value_t = true, help_heading = "Automation")]
verify: bool,
},
#[command(
after_long_help = r#"Shows uncommitted changes, divergence from the default branch and remote, and optional CI status and LLM summaries.
<!-- demo: wt-list.gif 1600x900 -->
The table renders progressively: branch names, paths, and commit hashes appear immediately, then status, divergence, and other columns fill in as background git operations complete.
## Full mode
`--full` adds columns that require network access or LLM calls: [CI status](#ci-status) (GitHub/GitLab pipeline pass/fail), line diffs since the merge-base, and [LLM-generated summaries](#llm-summaries) of each branch's changes. The table displays instantly and columns fill in as results arrive.
## Examples
List all worktrees:
<!-- wt list -->
```console
$ wt list
```
Include CI status, line diffs, and LLM summaries:
<!-- wt list --full -->
```console
$ wt list --full
```
Include branches that don't have worktrees:
<!-- wt list --branches --full -->
```console
$ wt list --branches --full
```
Output as JSON for scripting:
```console
$ wt list --format=json
```
## Columns
| Column | Shows |
|--------|-------|
| Branch | Branch name |
| Status | Compact symbols (see below) |
| HEAD± | Uncommitted changes: +added -deleted lines |
| main↕ | Commits ahead/behind default branch |
| main…± | Line diffs since the merge-base with the default branch; `--full` only |
| Summary | LLM-generated branch summary; requires `--full`, `summary = true`, and [`commit.generation`](@/config.md#commit) [experimental] |
| Remote⇅ | Commits ahead/behind tracking branch |
| CI | Pipeline status; `--full` only |
| Path | Worktree directory |
| URL | Dev server URL from project config; dimmed if port is not listening |
| Commit | Short hash (8 chars) |
| Age | Time since last commit |
| Message | Last commit message (truncated) |
Note: `main↕` and `main…±` refer to the default branch — the header label stays `main` for compactness. `main…±` uses a merge-base (three-dot) diff.
### CI status
The CI column shows GitHub/GitLab pipeline status:
| Indicator | Meaning |
|-----------|---------|
| `●` green | All checks passed |
| `●` blue | Checks running |
| `●` red | Checks failed |
| `●` yellow | Merge conflicts with base |
| `●` gray | No checks configured |
| `⚠` yellow | Fetch error (rate limit, network) |
| (blank) | No upstream or no PR/MR |
CI indicators are clickable links to the PR or pipeline page. Any CI dot appears dimmed when unpushed local changes make the status stale. PRs/MRs are checked first, then branch workflows/pipelines for branches with an upstream. Local-only branches show blank; remote-only branches — visible with `--remotes` — get CI status detection. Results are cached for 30-60 seconds; use `wt config state` to view or clear.
### LLM summaries [experimental]
Reuses the [`commit.generation`](@/config.md#commit) command — the same LLM that generates commit messages. Enable with `summary = true` in `[list]` config; requires `--full`. Results are cached until the branch's diff changes.
## Status symbols
The Status column has multiple subcolumns. Within each, only the first matching symbol is shown (listed in priority order):
| Subcolumn | Symbol | Meaning |
|-----------|--------|---------|
| Working tree (1) | `+` | Staged files |
| Working tree (2) | `!` | Modified files (unstaged) |
| Working tree (3) | `?` | Untracked files |
| Worktree | `✘` | Merge conflicts |
| | `⤴` | Rebase in progress |
| | `⤵` | Merge in progress |
| | `/` | Branch without worktree |
| | `⚑` | Branch-worktree mismatch (branch name doesn't match worktree path) |
| | `⊟` | Prunable (directory missing) |
| | `⊞` | Locked worktree |
| Default branch | `^` | Is the default branch |
| | `∅` | Orphan branch (no common ancestor with the default branch) |
| | `✗` | Would conflict if merged to the default branch; with `--full`, includes uncommitted changes |
| | `_` | Same commit as the default branch, clean |
| | `–` | Same commit as the default branch, uncommitted changes |
| | `⊂` | Content [integrated](@/remove.md#branch-cleanup) into the default branch or target |
| | `↕` | Diverged from the default branch |
| | `↑` | Ahead of the default branch |
| | `↓` | Behind the default branch |
| Remote | `\|` | In sync with remote |
| | `⇅` | Diverged from remote |
| | `⇡` | Ahead of remote |
| | `⇣` | Behind remote |
Rows are dimmed when [safe to delete](@/remove.md#branch-cleanup) (`_` same commit with clean working tree or `⊂` content integrated).
### Placeholder symbols
These appear across all columns while the table is loading:
| Symbol | Meaning |
|--------|---------|
| `⋯` | Data is loading |
| `·` | Skipped — collection timed out or branch too stale |
---
## JSON output
Query structured data with `--format=json`:
```console
# Current worktree path (for scripts)
$ wt list --format=json | jq -r '.[] | select(.is_current) | .path'
# Branches with uncommitted changes
$ wt list --format=json | jq '.[] | select(.working_tree.modified)'
# Worktrees with merge conflicts
$ wt list --format=json | jq '.[] | select(.operation_state == "conflicts")'
# Branches ahead of main (needs merging)
$ wt list --format=json | jq '.[] | select(.main.ahead > 0) | .branch'
# Integrated branches (safe to remove)
$ wt list --format=json | jq '.[] | select(.main_state == "integrated" or .main_state == "empty") | .branch'
# Branches without worktrees
$ wt list --format=json --branches | jq '.[] | select(.kind == "branch") | .branch'
# Worktrees ahead of remote (needs pushing)
$ wt list --format=json | jq '.[] | select(.remote.ahead > 0) | {branch, ahead: .remote.ahead}'
# Stale CI (local changes not reflected in CI)
$ wt list --format=json --full | jq '.[] | select(.ci.stale) | .branch'
```
**Fields:**
| Field | Type | Description |
|-------|------|-------------|
| `branch` | string/null | Branch name (null for detached HEAD) |
| `path` | string | Worktree path (absent for branches without worktrees) |
| `kind` | string | `"worktree"` or `"branch"` |
| `commit` | object | Commit info (see below) |
| `working_tree` | object | Working tree state (see below) |
| `main_state` | string | Relation to the default branch (see below) |
| `integration_reason` | string | Why branch is integrated (see below) |
| `operation_state` | string | `"conflicts"`, `"rebase"`, or `"merge"`; absent when clean |
| `main` | object | Relationship to the default branch (see below); absent when is_main |
| `remote` | object | Tracking branch info (see below); absent when no tracking |
| `worktree` | object | Worktree metadata (see below) |
| `is_main` | boolean | Is the main worktree |
| `is_current` | boolean | Is the current worktree |
| `is_previous` | boolean | Previous worktree from wt switch |
| `ci` | object | CI status (see below); absent when no CI |
| `url` | string | Dev server URL from project config; absent when not configured |
| `url_active` | boolean | Whether the URL's port is listening; absent when not configured |
| `summary` | string | LLM-generated branch summary; absent when not configured or no summary |
| `statusline` | string | Pre-formatted status with ANSI colors |
| `symbols` | string | Raw status symbols without colors (e.g., `"!?↓"`) |
| `vars` | object | Per-branch variables from `wt config state vars` (absent when empty) |
### Commit object
| Field | Type | Description |
|-------|------|-------------|
| `sha` | string | Full commit SHA (40 chars) |
| `short_sha` | string | Short commit SHA (7 chars) |
| `message` | string | Commit message (first line) |
| `timestamp` | number | Unix timestamp |
### working_tree object
| Field | Type | Description |
|-------|------|-------------|
| `staged` | boolean | Has staged files |
| `modified` | boolean | Has modified files (unstaged) |
| `untracked` | boolean | Has untracked files |
| `renamed` | boolean | Has renamed files |
| `deleted` | boolean | Has deleted files |
| `diff` | object | Lines changed vs HEAD: `{added, deleted}` |
### main object
| Field | Type | Description |
|-------|------|-------------|
| `ahead` | number | Commits ahead of the default branch |
| `behind` | number | Commits behind the default branch |
| `diff` | object | Lines changed vs the default branch: `{added, deleted}` |
### remote object
| Field | Type | Description |
|-------|------|-------------|
| `name` | string | Remote name (e.g., `"origin"`) |
| `branch` | string | Remote branch name |
| `ahead` | number | Commits ahead of remote |
| `behind` | number | Commits behind remote |
### worktree object
| Field | Type | Description |
|-------|------|-------------|
| `state` | string | `"no_worktree"`, `"branch_worktree_mismatch"`, `"prunable"`, `"locked"` (absent when normal) |
| `reason` | string | Reason for locked/prunable state |
| `detached` | boolean | HEAD is detached |
### ci object
| Field | Type | Description |
|-------|------|-------------|
| `status` | string | CI status (see below) |
| `source` | string | `"pr"` (PR/MR) or `"branch"` (branch workflow) |
| `stale` | boolean | Local HEAD differs from remote (unpushed changes) |
| `url` | string | URL to the PR/MR page |
### main_state values
These values describe the relation to the default branch.
`"is_main"` `"orphan"` `"would_conflict"` `"empty"` `"same_commit"` `"integrated"` `"diverged"` `"ahead"` `"behind"`
### integration_reason values
When `main_state == "integrated"`: `"ancestor"` `"trees_match"` `"no_added_changes"` `"merge_adds_nothing"` `"patch-id-match"`
### ci.status values
`"passed"` `"running"` `"failed"` `"conflicts"` `"no-ci"` `"error"`
Missing a field that would be generally useful? Open an issue at https://github.com/max-sixty/worktrunk.
## See also
- [`wt switch`](@/switch.md) — Switch worktrees or open interactive picker
"#
)]
#[command(args_conflicts_with_subcommands = true)]
List {
#[command(subcommand)]
subcommand: Option<ListSubcommand>,
#[arg(long, value_enum, default_value = "table", hide_possible_values = true)]
format: OutputFormat,
#[arg(long)]
branches: bool,
#[arg(long)]
remotes: bool,
#[arg(long)]
full: bool,
#[arg(long, overrides_with = "no_progressive")]
progressive: bool,
#[arg(long = "no-progressive", overrides_with = "progressive", hide = true)]
no_progressive: bool,
},
#[command(after_long_help = r#"## Examples
Remove current worktree:
```console
$ wt remove
```
Remove specific worktrees / branches:
```console
$ wt remove feature-branch
$ wt remove old-feature another-branch
```
Keep the branch:
```console
$ wt remove --no-delete-branch feature-branch
```
Force-delete an unmerged branch:
```console
$ wt remove -D experimental
```
## Branch cleanup
By default, branches are deleted when they would add no changes to the default branch if merged. This works with both unchanged git histories, and squash-merge or rebase workflows where commit history differs but file changes match.
Worktrunk checks six conditions (in order of cost):
1. **Same commit** — Branch HEAD equals the default branch. Shows `_` in `wt list`.
2. **Ancestor** — Branch is in target's history (fast-forward or rebase case). Shows `⊂`.
3. **No added changes** — Three-dot diff (`target...branch`) is empty. Shows `⊂`.
4. **Trees match** — Branch tree SHA equals target tree SHA. Shows `⊂`.
5. **Merge adds nothing** — Simulated merge produces the same tree as target. Handles squash-merged branches where target has advanced with changes to different files. Shows `⊂`.
6. **Patch-id match** — Branch's entire diff matches a single squash-merge commit on target. Fallback for when the simulated merge conflicts because target later modified the same files the branch touched. Shows `⊂`.
The 'same commit' check uses the local default branch; for other checks, 'target' means the default branch, or its upstream (e.g., `origin/main`) when strictly ahead.
Branches matching these conditions and with empty working trees are dimmed in `wt list` as safe to delete.
## Force flags
Worktrunk has two force flags for different situations:
| Flag | Scope | When to use |
|------|-------|-------------|
| `--force` (`-f`) | Worktree | Worktree has untracked files |
| `--force-delete` (`-D`) | Branch | Branch has unmerged commits |
```console
$ wt remove feature --force # Remove worktree with untracked files
$ wt remove feature -D # Delete unmerged branch
$ wt remove feature --force -D # Both
```
Without `--force`, removal fails if the worktree contains untracked files. Without `--force-delete`, removal keeps branches with unmerged changes. Use `--no-delete-branch` to keep the branch regardless of merge status.
## Background removal
Removal runs in the background by default — the command returns immediately. Logs are written to `.git/wt/logs/{branch}-remove.log`. Use `--foreground` to run in the foreground.
## Hooks
`pre-remove` hooks run before the worktree is deleted (with access to worktree files). `post-remove` hooks run after removal. See [`wt hook`](@/hook.md) for configuration.
## Detached HEAD worktrees
Detached worktrees have no branch name. Pass the worktree path instead: `wt remove /path/to/worktree`.
## See also
- [`wt merge`](@/merge.md) — Remove worktree after merging
- [`wt list`](@/list.md) — View all worktrees
"#)]
Remove {
#[arg(add = crate::completion::local_branches_completer())]
branches: Vec<String>,
#[arg(long = "no-delete-branch", action = clap::ArgAction::SetFalse, default_value_t = true)]
delete_branch: bool,
#[arg(short = 'D', long = "force-delete")]
force_delete: bool,
#[arg(long)]
foreground: bool,
#[arg(short, long, help_heading = "Automation")]
yes: bool,
#[arg(long = "no-verify", action = clap::ArgAction::SetFalse, default_value_t = true, help_heading = "Automation")]
verify: bool,
#[arg(short, long)]
force: bool,
},
#[command(
after_long_help = r#"Unlike `git merge`, this merges the current branch into the target branch — not the target into current. Similar to clicking "Merge pull request" on GitHub, but locally. The target defaults to the default branch.
<!-- demo: wt-merge.gif 1600x900 -->
## Examples
Merge to the default branch:
```console
$ wt merge
```
Merge to a different branch:
```console
$ wt merge develop
```
Keep the worktree after merging:
```console
$ wt merge --no-remove
```
Preserve commit history (no squash):
```console
$ wt merge --no-squash
```
Create a merge commit — semi-linear history:
```console
$ wt merge --no-ff
```
Skip committing/squashing (rebase still runs unless --no-rebase):
```console
$ wt merge --no-commit
```
## Pipeline
`wt merge` runs these steps:
1. **Commit** — Pre-commit hooks run, then uncommitted changes are committed. Post-commit hooks run in background. Skipped when squashing (the default) — changes are staged during the squash step instead. With `--no-squash`, this is the only commit step.
2. **Squash** — Combines all commits since target into one (like GitHub's "Squash and merge"). Use `--stage` to control what gets staged: `all` (default), `tracked`, or `none`. A backup ref is saved to `refs/wt-backup/<branch>`. With `--no-squash`, individual commits are preserved.
3. **Rebase** — Rebases onto target if behind. Skipped if already up-to-date. Conflicts abort immediately.
4. **Pre-merge hooks** — Hooks run after rebase, before merge. Failures abort. See [`wt hook`](@/hook.md).
5. **Merge** — Fast-forward merge to the target branch. With `--no-ff`, a merge commit is created instead — semi-linear history with rebased commits plus a merge commit. Non-fast-forward merges are rejected.
6. **Pre-remove hooks** — Hooks run before removing worktree. Failures abort.
7. **Cleanup** — Removes the worktree and branch. Use `--no-remove` to keep the worktree. When already on the target branch or in the primary worktree, the worktree is preserved.
8. **Post-remove + post-merge hooks** — Run in background after cleanup.
Use `--no-commit` to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless `--no-rebase` is passed. Useful after preparing commits manually with `wt step commit`. Requires a clean working tree.
## Local CI
For personal projects, pre-merge hooks open up the possibility of a workflow with much faster iteration — an order of magnitude more small changes instead of fewer large ones.
Historically, ensuring tests ran before merging was difficult to enforce locally. Remote CI was valuable for the process as much as the checks: it guaranteed validation happened. `wt merge` brings that guarantee local.
The full workflow: start an agent (one of many) on a task, work elsewhere, return when it's ready. Review the diff, run `wt merge`, move on. Pre-merge hooks validate before merging — if they pass, the branch goes to the default branch and the worktree cleans up.
```toml
[pre-merge]
test = "cargo test"
lint = "cargo clippy"
```
## See also
- [`wt step`](@/step.md) — Run individual operations (commit, squash, rebase, push)
- [`wt remove`](@/remove.md) — Remove worktrees without merging
- [`wt switch`](@/switch.md) — Navigate to other worktrees
"#
)]
Merge {
#[arg(add = crate::completion::branch_value_completer())]
target: Option<String>,
#[arg(long, overrides_with = "no_squash", hide = true)]
squash: bool,
#[arg(long = "no-squash", overrides_with = "squash")]
no_squash: bool,
#[arg(long, overrides_with = "no_commit", hide = true)]
commit: bool,
#[arg(long = "no-commit", overrides_with = "commit")]
no_commit: bool,
#[arg(long, overrides_with = "no_rebase", hide = true)]
rebase: bool,
#[arg(long = "no-rebase", overrides_with = "rebase")]
no_rebase: bool,
#[arg(long, overrides_with = "no_remove", hide = true)]
remove: bool,
#[arg(long = "no-remove", overrides_with = "remove")]
no_remove: bool,
#[arg(long = "no-ff", overrides_with = "ff")]
no_ff: bool,
#[arg(long, overrides_with = "no_ff", hide = true)]
ff: bool,
#[arg(short, long, help_heading = "Automation")]
yes: bool,
#[arg(long, overrides_with = "no_verify", hide = true)]
verify: bool,
#[arg(
long = "no-verify",
overrides_with = "verify",
help_heading = "Automation"
)]
no_verify: bool,
#[arg(long)]
stage: Option<crate::commands::commit::StageMode>,
},
#[command(hide = true)]
Select {
#[arg(long)]
branches: bool,
#[arg(long)]
remotes: bool,
},
#[command(
name = "step",
after_long_help = r#"## Examples
Commit with LLM-generated message:
```console
$ wt step commit
```
Manual merge workflow with review between steps:
```console
$ wt step commit
$ wt step squash
$ wt step rebase
$ wt step push
```
## Operations
- [`commit`](#wt-step-commit) — Stage and commit with [LLM-generated message](@/llm-commits.md)
- [`squash`](#wt-step-squash) — Squash all branch commits into one with [LLM-generated message](@/llm-commits.md)
- `rebase` — Rebase onto target branch
- `push` — Fast-forward target to current branch
- [`diff`](#wt-step-diff) — Show all changes since branching (committed, staged, unstaged, untracked)
- [`copy-ignored`](#wt-step-copy-ignored) — Copy gitignored files between worktrees
- [`eval`](#wt-step-eval) — [experimental] Evaluate a template expression
- [`for-each`](#wt-step-for-each) — [experimental] Run a command in every worktree
- [`promote`](#wt-step-promote) — [experimental] Swap a branch into the main worktree
- [`prune`](#wt-step-prune) — Remove worktrees and branches merged into the default branch
- [`relocate`](#wt-step-relocate) — [experimental] Move worktrees to expected paths
- [`<alias>`](#aliases) — [experimental] Run a configured command alias
## See also
- [`wt merge`](@/merge.md) — Runs commit → squash → rebase → hooks → push → cleanup automatically
- [`wt hook`](@/hook.md) — Run configured hooks
<!-- subdoc: commit -->
<!-- subdoc: squash -->
<!-- subdoc: diff -->
<!-- subdoc: copy-ignored -->
<!-- subdoc: eval -->
<!-- subdoc: for-each -->
<!-- subdoc: promote -->
<!-- subdoc: prune -->
<!-- subdoc: relocate -->
## Aliases [experimental]
Custom command templates configured in user config (`~/.config/worktrunk/config.toml`) or project config (`.config/wt.toml`). Aliases support the same [template variables](@/hook.md#template-variables) as hooks.
```toml
# .config/wt.toml
[aliases]
deploy = "make deploy BRANCH={{ branch }}"
port = "echo http://localhost:{{ branch | hash_port }}"
```
```console
$ wt step deploy # run the alias
$ wt step deploy --dry-run # show expanded command
$ wt step deploy --var env=staging # pass extra template variables
$ wt step deploy --yes # skip approval prompt
```
When defined in both user and project config, both run — user first, then project. Project-config aliases require [command approval](@/hook.md#wt-hook-approvals) on first run, same as project hooks. User-config aliases are trusted.
Alias names that match a built-in step command (`commit`, `squash`, etc.) are shadowed by the built-in and will never run."#
)]
Step {
#[command(subcommand)]
action: StepCommand,
},
#[command(
name = "hook",
after_long_help = r#"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` |
| **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 with output logged (use [`wt config state logs`](@/config.md#wt-config-state-logs) to find and manage log files). Use `-v` to see expanded command details for background hooks.
The most common starting point is `post-start` — it runs background tasks (dev servers, file copying, builds) when creating a worktree.
| Hook | Purpose |
|------|---------|
| `pre-switch` | Runs before branch resolution or worktree creation. `{{ branch }}` is the destination as typed (before resolution) |
| `post-switch` | Triggers on all switch results: creating, switching to existing, or staying on current |
| `pre-start` | Tasks that must complete before `post-start`/`--execute`: dependency install, env file generation |
| `post-start` | 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. As usual, post-* hooks run in the background. See [`wt merge`](@/merge.md#pipeline) for the complete pipeline.
# Security
Project commands require approval on first run:
```
▲ repo needs approval to execute 3 commands:
○ pre-start install:
npm ci
○ pre-start build:
cargo build --release
○ pre-start env:
echo 'PORT={{ branch | hash_port }}' > .env.local
❯ Allow and remember? [y/N]
```
- Approvals are saved to `~/.config/worktrunk/approvals.toml`
- If a command changes, new approval is required
- Use `--yes` to bypass prompts — useful for CI and automation
- Use `--no-verify` to skip hooks
Manage approvals with `wt hook approvals add` and `wt hook 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 — a single command or multiple named commands:
```toml
# Single command (string)
pre-start = "npm install"
# Multiple commands (table)
[pre-merge]
test = "cargo test"
build = "cargo build --release"
```
For pre-* hooks, commands in a table run sequentially. For post-* hooks, they run concurrently in the background. Post-* hooks that need ordering guarantees can use [pipeline ordering](#pipeline-ordering).
## 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-verify`. 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:
| Variable | Description |
|----------|-------------|
| `{{ branch }}` | Active branch name |
| `{{ worktree_path }}` | Active worktree path |
| `{{ worktree_name }}` | Active worktree directory name |
| `{{ commit }}` | Active branch HEAD SHA |
| `{{ short_commit }}` | Active branch HEAD SHA (7 chars) |
| `{{ upstream }}` | Active branch upstream (if tracking a remote) |
| `{{ base }}` | Base branch name |
| `{{ base_worktree_path }}` | Base worktree path |
| `{{ target }}` | Target branch name |
| `{{ target_worktree_path }}` | Target worktree path |
| `{{ cwd }}` | Directory where the hook command runs |
| `{{ repo }}` | Repository directory name |
| `{{ repo_path }}` | Absolute path to repository root |
| `{{ primary_worktree_path }}` | Primary worktree path |
| `{{ default_branch }}` | Default branch name |
| `{{ remote }}` | Primary remote name |
| `{{ remote_url }}` | Remote URL |
| `{{ hook_type }}` | Hook type being run (e.g. `pre-start`, `pre-merge`) |
| `{{ hook_name }}` | Hook command name (if named) |
| `{{ vars.<key> }}` | Per-branch variables from `wt config state vars` |
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 |
| merge | feature being merged | = bare vars | merge target |
| remove | branch being removed | = bare vars | where you end up |
Pre and post 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 differs from `worktree_path` in three cases: pre-switch, where the hook runs in the source but `worktree_path` is the destination; post-remove, where the active worktree is gone so the hook runs in primary; and post-merge with removal, same — the active worktree is gone, so the hook runs in target.
Some variables are conditional: `upstream` requires remote tracking; `base`/`target` are only in two-worktree hooks; `vars` keys may not exist. 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 %}"
```
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 63 chars) |
| `hash_port` | `{{ branch \| hash_port }}` | Hash to port 10000-19999 |
The `sanitize` filter makes branch names safe for filesystem paths. 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 `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'])
```
# 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.
```console
$ wt hook pre-merge # Run all pre-merge hooks
$ wt hook pre-merge test # Run hooks named "test" from both sources
$ 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 "test" hook
$ wt hook pre-merge project:test # Run only project's "test" hook
$ wt hook pre-merge --yes # Skip approval prompts (for CI)
$ wt hook pre-start --var branch=feature/test # Override template variable
```
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.
The `--var KEY=VALUE` flag overrides built-in template variables — useful for testing hooks with different contexts without switching to that context.
# Pipeline Ordering [experimental]
By default, all commands in a `post-*` hook run concurrently in the background. The TOML type determines execution order. In the simplest case, a string runs one command:
```toml
post-start = "npm install"
```
Most hooks are a map of named commands, which run concurrently:
```toml
[post-start]
install = "npm install"
build = "npm run build"
lint = "npm run lint"
```
When one command depends on another — `npm run build` needs `npm install` to finish first — use a list to run steps in order:
```toml
# A list of two maps, run in order.
# Each map runs its entries concurrently.
post-start = [
# install runs first
{ install = "npm install" },
# ...then build and lint run concurrently
{ build = "npm run build", lint = "npm run lint" }
]
```
In summary:
- **String** — one command
- **Map** of `name = "command"` pairs — run concurrently
- **List** of maps — run in order
## How it works
Steps run in order. A failing step aborts the pipeline — later steps don't run. A multi-entry map spawns its commands concurrently and waits for all to complete before the next step.
Pre-* hooks ignore pipeline structure — all commands run serially regardless, since pre-* hooks are blocking by nature.
## When to use pipelines
Most hooks don't need pipelines. A table of concurrent post-start commands is fine when they're independent:
```toml
[post-start]
server = "npm run dev -- --port {{ branch | hash_port }}"
copy = "wt step copy-ignored"
```
Pipelines matter when there's a dependency chain — typically setup steps that must complete before other tasks can start. Common pattern: install dependencies, then run build + dev server concurrently.
# Designing Effective Hooks
## pre-start vs post-start
Both run when creating a worktree. The difference:
| Hook | Execution | Best for |
|------|-----------|----------|
| `pre-start` | Blocks until complete | Tasks the developer needs before working (dependency install) |
| `post-start` | Background, parallel | Long-running tasks that don't block worktree creation |
Many tasks work well in `post-start` — they'll likely be ready by the time they're needed, especially when the fallback is recompiling. If unsure, prefer `post-start` for faster worktree creation. For finer control over execution order within `post-start`, see [Pipeline ordering](#pipeline-ordering).
## Copying untracked files
Git worktrees share the repository but not untracked files. [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) copies gitignored files between worktrees:
```toml
[post-start]
copy = "wt step copy-ignored"
```
Use `pre-start` instead if subsequent hooks need the copied files — for example, copying `node_modules/` before `pnpm install` so the install reuses cached packages:
```toml
[pre-start]
copy = "wt step copy-ignored"
install = "pnpm install"
```
## Dev servers
Run a dev server per worktree on a deterministic port using `hash_port`:
```toml
[post-start]
server = "npm run dev -- --port {{ branch | hash_port }}"
[post-remove]
server = "lsof -ti :{{ branch | hash_port }} -sTCP:LISTEN | xargs kill 2>/dev/null || true"
```
The port is stable across machines and restarts — `feature-api` always gets the same port. Show it in `wt list`:
```toml
[list]
url = "http://localhost:{{ branch | hash_port }}"
```
For subdomain-based routing (useful for cookies/CORS), use `.localhost` subdomains which resolve to 127.0.0.1:
```toml
[post-start]
server = "npm run dev -- --host {{ branch | sanitize }}.localhost --port {{ branch | hash_port }}"
```
## Databases
Each worktree can have its own database. A pipeline sets up the container name and connection string as vars, then later steps and hooks reference them:
```toml
post-start = [
"""
wt config state vars set \
container='{{ repo }}-{{ branch | sanitize }}-postgres' \
port='{{ ('db-' ~ branch) | hash_port }}' \
db_url='postgres://postgres:dev@localhost:{{ ('db-' ~ branch) | hash_port }}/{{ branch | sanitize_db }}'
""",
{ db = """
docker run -d --rm \
--name {{ vars.container }} \
-p {{ vars.port }}:5432 \
-e POSTGRES_DB={{ branch | sanitize_db }} \
-e POSTGRES_PASSWORD=dev \
postgres:16
"""},
]
[post-remove]
db-stop = "docker stop {{ vars.container }} 2>/dev/null || true"
```
The first pipeline step derives names and ports from the branch name and stores them as vars. The second step uses `{{ vars.container }}` and `{{ vars.port }}` — expanded at execution time, after the vars are set. The `post-remove` hook reads the same vars.
The connection string is accessible anywhere — not just in hooks:
```console
$ DATABASE_URL=$(wt config state vars get db_url) npm start
```
## Progressive validation
Quick checks before commit, thorough validation before merge:
```toml
[pre-commit]
lint = "npm run lint"
typecheck = "npm run typecheck"
[pre-merge]
test = "npm test"
build = "npm run build"
```
## Target-specific behavior
Different actions for production vs staging:
```toml
post-merge = """
if [ {{ target }} = main ]; then
npm run deploy:production
elif [ {{ target }} = staging ]; then
npm run deploy:staging
fi
"""
```
## Python virtual environments
Use `uv sync` to recreate virtual environments, or `python -m venv .venv && .venv/bin/pip install -r requirements.txt` for pip-based projects:
```toml
[pre-start]
install = "uv sync"
```
For copying dependencies and caches between worktrees, see [`wt step copy-ignored`](@/step.md#language-specific-notes).
## Hook type examples
```toml
# Single command (string) — top-level, before any table headers
post-merge = "cargo install --path ."
[pre-switch]
# Pull if last fetch was more than 6 hours ago
pull = """
FETCH_HEAD="$(git rev-parse --git-common-dir)/FETCH_HEAD"
if [ "$(find "$FETCH_HEAD" -mmin +360 2>/dev/null)" ] || [ ! -f "$FETCH_HEAD" ]; then
git pull
fi
"""
[post-switch]
tmux = "[ -n \"$TMUX\" ] && tmux rename-window {{ branch | sanitize }}"
[pre-start]
install = "npm ci"
env = "echo 'PORT={{ branch | hash_port }}' > .env.local"
[post-start]
copy = "wt step copy-ignored"
server = "npm run dev -- --port {{ branch | hash_port }}"
[pre-commit]
format = "cargo fmt -- --check"
lint = "cargo clippy -- -D warnings"
[post-commit]
notify = "curl -s https://ci.example.com/trigger?branch={{ branch }}"
[pre-merge]
test = "cargo test"
build = "cargo build --release"
[pre-remove]
archive = "tar -czf ~/.wt-logs/{{ branch }}.tar.gz test-results/ logs/ 2>/dev/null || true"
[post-remove]
kill-server = "lsof -ti :{{ branch | hash_port }} -sTCP:LISTEN | xargs kill 2>/dev/null || true"
remove-db = "docker stop {{ repo }}-{{ branch | sanitize }}-postgres 2>/dev/null || true"
```
## 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`](@/config.md) — Manage hook approvals
- [`wt config state logs`](@/config.md#wt-config-state-logs) — Access background hook logs
<!-- subdoc: approvals -->
"#
)]
Hook {
#[command(subcommand)]
action: HookCommand,
},
#[command(
after_long_help = concat!(r#"## Examples
Install shell integration (required for directory switching):
```console
$ wt config shell install
```
Create user config file with documented examples:
```console
$ wt config create
```
Create project config file (`.config/wt.toml`) for hooks:
```console
$ wt config create --project
```
Show current configuration and file locations:
```console
$ wt config show
```
## Configuration files
| File | Location | Contains | Committed & shared |
|------|----------|----------|--------------------|
| **User config** | `~/.config/worktrunk/config.toml` | Worktree path template, LLM commit configs, etc | ✗ |
| **Project config** | `.config/wt.toml` | Project hooks, dev server URL | ✓ |
Organizations can also deploy a system-wide config file for shared defaults — run `wt config show` for the platform-specific location.
**User config** — personal preferences:
```toml
# ~/.config/worktrunk/config.toml
worktree-path = ".worktrees/{{ branch | sanitize }}"
[commit.generation]
command = "CLAUDECODE= MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haiku --tools='' --disable-slash-commands --setting-sources='' --system-prompt=''"
```
**Project config** — shared team settings:
```toml
# .config/wt.toml
[pre-start]
deps = "npm ci"
[pre-merge]
test = "npm test"
```
<!-- USER_CONFIG_START -->
# User Configuration
Create with `wt config create`. Values shown are defaults unless noted otherwise.
Location:
- macOS/Linux: `~/.config/worktrunk/config.toml` (or `$XDG_CONFIG_HOME` if set)
- Windows: `%APPDATA%\worktrunk\config.toml`
## Worktree path template
Controls where new worktrees are created.
**Variables:**
- `{{ repo_path }}` — absolute path to the repository root (e.g., `/Users/me/code/myproject`. Or for bare repos, the bare directory itself)
- `{{ repo }}` — repository directory name (e.g., `myproject`)
- `{{ branch }}` — raw branch name (e.g., `feature/auth`)
- `{{ branch | sanitize }}` — filesystem-safe: `/` and `\` become `-` (e.g., `feature-auth`)
- `{{ branch | sanitize_db }}` — database-safe: lowercase, underscores, hash suffix (e.g., `feature_auth_x7k`)
**Examples** for repo at `~/code/myproject`, branch `feature/auth`:
Default — sibling directory (`~/code/myproject.feature-auth`):
```toml
worktree-path = "{{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}"
```
Inside the repository (`~/code/myproject/.worktrees/feature-auth`):
```toml
worktree-path = "{{ repo_path }}/.worktrees/{{ branch | sanitize }}"
```
Centralized worktrees directory (`~/worktrees/myproject/feature-auth`):
```toml
worktree-path = "~/worktrees/{{ repo }}/{{ branch | sanitize }}"
```
Bare repository (`~/code/myproject/feature-auth`):
```toml
worktree-path = "{{ repo_path }}/../{{ branch | sanitize }}"
```
`~` expands to the home directory. Relative paths resolve from `repo_path`.
## LLM commit messages
Generate commit messages automatically during merge. Requires an external CLI tool.
### Claude Code
```toml
[commit.generation]
command = "CLAUDECODE= MAX_THINKING_TOKENS=0 claude -p --no-session-persistence --model=haiku --tools='' --disable-slash-commands --setting-sources='' --system-prompt=''"
```
### Codex
```toml
[commit.generation]
command = "codex exec -m gpt-5.1-codex-mini -c model_reasoning_effort='low' -c system_prompt='' --sandbox=read-only --json - | jq -sr '[.[] | select(.item.type? == \"agent_message\")] | last.item.text'"
```
### OpenCode
```toml
[commit.generation]
command = "opencode run -m anthropic/claude-haiku-4.5 --variant fast"
```
### llm
```toml
[commit.generation]
command = "llm -m claude-haiku-4.5"
```
### aichat
```toml
[commit.generation]
command = "aichat -m claude:claude-haiku-4.5"
```
See [LLM commits docs](@/llm-commits.md) for setup and [Custom prompt templates](#custom-prompt-templates) for template customization.
## Command config
### List
Persistent flag values for `wt list`. Override on command line as needed.
```toml
[list]
summary = false # Enable LLM branch summaries (requires [commit.generation])
full = false # Show CI, main…± diffstat, and LLM summaries (--full)
branches = false # Include branches without worktrees (--branches)
remotes = false # Include remote-only branches (--remotes)
task-timeout-ms = 0 # Kill individual git commands after N ms; 0 disables
timeout-ms = 0 # Wall-clock budget for the entire collect phase; 0 disables
```
### Commit
Shared by `wt step commit`, `wt step squash`, and `wt merge`.
```toml
[commit]
stage = "all" # What to stage before commit: "all", "tracked", or "none"
```
### Merge
Most flags are on by default. Set to false to change default behavior.
```toml
[merge]
squash = true # Squash commits into one (--no-squash to preserve history)
commit = true # Commit uncommitted changes first (--no-commit to skip)
rebase = true # Rebase onto target before merge (--no-rebase to skip)
remove = true # Remove worktree after merge (--no-remove to keep)
verify = true # Run project hooks (--no-verify to skip)
ff = true # Fast-forward merge (--no-ff to create a merge commit instead)
```
### Switch
```toml
[switch]
cd = true # Change directory after switching (--no-cd to skip)
[switch.picker]
pager = "delta --paging=never" # Example: override git's core.pager for diff preview
timeout-ms = 500 # Wall-clock budget (ms) for picker data collection; 0 disables
```
### Step
```toml
[step.copy-ignored]
exclude = [] # Additional excludes (e.g., [".cache/", ".turbo/"])
```
Built-in excludes always apply: VCS metadata directories (`.bzr/`, `.hg/`, `.jj/`, `.pijul/`, `.sl/`, `.svn/`) and tool-state directories (`.conductor/`, `.entire/`, `.pi/`, `.worktrees/`). User config and project config exclusions are combined.
### Aliases
Command templates that run with `wt step <name>`. See [`wt step` aliases](@/step.md#aliases) for usage and flags.
```toml
[aliases]
greet = "echo Hello from {{ branch }}"
url = "echo http://localhost:{{ branch | hash_port }}"
```
Aliases defined here apply to all projects. For project-specific aliases, use the [project config](@/config.md#project-configuration) `[aliases]` section instead.
### User project-specific settings
For context:
- [Project config](@/config.md#project-configuration) settings are shared with teammates.
- User configs generally apply to all projects.
- User configs _also_ has a `[projects]` table which holds project-specific settings for the user, such as worktree layout and setting overrides. That's what this section covers.
Entries are keyed by project identifier (e.g., `github.com/user/repo`). Scalar values (like `worktree-path`) replace the global value; everything else (hooks, aliases, etc.) appends, global first.
```toml
[projects."github.com/user/repo"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
list.full = true
merge.squash = false
pre-start.env = "cp .env.example .env"
step.copy-ignored.exclude = [".repo-local-cache/"]
aliases.deploy = "make deploy BRANCH={{ branch }}"
```
### Custom prompt templates
Templates use [minijinja](https://docs.rs/minijinja/) syntax.
#### Commit template
Available variables:
- `{{ git_diff }}`, `{{ git_diff_stat }}` — diff content
- `{{ branch }}`, `{{ repo }}` — context
- `{{ recent_commits }}` — recent commit messages
Default template:
<!-- DEFAULT_TEMPLATE_START -->
```toml
[commit.generation]
template = """
<task>Write a commit message for the staged changes below.</task>
<format>
- Subject line under 50 chars
- For material changes, add a blank line then a body paragraph explaining the change
- Output only the commit message, no quotes or code blocks
</format>
<style>
- Imperative mood: "Add feature" not "Added feature"
- Match recent commit style (conventional commits if used)
- Describe the change, not the intent or benefit
</style>
<diffstat>
{{ git_diff_stat }}
</diffstat>
<diff>
{{ git_diff }}
</diff>
<context>
Branch: {{ branch }}
{% if recent_commits %}<recent_commits>
{% for commit in recent_commits %}- {{ commit }}
{% endfor %}</recent_commits>{% endif %}
</context>
"""
```
<!-- DEFAULT_TEMPLATE_END -->
#### Squash template
Available variables (in addition to commit template variables):
- `{{ commits }}` — list of commits being squashed
- `{{ target_branch }}` — merge target branch
Default template:
<!-- DEFAULT_SQUASH_TEMPLATE_START -->
```toml
[commit.generation]
squash-template = """
<task>Write a commit message for the combined effect of these commits.</task>
<format>
- Subject line under 50 chars
- For material changes, add a blank line then a body paragraph explaining the change
- Output only the commit message, no quotes or code blocks
</format>
<style>
- Imperative mood: "Add feature" not "Added feature"
- Match the style of commits being squashed (conventional commits if used)
- Describe the change, not the intent or benefit
</style>
<commits branch="{{ branch }}" target="{{ target_branch }}">
{% for commit in commits %}- {{ commit }}
{% endfor %}</commits>
<diffstat>
{{ git_diff_stat }}
</diffstat>
<diff>
{{ git_diff }}
</diff>
"""
```
<!-- DEFAULT_SQUASH_TEMPLATE_END -->
## Hooks
See [`wt hook`](@/hook.md) for hook types, execution order, template variables, and examples. User hooks apply to all projects; [project hooks](@/config.md#project-configuration) apply only to that repository.
Single command:
```toml
pre-start = "npm ci"
```
Multiple named commands (concurrent for post-*, sequential for pre-*):
```toml
[pre-merge]
test = "npm test"
build = "npm run build"
```
Pipeline — list of maps, run in order (each map concurrent):
```toml
post-start = [
{ install = "npm ci" },
{ build = "npm run build", server = "npm run dev" }
]
```
<!-- USER_CONFIG_END -->
<!-- PROJECT_CONFIG_START -->
# Project Configuration
Create with `wt config create --project`. Examples shown — uncomment and customize for your project.
Location: `.config/wt.toml` (checked into version control and shared with the team).
## Hooks
Project hooks apply to this repository only. See [`wt hook`](@/hook.md) for hook types, execution order, and examples.
```toml
pre-start = "npm ci"
post-start = "npm run dev"
pre-merge = "npm test"
```
## Dev server URL
URL column in `wt list` (dimmed when port not listening):
```toml
[list]
url = "http://localhost:{{ branch | hash_port }}"
```
## Forge platform override
Override platform detection for SSH aliases or self-hosted instances:
```toml
[forge]
platform = "github" # or "gitlab"
hostname = "github.example.com" # Example: API host (GHE / self-hosted GitLab)
```
## Copy-ignored excludes
Additional excludes for `wt step copy-ignored`:
```toml
[step.copy-ignored]
exclude = [".cache/", ".turbo/"]
```
Built-in excludes always apply: VCS metadata directories (`.bzr/`, `.hg/`, `.jj/`, `.pijul/`, `.sl/`, `.svn/`) and tool-state directories (`.conductor/`, `.entire/`, `.pi/`, `.worktrees/`). User config and project config exclusions are combined.
## Aliases
Command templates that run with `wt step <name>`. See [`wt step` aliases](@/step.md#aliases) for usage and flags.
```toml
[aliases]
deploy = "make deploy BRANCH={{ branch }}"
url = "echo http://localhost:{{ branch | hash_port }}"
```
Aliases defined here are shared with teammates. For personal aliases, use the [user config](@/config.md#aliases) `[aliases]` section instead.
<!-- PROJECT_CONFIG_END -->
# Shell Integration
Worktrunk needs shell integration to change directories when switching worktrees. Install with:
```console
$ wt config shell install
```
For manual setup, see `wt config shell init --help`.
Without shell integration, `wt switch` prints the target directory but cannot `cd` into it.
### First-run prompts
On first run without shell integration, Worktrunk offers to install it. Similarly, on first commit without LLM configuration, it offers to configure a detected tool (`claude`, `codex`). Declining sets `skip-shell-integration-prompt` or `skip-commit-generation-prompt` automatically.
# Other
## Environment variables
All user config options can be overridden with environment variables using the `WORKTRUNK_` prefix.
### Naming convention
Config keys use kebab-case (`worktree-path`), while env vars use SCREAMING_SNAKE_CASE (`WORKTRUNK_WORKTREE_PATH`). The conversion happens automatically.
For nested config sections, use double underscores to separate levels:
| Config | Environment Variable |
|--------|---------------------|
| `worktree-path` | `WORKTRUNK_WORKTREE_PATH` |
| `commit.generation.command` | `WORKTRUNK_COMMIT__GENERATION__COMMAND` |
| `commit.stage` | `WORKTRUNK_COMMIT__STAGE` |
Note the single underscore after `WORKTRUNK` and double underscores between nested keys.
### Example: CI/testing override
Override the LLM command in CI to use a mock:
```console
$ WORKTRUNK_COMMIT__GENERATION__COMMAND="echo 'test: automated commit'" wt merge
```
### Other environment variables
| Variable | Purpose |
|----------|---------|
| `WORKTRUNK_BIN` | Override binary path for shell wrappers; useful for testing dev builds |
| `WORKTRUNK_CONFIG_PATH` | Override user config file location |
| `WORKTRUNK_SYSTEM_CONFIG_PATH` | Override system config file location |
| `XDG_CONFIG_DIRS` | Colon-separated system config directories (default: `/etc/xdg`) |
| `WORKTRUNK_DIRECTIVE_FILE` | Internal: set by shell wrappers to enable directory changes |
| `WORKTRUNK_SHELL` | Internal: set by shell wrappers to indicate shell type (e.g., `powershell`) |
| `WORKTRUNK_MAX_CONCURRENT_COMMANDS` | Max parallel git commands (default: 32). Lower if hitting file descriptor limits. |
| `NO_COLOR` | Disable colored output ([standard](https://no-color.org/)) |
| `CLICOLOR_FORCE` | Force colored output even when not a TTY |
<!-- subdoc: show -->
<!-- subdoc: state -->"#)
)]
Config {
#[command(subcommand)]
action: ConfigCommand,
},
}