--- title: .gwm.toml schema description: Every section (worktree, bootstrap.*, hooks.*, theme, tui, tui.keys, tui.open, git_tui, review, doctor) with defaults and validation rules. --- # `.gwm.toml` schema `.gwm.toml` lives at the repo root. Without one, gwm uses sensible defaults (path = `~/cc-worktree//--`, no bootstrap, `lazygit -p {path}` as `l`). With one, you can configure every facet: branch naming, file copies, security guards, launcher commands, TUI behaviour, and doctor checks. The annotated full version lives at [`examples/gwm.toml.example`](https://github.com/kbrdn1/gwm-cli/blob/main/examples/gwm.toml.example), and `gwm init` writes that file unchanged to your repo root. The TUI's Settings panel (`4`) is the same schema, resolved: every key with the value in force and where it came from, editable in place. ![The Settings panel on its Worktree tab: base, path_pattern and branch_pattern with their resolved values](./_assets/config-panel.png) Use `gwm config` for scriptable reads and safe edits: ```bash gwm config get worktree.base gwm config set tui.confirm_countdown_secs 5 gwm config list --prefix review gwm config validate ``` `gwm config set` preserves existing comments and formatting, then validates the file against this schema before returning success. The same schema may also live at `~/.config/gwm/config.toml` as a **user-level global config**, merged underneath each repo's `.gwm.toml`. See [User-level global config](/configuration/global-config). ## `[worktree]` Branch and path conventions. ```toml [worktree] base = "{home}/cc-worktree/{repo}" path_pattern = "{type}-{issue}-{desc}" branch_pattern = "{type}/#{issue}-{desc}" ``` Placeholders: `{home}`, `{repo}`, `{type}`, `{issue}`, `{desc}`, `{repo_path}`, `{repo_parent}`. Tilde (`~/…`) is also expanded. | Placeholder | Expands to | |:----------------|:----------------------------------------------------------------------| | `{home}` | your home directory | | `{repo}` | the repo name | | `{type}` | the branch type (`feat`, `fix`, …) | | `{issue}` | the issue number | | `{desc}` | the description slug | | `{repo_path}` | the main repo's absolute working directory | | `{repo_parent}` | the directory *containing* the main repo (`{repo_path}`'s parent) | `{repo_path}` and `{repo_parent}` let the base sit relative to the repo on disk. For example, `base = "{repo_parent}/worktrees"` puts worktrees in a sibling `worktrees/` dir, matching an editor's `../worktrees` convention (Zed's `git.worktree_directory`) without a per-project editor config. They are additive, so existing `{home}` / `{repo}` bases are unchanged. ```toml [worktree] base = "{repo_parent}/worktrees/{repo}" # a sibling `worktrees/` dir ``` ### `branch_pattern` is read back by a parser derived from itself `branch_pattern` is honoured when gwm **writes** a branch name, and since [#417](https://github.com/kbrdn1/gwm-cli/issues/417) the parser that **reads one back** is compiled from that same pattern. One source of truth, so a repo that customises the pattern keeps issue auto-linking from the branch name, gitmoji selection, `gwm pr` template selection and placeholders, hook placeholders on the remove / bootstrap paths, the TUI rename, and the `doctor` branch-convention check. Before #417 the reader was a fixed regex for the default `{type}/#{issue}-{desc}`, so all of those went quiet on a customised pattern with nothing connecting cause to effect. [#415](https://github.com/kbrdn1/gwm-cli/issues/415) turned that silence into a warning; #417 removed the cause. A pattern may also **freeze** a segment instead of writing it from a placeholder: `feat/#{issue}-{desc}` hardcodes the type, `{type}/#1-{desc}` hardcodes the issue number. gwm reads the frozen literal back, so gitmoji, auto-linking and the rest keep working on those branches, exactly as they did before #417. What such a pattern does cost is stated separately: `gwm create fix 42 x` writes a `feat/` branch, so the type you asked for is not the type anyone reads back, and `gwm doctor` says so. Two things a derived parser genuinely cannot recover, and `gwm doctor` / `gwm config validate` name both: 1. **A pattern whose split can move.** The question is never "is there a separator" but "can the boundary between two placeholders land in more than one place". `{issue}{desc}` writes `42123-x` from `42` and `123-x`, which reads back as `4212` and `3-x`; `{desc}{issue}` is worse, because `a12` is what both `a` + `12` and `a1` + `2` produce, so no parser can be right. A non-empty separator is no guarantee either: `{type}-{issue}9{desc}` writes `feat-42919x` from issue `42` and desc `19x`, and the greedy `\d+` slides right across the `9` to read issue `4291` and desc `x`. Both halves of the rule are narrower than they look. Adjacency alone is fine when the two alphabets are disjoint: `{type}{issue}` writes `feat42`, and since `[a-z]+` stops at the first digit and `\d+` at the first letter there is exactly one split. A separator inside the left placeholder's charset is fine too, as long as the right one cannot supply it back: `-` after `{desc}` can be swallowed but never reappear, because an issue number cannot contain it, so `{desc}-{issue}` stays legal. Even a multi-character separator only counts if the left side can eat a *repeating* prefix of it, which is why `{type}-{issue}9-{desc}` works where `{type}-{issue}9{desc}` does not. Everything that fails is refused with a message naming the fix. 2. **A segment the pattern neither writes nor freezes.** `{type}/{desc}` has no `{issue}` and no literal number to stand in for one, so nothing can read an issue out of a branch it wrote. The warning says which placeholder to add. **The two commands differ on exit code, which matters if you gate CI on them:** | Command | On a pattern that does not round-trip | |:--------|:--------------------------------------| | `gwm config validate` | prints the warning on stderr, exits **`0`**, since a custom pattern is valid configuration rather than an error | | `gwm doctor` | reports a `!` check, so the run exits **`1`** like any other Warning ([exit codes](/integrations/doctor#exit-codes)) | A CI job that runs `gwm doctor` and tolerates Warnings should use `gwm doctor; [ $? -le 1 ]`. `config validate` reads the effective pattern, so one set only in the global `~/.config/gwm/config.toml` is caught too. `path_pattern` is unaffected: it is generation-only, and a worktree's name comes from its directory rather than from re-parsing the pattern. #### which patterns work The field stays free-form. The table below is verified against the real check, not assumed: a test pins it so it cannot drift. **Round-trips fully**, so every branch-name feature keeps working: | Pattern | Note | |:--------|:-----| | `{type}/#{issue}-{desc}` | the default | | `{type}-{issue}-{desc}` | slash-less, and unambiguous: `-` is in neither `[a-z]+` nor `\d+` | | `{type}_{issue}_{desc}` | any separator works, as long as there is one | | `{type}/{issue}-{desc}` | the `#` is decoration, not structure | | `{type}/#{issue}_{desc}` | | | `{repo}/{type}/#{issue}-{desc}`, `wt/{type}/#{issue}-{desc}` | extra leading segments are fine | | `{type}/#{issue}-prefix-{desc}` | a literal wedged between placeholders is fine | | `{type}/#{issue}-{desc}-{repo}` | so is one appended after `{desc}` | | `{desc}/#{issue}-{type}` | the order is yours | | `{type}/#{desc}-{issue}` | a `-` after `{desc}` is legal: `\d+` can never contain it, so there is one split | | `{type}{issue}-{desc}`, `{issue}{type}-{desc}` | adjacent, but `[a-z]+` and `\d+` share no character, so the split cannot move | | `{type}-{issue}9-{desc}` | `\d+` can eat the `9` but never the `-` that would have to follow it | **Refused as unreadable**, with an error naming the fix: - `{issue}{desc}`, `{desc}{issue}`, `{type}{desc}` : adjacent placeholders whose alphabets overlap, so a character can cross the split - `{type}-{issue}9{desc}`, `{type}a{desc}`, `{desc}1{issue}` : a separator both neighbours could contain, so the split can move - `{desc}-{desc}` : the same placeholder twice, since every occurrence expands to the same value **Freezes a segment.** The literal is read back, so nothing stops working on the branches these produce. What is lost is the argument `gwm create` was given, and `gwm doctor` names it: - `feat/#{issue}-{desc}` : every branch is a `feat` branch, whatever type you pass. Harmless when `feat` is the only configured branch type, and `gwm doctor` stays quiet in that case - `{type}/#1-{desc}` : every branch links to issue 1 - `{type}/#{issue}-fixed` : every branch has the description `fixed` The TUI rename form shows a frozen segment, and whether it can be changed depends on where the new value could go. The question is the formatter's, so all three patterns it expands are asked: `branch_pattern`, `worktree.path_pattern`, and `[worktree].base`. When the path pattern writes the segment, editing it renames the worktree directory; when `base` writes it, the worktree moves between base directories, which is what a `base` of `.../{type}` is for. Either way the branch is left alone, which is a real rename and is allowed, and the modal's preview states it by showing the branch unchanged. Only when none of the three writes the segment is the edit refused, because the submit would rebuild the same branch at the same path. Segments `branch_pattern` writes are always editable. The value it shows comes from the worktree's **directory** when `path_pattern` carries the segment and `branch_pattern` does not ([#478](https://github.com/kbrdn1/gwm-cli/issues/478)). Under `branch_pattern = "feat/#{issue}-{desc}"` with the default `path_pattern`, `gwm create fix 42 x` writes the branch `feat/#42-x` and the directory `fix-42-x`, and `fix` exists nowhere else, so renaming the description keeps it instead of moving the directory to `feat-42-…`. The branch always wins for a segment it writes itself: a directory renamed by hand never rewrites the worktree's identity. The recovery is positional first, then an exact match, never a guess. A literal is only read as a segment if it sits **where that segment goes**: before `{issue}` for a type, after it for a description. So `feat/#{issue}-fix` recovers both, even though `feat` and `fix` are each a configured branch type, and `wt/{type}/#{issue}` recovers nothing, since `wt` sits before the type, where no segment goes, so it stays the namespace it looks like. Then each candidate is put to its own test: a branch type is looked up in the repo's configured list, so `feature/#{issue}-{desc}` recovers nothing (`feature` names a namespace, not a type); an issue number has to be all digits; a description is whatever `DESC_RE` accepts. A segment is recovered when every reading of the pattern names it with the same value, and the rule is per segment: `feat/fix-{issue}-{desc}` names two different configured types in the same position and recovers neither, `feat/feat/#{issue}-{desc}` reads `feat` whichever of its two candidates is taken and so freezes the type like any other literal, and `feat/#{issue}-fix/done` is read two ways that disagree about the description while both saying `feat`, so it recovers the type and not the description. **Carries no such segment at all**, so `gwm doctor` warns and says which placeholder to add: - `{issue}-{desc}` : no `{type}` and no literal type to freeze one from - `{type}/{desc}` : no `{issue}`, so issue auto-linking from the branch name is inactive - `{type}/#{issue}` : no `{desc}` One shape the compiler does not mirror: a pattern starting with `~`, because the writer runs tilde expansion as its last step and the reader has no way to undo it. `gwm doctor` reports it as a pattern nothing reads back. ### supported branch types `feat`, `fix`, `hotfix`, `docs`, `test`, `refactor`, `chore`, `perf`, `ci`, `build`. Override per repo if your team uses something else. ## `[[bootstrap.copy]]` File copies from the main checkout into the new worktree. ```toml [[bootstrap.copy]] from = ".env.testing" to = ".env.testing" required = true fallback = "inline" # inline | skip | abort (default: skip when required=false) [[bootstrap.copy]] from = ".env" to = ".env" required = false guards = ["no-aws-rds"] # reference into [[bootstrap.guard]] ``` | Field | Type | Default | Meaning | |:-----------|:----------------|:--------|:-------------------------------------------------------------------------| | `from` | string | _(required)_ | source path, relative to the main checkout | | `to` | string | _(required)_ | destination path, relative to the new worktree | | `required` | bool | `false` | when `true`, missing source aborts the bootstrap unless `fallback` saves | | `guards` | list of strings | `[]` | names of `[[bootstrap.guard]]` rules to apply after the copy | | `fallback` | string | none | `inline` (use `[bootstrap.fallback.]`), `skip`, or `abort` | See [Bootstrap pipeline](/configuration/bootstrap) for execution order. ## `[[bootstrap.guard]]` Regex deny-lists on copied files, generalised from the original "no AWS RDS in `.env`" use case. ```toml [[bootstrap.guard]] name = "no-aws-rds" deny_patterns = ["amazonaws\\.com", "\\.rds\\."] on_match = "seed-from-example" # abort | seed-from-example example_file = ".env.example" ``` See [Regex guards](/configuration/guards) for the full pattern API. ## `[bootstrap.fallback.]` Inline content used when a required `[[bootstrap.copy]]` source is missing and `fallback = "inline"`. ```toml [bootstrap.fallback.env_testing] target = ".env.testing" content = """ APP_ENV=testing DB_CONNECTION=sqlite DB_DATABASE=:memory: """ ``` The `` is referenced implicitly by matching `target` to a copy step's `to`. Multiple fallbacks may coexist. ## `[[bootstrap.no_symlink]]` Refuse to inherit a symlink at the listed path (typically `vendor/`, `node_modules/`), which stops a stray symlink from polluting the main repo's build output. ```toml [[bootstrap.no_symlink]] path = "vendor" [[bootstrap.no_symlink]] path = "node_modules" ``` ## `[[bootstrap.command]]` Legacy shell hooks. New configs should prefer `[[hooks.post_create]]`. When a config defines both legacy `[[bootstrap.command]]` entries and any `[hooks.*]` entries, gwm prints a deprecation warning and treats the legacy commands as extra `post_create` steps to avoid ordering ambiguity. ```toml [[bootstrap.command]] name = "composer install" run = "composer install --no-interaction --prefer-dist" when = "file_exists:composer.json" env = { COMPOSER_IGNORE_PLATFORM_REQ = "ext-imagick" } ``` | Field | Type | Meaning | |:-------|:----------------------|:---------------------------------------------------------------------| | `name` | string | shown in the bootstrap report | | `run` | string | shell line to exec (split via `sh -c`) | | `when` | string | [`when:` predicate](/configuration/when-predicates) (optional) | | `env` | table of string→string | extra env vars injected for this command (optional) | ## `[[hooks.*]]` Lifecycle hooks run around worktree creation, bootstrap, and removal. Supported phases: - `[[hooks.pre_create]]`: before `git worktree add`, with `cwd` at the main repo. - `[[hooks.post_create]]`: after the worktree exists, with `cwd` at the worktree. - `[[hooks.pre_bootstrap]]` / `[[hooks.post_bootstrap]]`: around the bootstrap core. - `[[hooks.pre_remove]]` / `[[hooks.post_remove]]`: before and after a removal, whether it came from `gwm remove` or from `d` in the TUI. ```toml [[hooks.post_create]] name = "install deps" run = "npm ci" when = "file_exists:package-lock.json" on_fail = "warn" # abort | warn | ignore (default: abort) env = { CI = "1" } ``` | Field | Type | Meaning | |:----------|:-----------------------|:--------------------------------------------------------------| | `name` | string | shown in lifecycle reports as `[phase] name` | | `run` | string | shell line to exec via `sh -c` | | `when` | string | [`when:` predicate](/configuration/when-predicates) (optional) | | `env` | table of string→string | extra env vars injected for this hook (optional) | | `on_fail` | string | `abort`, `warn`, or `ignore`; default is `abort` | Hook commands and env values can use placeholders: `{branch}`, `{path}`, `{type}`, `{issue}`, `{desc}`, `{user}`, `{owner}`, `{repo}`. **A placeholder is a value, not a fragment of script.** In `run`, each substituted value is shell-escaped, so a branch name carrying `;`, `|`, `$`, a backtick or a space arrives at your command as one argument instead of changing what the command *is*. Git permits all of those in a ref, and a ref can come from someone else's push. Substitution is single-pass, so a value that itself contains a `{token}` is passed through untouched rather than expanded a second time. In `env`, values are **not** escaped: they go straight to the process environment and never see a shell, so escaping them would put literal quote characters into what your hook reads back. One consequence worth knowing: an **empty** placeholder now expands to an empty argument rather than to nothing at all. On a branch that does not match the branch convention, `{type}` / `{issue}` / `{desc}` are empty, so `mycmd {issue}` passes `mycmd` one empty argument where it previously passed none. Inside a larger word (`mycmd issue={issue}`) nothing changes. **The same context is exported as environment variables**, so a hook can skip placeholder syntax entirely: | Variable | Same as | |:---------|:--------| | `GWM_BRANCH` | `{branch}` | | `GWM_PATH` | `{path}` | | `GWM_TYPE` | `{type}` | | `GWM_ISSUE` | `{issue}` | | `GWM_DESC` | `{desc}` | | `GWM_USER` | `{user}` | | `GWM_OWNER` | `{owner}` | | `GWM_REPO` | `{repo}` | ```toml [[hooks.post_create]] name = "notify" run = 'printf "%s is ready at %s\n" "$GWM_BRANCH" "$GWM_PATH"' ``` Quote them (`"$GWM_BRANCH"`, not `$GWM_BRANCH`): a shell never re-parses metacharacters coming out of a variable, so nothing there can start a second command, but an unquoted expansion is still subject to word splitting and filename globbing, and a ref may contain a tab, a newline or a `*`. An explicit `env` entry with the same name wins over the exported one. Emergency bypass: ```bash gwm create feat 42 auth --skip-hooks pre_create gwm bootstrap auth --skip-hooks pre_bootstrap,post_bootstrap gwm remove auth --force # implies --skip-hooks pre_remove,post_remove ``` The TUI has no `--skip-hooks`: `d` is the plain form of `gwm remove`, so a `pre_remove` that refuses refuses there too. To delete past a hook, use the CLI with `--force`. Running a hook means executing code out of `.gwm.toml`, so the TUI checks the [trust ledger](/configuration/trust-ledger) before a delete in a repo whose config defines `pre_remove` or `post_remove` steps. The alternate screen cannot host the approval prompt, so an unapproved config refuses the delete rather than skipping the hook; `gwm trust add` from a terminal approves it, and `--allow-bootstrap` (or `GWM_ALLOW_BOOTSTRAP=1`) at launch bypasses the ledger for the session. A config whose hooks are all `post_create` runs nothing on a delete and is never asked. ## `[git_tui]` and `[review]` The TUI launcher bindings. Full schema lives in [TUI → Configurable launchers](/tui/launchers). ```toml # l keybinding — pre-v0.6 default kept implicit when omitted [git_tui] command = "lazygit -p {path}" fullscreen = true # R keybinding — inert until configured [review] tool = "lumen" # OR command = "" fullscreen = true # only honoured when command is set default_base = "main" # optional override for the base resolution chain ``` ## `[exec]` and `[clean]` Named profiles for the `gwm exec` and `gwm clean` fan-out commands (issue #324). Both blocks are opt-in: without them, `gwm exec -- ` and the built-in `gwm clean` set behave exactly as before. ```toml [exec] jobs = 1 # global default parallelism; 1 = sequential # Saved commands for `gwm exec --profile `. [exec.profiles.test] command = ["cargo", "test"] # argv ARRAY — no shell [exec.profiles.fmt] command = ["cargo", "fmt", "--all"] jobs = 4 # this profile fans out 4 at a time # Run a profile's command inside a container (issue #421). [exec.profiles.ci] command = ["cargo", "test", "--all-features"] [exec.profiles.ci.container] image = "rust:1.90" # required runtime = "podman" # optional; auto-detected (docker, then podman) extra_args = ["-e", "CI=1", "-v", "gwm-cargo:/usr/local/cargo/registry"] # Saved directory sets for `gwm clean --profile `. # `default` is what `gwm clean` uses WITHOUT --profile. [clean.profiles.default] dirs = ["target", "node_modules", "dist", "build", "coverage", ".turbo"] [clean.profiles.deep] dirs = ["target", "node_modules", "dist", "build", ".cache", ".venv"] ``` **exec: `command` is an argv array, not a shell line.** A profile's `command` is a list of argv tokens (`["cargo", "test"]`) run with **no shell**: no word-splitting, no globbing, no `{path}` placeholders. The program is executed verbatim in each worktree, exactly like the inline `gwm exec -- `. This is a deliberate divergence from `[git_tui]` and `[review]`, whose `command` is a single **shell** line (`"lazygit -p {path}"`). The two semantics are frozen for 1.0; don't expect shell features under `[exec.profiles]`. - `gwm exec --profile ` runs the saved command. `--profile` and an inline `-- ` are **mutually exclusive** (passing both exits 1); an **unknown** profile name exits 1. **exec: `jobs` is bounded parallelism.** `[exec] jobs` is the global default; a profile's `jobs` overrides it; the `--jobs ` flag wins over both. Precedence: `--jobs` > `[exec.profiles.].jobs` > `[exec] jobs` > `1`. **`1` (or absent) runs sequentially** with live, inherited output, the unchanged default. **`> 1` runs up to N worktrees at once**, capturing each one's output and printing it as a per-worktree block (in worktree order) once the fan-out completes, so concurrent runs don't interleave. The aggregate exit code is unchanged: non-zero if any worktree's command failed. **exec: `[container]` runs a profile's command inside a container.** `[exec.profiles..container]` wraps the profile's command in ` run` instead of running it on the host. It rides a **profile only**: the inline `gwm exec -- ` always runs on the host, whatever the config says, so a command line that used to run locally never starts a container behind your back. A containerised run announces itself in the per-worktree header: `━━ feat-1 (/path/to/feat-1) [docker rust:1.90]`. | key | meaning | |---|---| | `image` | **required**, non-empty. The image to run, e.g. `"rust:1.90"` | | `runtime` | The container CLI. Absent ⇒ auto-detected: **`docker` first, then `podman`**. Any Docker-compatible CLI works (`nerdctl`, a wrapper script); an explicit value is honoured even if it isn't on `PATH` | | `extra_args` | Extra `run` flags, spliced in **after** gwm's own and **before** the image: `["-e", "CI=1"]`, `["-v", "cache:/root/.cargo"]`, `["--network", "none"]` | | `selinux_relabel` | Suffix gwm's own mounts with `:z`, for an SELinux-enforcing host (Fedora, RHEL). Off by default because relabelling **writes to the host**, recursively, on the worktree and the main checkout's `.git`. `extra_args` cannot express it: it cannot reach the mounts gwm builds itself | The command gwm builds for each worktree: ```sh run --rm -v : -v
/.git:
/.git -w \ -e GIT_CONFIG_COUNT=2 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0= \ -e GIT_CONFIG_KEY_1=safe.directory -e GIT_CONFIG_VALUE_1=
/.git \ ``` **Host paths are mirrored, and the main checkout's gitdir is mounted alongside.** This is the point of the feature rather than a detail. A linked worktree's `.git` is not a directory, it is a **file** holding the **absolute host path** of `
/.git/worktrees/`. Mount only the worktree and that path does not exist inside the container: no `git status`, no `git describe` to stamp a version, no commit, no hook, and no coding agent that touches git. Mounting the main checkout's `.git` at its own host path fixes it, and once a host-absolute path has to be reproduced anyway, a `/workspace` mount point buys nothing, so `-w` is the worktree's own path and `{path}` / `GWM_PATH` stay true on both sides. When the gitdir already lives inside the worktree (the main checkout, reachable via an explicit slug), the first mount covers it and the second is skipped. - **Every mounted path is declared `safe.directory`.** With a rootful Docker on Linux the container runs as uid 0 while the bind-mounted tree belongs to you, and git refuses a repository it reads as `dubious ownership`, which would undo the mount above. gwm declares the paths **it mounts itself** through `GIT_CONFIG_*` environment: nothing is written to any config file, and the blanket `*` is never used, so the ownership check stays on for everything else. It is the same fix CI providers apply to their own checkouts. What this does **not** change is file ownership: with a rootful daemon, files the command creates (a `target/`, a `node_modules/`) belong to root on the host. Add `extra_args = ["--user", "1000:1000"]` if that matters to you, keeping in mind that some images expect root (a writable `CARGO_HOME`, `apt-get`). - **`gwm exec` allocates no TTY; the TUI overlay does.** A fan-out over N worktrees has no use for a terminal per container. The TUI exec overlay (`e`) spawns into a real pty, so there the container is started with `-i -t` and a REPL, a debugger or any prompting command keeps working, exactly as it does when the profile runs on the host. - **Not supported on Windows.** The wrapper mirrors host paths, and `C:\…` is neither mountable nor resolvable inside a Linux container. Worse, a linked worktree's `.git` file would still name a drive-letter path, so even a translated mount would leave git unable to answer, which is the one thing this feature exists to guarantee. A profile carrying `[container]` is refused there with a message saying so, rather than handed to `docker run` to fail obscurely. - **argv, never a shell string.** gwm hands `docker`/`podman` an argument vector; nothing is quoted, joined, or re-parsed by a shell at any point. This is an invariant, not a happy consequence (see [GHSA-fffq-vg6f-gxqm](https://github.com/kbrdn1/gwm-cli/security/advisories/GHSA-fffq-vg6f-gxqm), branch-name injection through a shell hook). - **The command is the container's CMD**, so an image with an `ENTRYPOINT` receives it as arguments. `extra_args = ["--entrypoint", ""]` opts out. The stock language images (`rust`, `node`, `golang`, …) declare no entrypoint, so the plain form works for them. - **No `interactive` / TTY knob.** `gwm exec` is a fan-out over N worktrees, where a TTY per container means nothing. It will come with the surfaces that can honour it (the multiplexer windows, the PTY overlay), not here. - **`extra_args` come last, so they win.** A repeated flag overrides gwm's: `["-w", "/workspace"]` moves the working directory. Taking that means owning the consequence: the worktree is still mounted at its host path, so `-w /workspace` points at a directory the container does not have. If you also remount it (`["-v", ":/workspace", "-w", "/workspace"]`), the `.git` file inside it still names the host path, and git will only answer because the gitdir mount is there. - **The container is removed when the TUI overlay closes.** Killing a `docker run` client does **not** stop the container: the daemon owns it, and `--rm` only fires once it exits. So a long command would keep writing to the worktree after the overlay visibly closed. The overlay names its container (`--name gwm---`, carrying gwm's pid so two gwm processes on the same worktree cannot agree on a name and tear down each other's container) and removes it on close, from the worktree, so a relative `runtime` resolves the same way it did on the way in. `--name` in `extra_args` is refused for that reason: a runtime honours the last one it is given, which would leave the teardown removing something else. The `gwm exec` fan-out needs none of this, since it never kills its client mid-run. - **A `:` in the worktree path is refused.** It is legal on Unix but is the field separator of `-v source:destination`, so such a mount cannot be expressed. gwm says so instead of letting the runtime reject the spec with a message about neither the worktree nor gwm. - **For caches, prefer a named volume to a host path.** `.gwm.toml` is committed and travels between machines, so `-v gwm-cargo:/usr/local/cargo/registry` is portable where `/Users/you/.cargo` is not. There is no `~` or `$VAR` expansion in `extra_args`: tokens are passed through verbatim. - **Any Docker-compatible socket works, with nothing to integrate.** OrbStack, Colima, Rancher Desktop, Docker Desktop and native Linux Docker all expose the `docker` CLI; gwm only builds an argv for it. Listing them as "supported runtimes" would be marketing rather than integration. **clean: a profile's `dirs` is a complete set that replaces the built-ins.** `[clean.profiles.].dirs` does **not** add to the built-in `target`/`node_modules`/`dist`/`build`: it **replaces** them wholesale. Each entry must be a **single worktree-relative directory name**, one path component. An absolute path, a `..` traversal, an empty string, a bare `.` (which resolves to the worktree root), a **nested** path like `target/debug`, or a name with **git pathspec metacharacters** (`* ? [ ]` or a leading `:`) is rejected (exit 1). The nesting restriction is deliberate for 1.0 (an intermediate component could be a symlink the scan/delete would follow out of the worktree); the metacharacter restriction keeps the git-ignored / tracked-file safety checks matching the literal directory. A leading `-` is fine. Exact duplicate entries are dropped so a directory is reclaimed once. The safety gate (git-ignored + no tracked files + skip symlinks) still applies to every directory in the set. - `gwm clean` **without** `--profile` uses `[clean.profiles.default]` when present, else the built-in four. `gwm clean --profile ` uses that profile's set; an **unknown** name exits 1. ## `[tui]` Runtime knobs for the worktree TUI. ```toml [tui] # Safety countdown (in seconds) for the delete-confirm overlay when `p` is armed. # Accepts 0..=5; values above 5 are clamped on read. Setting it to 0 keeps the # classic single-keystroke modal even when delete-branch-on-remove is armed. confirm_countdown_secs = 3 # Which side the worktree-details sidebar sits on in the side-by-side layout. # "right" (default, pre-#188 behaviour) or "left". Toggle it live with `v`. sidebar_position = "right" # How the sidebar is arranged relative to the table: "stacked" (default), # "side-by-side", or "auto" (width-driven). Cycle it live with `z`. sidebar_orientation = "stacked" # How panes and sidebar sections are framed: "compact" (default, filled # one-line headers) or "bordered" (the lazygit-style boxes, gwm's layout up # to 1.7). See "layout" below. layout = "compact" # Dim the body of whichever pane does not hold focus. Off by default — it # trades contrast for a stronger "where am I" signal. Applies to both layouts. dim_unfocused = false # Fold the sidebar's Status block onto one line (branch · head · state · # diff · age). On by default; set to false for the labelled four-row block. status_one_line = true # Give the in-TUI note editor (`N`) a vim normal mode. On by default: the # first `Esc` leaves insert, and the second one writes and closes. Set it to # false for the modeless editor, where one `Esc` writes and closes. note_vim = true # What the TUI opens in the multiplexer: "pane" (default), "tab" (a whole # tmux window / zellij tab / herdr tab), or "workspace" (herdr only). mux_open_in = "pane" # Which half a mux pane takes: "right" (default), "down", "left" or "up". # herdr takes only the first two. Only meaningful under mux_open_in = "pane". # Also the direction a CLI `--split` takes. mux_pane_direction = "right" # Command that renders a URL inside the terminal, taking an optional {url} # placeholder. Only used when a multiplexer is detected; unset (the default) # sends every link to the external browser, as gwm always has. terminal_browser = "w3m {url}" # Who places that browser: "overlay" (gwm hosts it in a pane or the PTY # overlay, the default) or "detached" (the browser splits on its own and gwm # only launches it). terminal_browser_open_in = "overlay" # How yanked text reaches the clipboard: "auto" (OSC52 over SSH, host tools # otherwise), "osc52", or "tools". clipboard = "auto" # Periodic worktree-list refresh interval, in seconds. Default 60 keeps the # Issue/PR table state reasonably fresh; set to 0 to disable the auto-refresh # loop entirely (you can still refresh on demand with the `refresh` key). auto_refresh_secs = 60 ``` `sidebar_position` (issue #188) sets the default side of the details sidebar in the **side-by-side** layout: `"right"` (default) or `"left"`. `v` toggles it live in the TUI. The **stacked** layout (table on top, sidebar below) ignores this, since there the sidebar is always at the bottom. An unknown value is a **hard config error at load time**. `sidebar_orientation` (issue #365) sets how the sidebar is arranged relative to the table: | Value | Behaviour | |:------|:----------| | `"stacked"` | Table on top, sidebar below. **Default** since issue #217, because the status pane reads best at full terminal width. | | `"side-by-side"` | Always beside the table, whatever the terminal width. | | `"auto"` | Side-by-side at `>= 120` columns, stacked below that. | `z` cycles it live (`auto` → side-by-side → stacked → `auto`; it was `Space` before #484). Before #365 that live choice was runtime-only and reset on every launch; setting the key here makes it stick. An unknown value is a **hard config error at load time**. It is also exposed in the Settings panel under the **TUI** tab. ### layout `layout` (issue [#545](https://github.com/kbrdn1/gwm-cli/issues/545)) chooses how panes and sidebar sections are framed. A bordered section spends two rows and two columns on its frame plus a third row on its bottom-rule counter; a compact one spends a single row. | | `"compact"` (default) | `"bordered"` | |:--|:--|:--| | Section frame | one filled header line | four rules, title in the top one | | Title | ` ISSUE / PR [F] ` (uppercased) | ` Issue / PR [F] ` | | Counter | right of the header line | bottom rule, right-aligned | | Focus signal | header fill (a darkened `accent` band when focused, `section_bg` otherwise) + a bold title | border colour | | Pane boundary | a `muted` rule between the two panes | the boxes themselves | | Worktrees pane height | its row count, capped at its share | its share of the stacked split | | Modal frame | a filled title band, a `section_bg` footer band, no rules | four rules, title in the top one | | Modal padding | one blank row at each end of the content, one column each side | one blank row and two columns each side | **`"compact"` is the default.** The density is the point: the box rules were the single largest source of wasted space on screen, and the mode buys back two rows and two columns per section. **`"bordered"` reproduces gwm's layout up to 1.7** for users who prefer it. It is deliberately left untouched by the compact-mode refinements — no dimming, no separator rule — so it stays a faithful restore rather than a third look. An inactive header is the `section_bg` [theme role](/tui/themes), an indexed colour rather than a translucent white so it stays readable on a terminal without truecolor, with the title on it in `accent`. The focused one trades the pair ([#605](https://github.com/kbrdn1/gwm-cli/issues/605)): an `accent` band carrying that section tone as its text, bold — the dark-on-colour treatment the version chip and the footer's context anchor already use. The band is `accent` pulled down toward `section_bg`, mixed from the two rather than declared as its own role, so overriding either keeps them in tune; a palette with nothing to mix — an ANSI colour name, whose value belongs to the terminal, or a 256-palette index — keeps `accent` itself, which is the default theme's case. Not `focus`, which is the border tone and *more* saturated. How far it is pulled down is bounded by the dark text on it: the two keep a 3:1 contrast. Neither state uses `muted`: a pane's name is how you find the pane to `Tab` into, so it stays legible when the pane is inactive. Focus adds weight to the whole header line on top of the fill. A span that carries its own colour keeps it on either band — the filter `/` prompt, the Working Tree per-category counts — because the header style is patched onto a span rather than substituted for it. Overlays and modals follow the knob too (issue [#594](https://github.com/kbrdn1/gwm-cli/issues/594)). Compact gives them the same treatment the panes get: the title on a filled band across the first row, no rules on any side, and the row every modal already spends on its key hints painted as a quiet `section_bg` band, which is two rows and four columns back per overlay. Content never sits flush against a band at either end: the frame keeps a blank row under the title, and every modal leaves one above its hints (the four full-size overlays and the note editor gained theirs with #594, in both layouts). The bottom-rule counter a bordered modal carries (the Working Tree's per-category counts) moves to the right of that band, where a compact pane puts its own. A panel floating over content is where a rule earns its keep, which is what the missing rules cost. What replaces them is the ground: while a compact modal is up, everything behind it is darkened, the colours mixed toward black rather than just `DIM`, because `DIM` touches the foreground only and the pane header band directly above a full-size overlay would otherwise read as part of it. A palette with no components to mix keeps `DIM` alone. `bordered` is left alone here as everywhere else: it already has its boundary. An unknown value is a **hard config error at load time**. `layout`, `dim_unfocused`, `status_one_line`, `note_vim`, `mux_open_in`, `mux_pane_direction`, `terminal_browser` and `terminal_browser_open_in` are also exposed in the Settings panel under the **TUI** tab (`4`), where cycling the choice applies live. ### dim_unfocused `dim_unfocused` dims the body of whichever pane does not hold focus. **Off by default**, and it applies to **both** layouts — the signal is about focus, not about how a pane is framed. It ships off because it is a trade-off rather than a strict improvement: the inactive pane's content is still information you may be reading, and dimming costs contrast on a surface that is often a screenshot. Users who move between the two panes constantly get a stronger "where am I" cue by turning it on. The dimming uses the terminal's `DIM` attribute rather than repainting in `muted`, so the body keeps its semantic colours — a dirty branch stays yellow, a staged file stays cyan. A terminal that ignores `DIM` simply renders as if the option were off, where the header fill (compact) or the border colour (bordered) still carries the signal. ### status_one_line `status_one_line` (issue [#547](https://github.com/kbrdn1/gwm-cli/issues/547)) folds the Status block's four values onto a single row. **On by default.** ```text status_one_line = true status_one_line = false feat/#42-webhooks · f9e8a58 · ● dirty … Branch feat/#42-webhooks · f9e8a58 Path ~/cc-worktree/webhooks Created 1w Diff +2 -0 State ● dirty Path ~/cc-worktree/webhooks ``` Four labelled rows for four values of a handful of characters each was the sidebar's largest remaining waste once #545 cut the chrome. The fold buys back three rows and hands them to the panes below. It is a knob rather than a compact-mode behaviour, so it applies under **both** layouts — `bordered` folds too unless you turn this off. The `Path` row is never folded in: a path is the one value long enough that sharing a row would clip the path and whatever joined it. **Segment order is the width policy.** The sidebar does not wrap, so a row wider than the pane is clipped on the right: identity (branch, head) leads because it is what the row is for, and `Created` trails because it is the value the pane can most afford to lose. Every segment keeps the [theme role](/tui/themes) it wears in the labelled block. ### note_vim `note_vim` (issue [#557](https://github.com/kbrdn1/gwm-cli/issues/557)) gives the in-TUI note editor (`N`) a vim normal mode. **On by default.** `N` opens in normal mode, `i` / `I` / `a` / `A` / `o` / `O` enter insert, `Esc` goes back to normal, and the second `Esc` writes and closes. The motions are `hjkl`, `w` / `b` / `e` and their `W` / `B` / `E`, `0` / `^` / `$`, `gg` / `G`, plus `x` and `dd`. The full table is in [TUI → keybindings](/tui/keybindings#vim-normal-mode-557). That second sentence is the cost, so it is worth stating on its own: **the first `Esc` no longer writes and closes**. Set `note_vim = false` and the editor is exactly the one #515 shipped, where every printable is text and one `Esc` writes and closes. It changes no binding: `[tui.keys.modal.note]` holds the same four verbs either way, and an unmodified printable bound to one of them is refused at load time with or without the mode. What it does change is what unbound printables mean, which is why the modal title carries a `NORMAL` / `INSERT` chip while it is on. No counts, no registers, no undo. `Ctrl+e` still hands the file to the real vim, and that handoff is the answer for anything this mode does not cover. ### mux_open_in `mux_open_in` (issue [#608](https://github.com/kbrdn1/gwm-cli/issues/608)) decides *what* the TUI opens in the multiplexer, for both `t` on the worktree list and `o` on the [agent sessions overlay](/tui/keybindings#agent-sessions-overlay-a). A multiplexer nests panes inside tabs, and herdr nests tabs inside workspaces; this picks the level. | Value | Behaviour | |:------|:----------| | `"pane"` | **Default.** Split the current pane. Which half it takes is [`mux_pane_direction`](#mux_pane_direction) below. | | `"tab"` | A whole screen of its own: a `tmux` **window**, a zellij or herdr **tab**. One thing under three names; the status bar uses your backend's word. | | `"workspace"` | herdr's level above a tab. **herdr only.** | **`"workspace"` is refused on tmux and zellij, not downgraded.** Neither has a level there, so `t` says so on the status bar and opens nothing: ``` tmux has no workspace level: herdr is the only backend with one ``` Quietly opening a tab instead would leave the setting describing something that did not happen, which is worse than a key that refuses and explains itself. The two backends do have *sessions*, which is the structural analogue, but gwm runs inside one: tmux would need two commands to create and switch to a sibling, and zellij refuses to nest sessions at all. Under `"workspace"`, herdr runs `herdr workspace create --label --cwd --focus`. It is `tab create` minus the `--workspace` it would be creating. A `[tui.macro*]` with `open_in = "mux_pane"` reads this key too, and under `"workspace"` it falls back to the PTY overlay on **every** backend: tmux and zellij have no workspace, and herdr's `workspace create` takes no trailing command any more than its `tab create` does. The status bar names which one refused. This key does not reach the CLI. `gwm tmux|zellij|herdr ` spells its own target: bare is a tab, `--split` is a pane. There is no workspace flag, and `--workspace` is taken by the repo-set flag (issue #36). ### mux_pane_direction `mux_pane_direction` (issue [#589](https://github.com/kbrdn1/gwm-cli/issues/589)) decides which half a pane takes, under `mux_open_in = "pane"` and on the CLI's `--split`. Both TUI keys that open one read it: `t` and `o`. | Value | Behaviour | |:------|:----------| | `"right"` | **Default.** Side by side: `tmux split-window -h`, `zellij action new-pane --direction right`, `herdr pane split --direction right`. | | `"down"` | Stacked below: `tmux split-window -v`, `--direction down` on the other two. | | `"left"` | Side by side, other side: `tmux split-window -h -b`, `--direction left` on zellij. **Refused by herdr.** | | `"up"` | Stacked above: `tmux split-window -v -b`, `--direction up` on zellij. **Refused by herdr.** | **`right` is a behaviour change for tmux and zellij users**, and a deliberate one. Up to 1.9 a split carried no direction, so each backend answered for itself: tmux fell back to `-v` and stacked the pane, zellij took "the biggest available space", and herdr went right because gwm hardcoded it there. `right` is what the `--split` help has promised since it shipped ("a horizontal split of the current pane"), and the half that is actually free on a wide screen. Set `mux_pane_direction = "down"` to get the old tmux behaviour back. `-b` is where the second pair comes from, and it is worth knowing before reading tmux's flags as the words suggest: `-h` is the *horizontal split* and puts the pane to the **right**, `-v` stacks it **below**, and `-b` ("before") flips the side on whichever axis was picked. Measured on tmux 3.7c by reading the new pane's geometry back through `split-window -P -F` rather than inferring it from pane order. **`left` and `up` are refused on herdr**, which declares its `--direction` as `[possible values: right, down]` (0.8.2). The status bar says so rather than substituting a direction herdr does have: ``` herdr splits only right or down: left and up are tmux and zellij directions ``` This is [`mux_open_in = "workspace"`](#mux_open_in) inverted: there one backend can do what the other two cannot, here two can do what the third cannot. An unknown value is a **hard config error at load time**, and so is `"window"`, which moved to `mux_open_in = "tab"`. On the CLI, `gwm tmux|zellij|herdr --split` takes its direction from here unless `--direction ` overrides it for that one invocation. ### terminal_browser `terminal_browser` (issue [#590](https://github.com/kbrdn1/gwm-cli/issues/590)) names a command that renders a URL **inside the terminal**, so a link opens next to gwm instead of pulling you out of the workspace gwm is sitting in. It covers every link the TUI opens: the browse-links menu (`B`), the open-menu Issue and PR picks, a row in the rich PR/issue view, a CI check's details URL, and `.` for the docs. ```toml [tui] terminal_browser = "w3m {url}" # or lynx / carbonyl / browsh ``` The `{url}` placeholder is optional. A bare `terminal_browser = "w3m"` gets the URL appended as the last argument, which is what all four of those tools take anyway; `{url}` is for a command that needs it somewhere else (`browser --url={url} --no-sandbox`). **Unset is the default, on every platform, and it is exactly gwm's behaviour up to 1.9**: every link goes to the system browser (`open` / `xdg-open` / `explorer`). Setting the key never removes that path: it adds a rung above it. Where a URL actually lands: | Situation | Where it opens | |:----------|:---------------| | `terminal_browser` unset | System browser. Silently, because the default says nothing about a feature you did not ask for. | | No multiplexer (`$TMUX` / `$ZELLIJ` / `$HERDR_ENV` all unset) | System browser, with the reason in the status bar. | | The browser is not on `$PATH` | System browser, status bar naming the binary. | | `terminal_browser_open_in = "detached"` | The browser is **launched and nothing else**: it places itself. See below. | | A multiplexer whose container takes a command (tmux, a zellij pane) | **A new pane / tab**, running the browser beside gwm. | | A multiplexer whose container takes none (herdr, a zellij tab, any `workspace`) | The **PTY overlay**, with the refusal in the status bar. | **The multiplexer is the gate, deliberately.** A terminal browser with nowhere to put it is worse than the system browser, so gwm does not reach for one outside a multiplexer. The level it opens at is [`mux_open_in`](#mux_open_in) and [`mux_pane_direction`](#mux_pane_direction), the same pair `t` and `o` read, so you do not configure where panes open twice. **herdr and zellij tabs get the overlay rather than a pane.** Neither takes a command in the argv that opens the container ([`mux_open_in`](#mux_open_in) explains the same limit for `o`), and herdr's way around it (open, wait for the new shell's prompt, then type the line) measured up to ~60s on a worktree with `direnv` and a nix flake. For a browser that is the wrong trade: the key was pressed, a page has to appear. The overlay still renders it in the terminal; it covers gwm instead of sitting beside it, which is why the status bar names the backend that refused. #### terminal_browser_open_in Both shapes above host the browser: gwm opens a pane, or falls back to the PTY overlay. That assumes the browser draws inside whatever TTY it is handed, which is true of `w3m`, `lynx` and every other text browser. Some do not. `terminal-browser open {url} --split right` asks the multiplexer for its own pane and exits about four seconds later, and it renders through the terminal's image protocol, which positions against the real window rather than the region it was given. Hosted in the PTY overlay it does not land in the overlay's rect: it paints over the top-left corner of the screen, whatever rect gwm passes, and the mouse hits the same wall. Hosted in a gwm pane it splits twice, once for gwm's pane and once for its own. `terminal_browser_open_in = "detached"` is for those: gwm launches the command and stops there. ```toml [tui] terminal_browser = "terminal-browser open {url} --split right" terminal_browser_open_in = "detached" ``` | Value | Behaviour | |:------|:----------| | `"overlay"` | **Default.** gwm hosts the browser, in a mux pane or the PTY overlay, per the table above. | | `"detached"` | gwm launches the command and nothing else. No pane of its own, no overlay. | **The command has to open its own pane.** `detached` means gwm opens none, so a command that renders in the pane it is given takes over the one gwm is drawing in. `terminal-browser` documents this directly: `open` "opens the browser in the current pane. Pass `--split` to open it in a new split pane instead". Getting this wrong looks exactly like the bug `detached` exists to fix, so check the flag before blaming the mode. **The two gates stay in front of it, and the order is the point.** Placing itself still means asking a multiplexer for a pane, so with none running `terminal-browser open` would take over the pane gwm is drawing in, which is worse than the system browser it falls back to instead. A browser that is not on `$PATH` still falls back too, rather than being launched into nothing. `detached` changes where the browser goes, never whether gwm reaches for one. An unknown value is a **hard config error at load time**, so a typo fails loudly rather than falling back to `overlay` and painting over the screen with no explanation. Also exposed in the Settings panel under the **TUI** tab, next to `terminal_browser`. **The template is an argv, not a shell line.** It is tokenised and executed directly, so a leading `KEY=VAL` assignment is refused rather than run: no shell is there to apply it, and `argv[0]` would be the literal string `NO_COLOR=1`. Write `env NO_COLOR=1 w3m {url}` instead, which works because `env` is a real binary. A `=` inside a later argument is untouched, so `browser --url={url}` is fine. **The URL is one argument, always.** The template is tokenised **before** the placeholder is substituted, so `w3m {url}` and `w3m "{url}"` are the same command and the URL's `?`, `&` and `#` cannot become shell syntax: gwm owns the quoting rather than inheriting whatever the template wrote. Only absolute `http`/`https` URLs are passed on; anything else falls back to the system browser (see the branch-name injection advisory [GHSA-fffq-vg6f-gxqm](https://github.com/kbrdn1/gwm-cli/security/advisories/GHSA-fffq-vg6f-gxqm) for why the order matters). An empty string reads as unset, which is how the Settings panel turns the feature back off. `clipboard` (issue #367) selects how yanked text (path, branch, worktree name, command logs) reaches the clipboard: | Value | Behaviour | |:------|:----------| | `"auto"` | **Default.** OSC52 when `$SSH_TTY` or `$SSH_CONNECTION` is set, host tools otherwise. | | `"osc52"` | Always emit the OSC52 escape sequence. | | `"tools"` | Always use `pbcopy` / `wl-copy` / `xclip` / `xsel` / `clip.exe`. | The host tools write to the clipboard of the machine gwm runs on. Over SSH that is the wrong one, and it fails *silently*: on a remote macOS host `pbcopy` exists, succeeds, and gwm reports `yanked branch name (pbcopy)` while your actual clipboard is untouched. OSC52 hands the text to your terminal emulator, which owns the clipboard you paste from. An unknown value is a **hard config error at load time**. Also exposed in the Settings panel under the **TUI** tab. Three caveats, all rooted in the fact that **OSC52 is never acknowledged**: gwm can report that it emitted the sequence, never that the terminal took it: - **tmux** needs `set -g allow-passthrough on`. gwm wraps the sequence in DCS passthrough, but the option is off by default since tmux 3.3 and gwm cannot detect or enable it. - **GNU screen** (`$STY`) gets the host tools instead: screen needs its own chunked form, and an unwrapped sequence there is silently swallowed. Falling back is the honest failure. - **Terminal support varies**: kitty, WezTerm, Alacritty and iTerm2 (with the setting enabled) honour OSC52; Terminal.app does not. `"tools"` is the escape hatch. It is also the answer for a stale `$SSH_CONNECTION` in a tmux pane, which can make `auto` guess wrong. `auto_refresh_secs` (issue #285) drives a periodic background refresh of the worktree list so the Issue/PR table state stays current without a manual keystroke. It is a non-negative integer count of seconds; the default is `60` and `0` disables the loop. It is also exposed in the Settings panel under the **TUI** tab. See [TUI → Confirm-overlay countdown](/tui/confirm-countdown) and [TUI → Sidebar](/tui/sidebar). ## `[tui.macro1]` and `[tui.macro2]` User-defined commands fired from the worktree list (issue #290). Each is an optional sub-table; when present, the `macro_one` / `macro_two` actions (bound to `h` / `H` by default, see `gwm tui keys`) run the command **in the selected worktree's directory**. When the sub-table is absent the key is a no-op. ```toml [tui.macro1] command = "gh pr view --web" # forwarded to the OS shell (`sh -c`) open_in = "pty" # "pty" (default) | "mux_pane" [tui.macro2] command = "lazygit" open_in = "mux_pane" ``` - `command` (required) is the shell command to execute, forwarded to the OS shell (`sh -c …`). - `open_in` (optional, default `"pty"`) chooses where the command runs: - `"pty"`: an embedded PTY overlay, same as the lazygit / terminal launchers; the TUI suspends until the command exits. - `"mux_pane"`: a new pane of the running multiplexer (tmux via `$TMUX`, or Zellij via `$ZELLIJ`), falling back to a PTY overlay when no multiplexer is detected. The value is `snake_case`, so write `"mux_pane"` (not `"muxpane"`). Both keys are validated at load time; an unknown field under the sub-table errors. ## `[tui.keys.modal.]` Beyond the list-view keymap, the per-modal keymaps are rebindable too (issue #219). Where `[tui.keys]` holds **arrays** for global `View::List` actions (`quit = ["q"]`), the `[tui.keys.modal]` namespace holds **tables**, one per modal context, each binding a context's verbs: ```toml [tui.keys.modal.confirm] confirm = ["y"] cancel = ["n", "Esc"] [tui.keys.modal.help] close = ["Esc", "q", "?"] # Nested contexts use a dotted stage path: [tui.keys.modal.link.choose_target] accept = ["Enter"] cancel = ["Esc"] ``` Each verb takes an array of keys. Unlike the list-view keymap, **modal bindings are single keystrokes**: multi-key chords like `g g` are rejected. An override replaces the verb's default key set; verbs left unmentioned keep their defaults. The `note` context (issue #515) is the narrowest one: the editor is always typing, so every printable plus `Enter`, `Backspace` and `Delete` route to the buffer before any lookup, and only its two exits are bindable. ```toml [tui.keys.modal.note] close = ["Esc"] # writes the buffer and closes open_editor = ["Ctrl+e"] # hands the same file to $EDITOR ``` Binding either of those to a printable, `Enter`, `Backspace` or `Delete` is refused at load time: the key would type instead of firing, leaving the editor with no way out. The set of contexts and verbs is what the TUI actually exposes. Run `gwm tui keys` to print every modal context (`confirm`, `create`, `help`, `command_logs`, `working_tree`, `commits`, `config`, `config.edit`, `report`, `open_menu`, `palette`, `link.choose_target`, `link.input_number`, …) with its verbs and resolved keys. Binding under a context **group** rather than a leaf stage (e.g. `[tui.keys.modal.link]` instead of `[tui.keys.modal.link.choose_target]`) is a load-time error that names the stage to use. Load-time validation rejects, as a hard `GwmError::Config`: an unknown modal context, an unknown verb for a context, an unparsable or multi-stroke key, and a per-context conflict. Note that a handful of names (`create`, `help`, `command_logs`, `working_tree`, `commits`, `link`) exist as both a global action and a modal context. They do **not** collide: the global binding is the array at `tui.keys.`, the modal one is the table at `tui.keys.modal.`, two different paths, and both can live in the same file. What is rejected is putting the modal table at the global path, which names the fix: ```toml [tui.keys] working_tree = ["W"] # global: opens the overlay [tui.keys.modal.working_tree] # modal: its verbs, same name, no conflict close = ["Esc", "q"] ``` ``` tui.keys.working_tree: expected an array of chords; modal contexts go under [tui.keys.modal.], got table ``` See [TUI → Keymap and palette](/tui/keymap-and-palette). ## `[tui.keys]` Configurable keymap (issue #87). Rebind every list-view action with crossterm-grammar keys, including multi-key chords like `g g`. An override **replaces** the default binding for that action; it does not merge. Passing an empty list (`down = []`) unbinds the action entirely. ```toml [tui.keys] down = ["j", "Down"] up = ["k", "Up"] top = ["g g"] bottom = ["G", "End"] quit = ["q"] sync = ["s"] delete_branch = ["D"] toggle_sidebar = ["V"] cycle_sidebar_layout = ["z"] toggle_select = ["Space"] command_palette = [":"] ``` Each value is a list of chord strings. Within a chord string, whitespace separates keystrokes (so `"g g"` is press `g` twice) and `+` separates modifiers from the key (`"Ctrl+x Ctrl+s"`). Use `"Space"` for the literal space character. Recognised modifiers: `Ctrl`, `Alt`, `Shift`. Named keys include `Tab`, `Enter`, `Esc`, `Up`, `Down`, `Left`, `Right`, `Backspace`, `BackTab`, `Home`, `End`, `PageUp`, `PageDown`, `Insert`, `Delete`, `Space`. A shifted letter (`"V"`, `"Shift+v"`) canonicalises to the same uppercase stroke regardless of how the terminal reports shift. ### rebindable actions These are the resolved defaults of the current binary (post-#290 keymap redesign). `gwm tui keys` prints the live list with a per-row source, and stays authoritative if a future build shifts a default. | Action slug | Default chord(s) | Verb | |:--------------------------|:-----------------|:------------------------------------------------------| | `down` | `j`, `Down` | move selection down | | `up` | `k`, `Up` | move selection up | | `top` | `g g` | jump to first row | | `bottom` | `G`, `End` | jump to last row | | `focus_swap` | `Tab` | swap focus between table and sidebar | | `focus_worktrees` | `1` | focus the worktrees pane | | `focus_status` | `2` | focus the status pane | | `command_logs` | `3` | open the Command Logs overlay | | `config_panel` | `4` | open the Settings panel | | `working_tree` | `W` | open the Working Tree listing at full size | | `commits` | `c` | open the commit listing full size, with load-more | | `toggle_sidebar` | `V` | show / hide the details sidebar | | `toggle_sidebar_mode` | `S` | cycle the Details panel (`commits` ↔ `stashes`) | | `cycle_sidebar_layout` | `z` | cycle layout (`auto` → side-by-side → stacked → auto) | | `toggle_sidebar_position` | `v` | flip the sidebar left ↔ right | | `filter` | `/` | open the fuzzy filter bar | | `refresh` | `f` | refresh the worktree list | | `sync` | `s` | fetch + rebase onto upstream (`gwm sync`) | | `create` | `n` | open the new-worktree overlay | | `toggle_select` | `Space` | mark / unmark the row for a bulk delete | | `delete` | `d` | open the delete-confirm overlay (on the marked rows) | | `bootstrap` | `b` | re-run bootstrap on the selected worktree | | `delete_branch` | `D` | arm delete-branch-on-remove | | `pull` | `p` | `git pull` on the selected branch (async) | | `push` | `P` | `git push` on the selected branch (async) | | `edit_worktree` | `e` | rename the worktree / branch | | `edit_note` | `N` | edit the worktree's note in a modal | | `exit_to_worktree` | `E` | quit and print the selected path to stdout | | `lazygit_pty` | `l` | lazygit in the embedded PTY overlay (`[git_tui]`) | | `lazygit_fullscreen` | `L` | lazygit fullscreen | | `review_pty` | `r` | review tool in the PTY overlay (`[review]`) | | `review_fullscreen` | `R` | review tool fullscreen | | `terminal_pty` | `o` | open dispatch in the PTY overlay (`[tui.open]`) | | `terminal_fullscreen` | `O` | open-mode menu / fullscreen terminal | | `yank_path` | `Y` | copy the worktree path | | `yank_branch_name` | `y` | copy the branch name | | `yank_worktree_name` | `w` | copy the worktree name | | `mux_pane` | `t` | open the worktree in a new tmux / zellij / herdr pane | | `macro_one` | `h` | run `[tui.macro1]` | | `macro_two` | `H` | run `[tui.macro2]` | | `browse_links` | `B` | browse the issue / PR links | | `open_docs` | `.` | open the docs in the browser | | `link` | `i` | open the issue/PR link prompt | | `fetch_github` | `F` | fetch GitHub issue/PR state | | `help` | `?` | toggle the help overlay | | `quit` | `q` | quit the TUI | | `command_palette` | `:` | open the command palette | `Ctrl+C` (emergency quit) and the contextual `Esc` / `Enter` keys are hard-coded escape hatches outside the keymap, and they keep working regardless of `[tui.keys]`. **Load-time validation** rejects, as a hard `GwmError::Config`: - an **unknown action** slug (the error points you at `gwm tui keys` for the full list); - a **parse error** in a chord string (empty, dangling `+`, unknown modifier, unknown key name); - a **chord conflict**: two actions bound to the same chord; - a **prefix collision**: a chord that is a strict prefix of another bound chord (e.g. binding `g` alone while `g g` is also bound). Resolving this at runtime would need a Vim-style timeout in the event loop, so gwm refuses the config instead. `gwm tui keys` prints the resolved keymap with a per-row source; `gwm doctor` warns when no non-`Ctrl+C` binding for `quit` survives the overrides. The help overlay (`?`) is keymap-driven, so the documentation always matches the resolved bindings. See [TUI → Keymap and palette](/tui/keymap-and-palette). ## `[tui.open]` What the `o` key does. Full details in [TUI → Open dispatch](/tui/open-dispatch). ```toml [tui.open] mode = "shell" # shell (default) | editor | finder shell_cmd = "" # override $SHELL when mode=shell; empty = unset editor_cmd = "hx" # override $EDITOR when mode=editor; empty = unset ``` Unknown `mode` values are a **hard config error at load time**, not a silent fallback. ## `[tui.agent_resume]` How `o` on the [agent sessions overlay](/tui/keybindings#agent-sessions-overlay-a) resumes each backend in the new multiplexer pane (issue [#591](https://github.com/kbrdn1/gwm-cli/issues/591)). ```toml [tui.agent_resume] claude = "claude -r {session}" # Claude Code codex = "codex resume {session}" # Codex opencode = "opencode -s {session}" # opencode vibe = "vibe --resume {session}" # Mistral Vibe ``` Those are the defaults, so an absent block resumes all four correctly and no `.gwm.toml` change is required. They are configuration rather than a hardcoded table because these are four third-party CLIs on their own release cadence: the day one of them renames its flag, a gwm release should not be what stands between you and a working `o`. `{session}` is the detected session id, substituted in a single pass. It is read out of the tool's own artefacts on disk, so it is **data**: gwm refuses to resume unless it is a plain id (ASCII letters, digits, `-`, `_`, `.`), and quotes it on top. The charset is the load-bearing half, because the template around the placeholder is yours: write `claude -r "{session}"` and quoting alone would leave a `$(…)` in the id live, since the quotes it adds are literal inside your double quotes. Every real id is a UUID or a slug, so nothing legitimate is refused. An empty string on a key reads as unset and gives the default back, the convention `shell_cmd` / `editor_cmd` already use. The **level** the pane opens at is not set here: `o` reads [`mux_open_in`](#mux_open_in) and [`mux_pane_direction`](#mux_pane_direction) like `t` does. That is also where its refusals come from, since not every level can carry a command: a zellij tab takes none, and neither herdr level does. ## `[theme]` Role-based TUI colours (issue #33). Every visual signal maps to a semantic role rather than a hard-coded colour, so a `[theme]` block re-skins the whole TUI. Two knobs: an optional `preset` (a built-in palette) and any number of per-role overrides on top of it. ```toml [theme] preset = "catppuccin" # optional — seed every role from a built-in palette focus = "#89b4fa" # per-role override on top of the preset accent = "mauve" # (illustrative — named colours below) ``` `preset` (optional) seeds every role from a built-in palette; when absent, gwm starts from the default scheme (the pre-#33 hardcoded look). Built-in presets: | Preset | Alias(es) | |:--------------|:--------------------------------| | `catppuccin` | `catppuccin-mocha` | | `gruvbox` | `gruvbox-dark` | | `tokyo-night` | `tokyonight` | | `claude-dark` | `claude` | `gwm theme list` prints the preset names; `gwm theme show ` dumps a preset as a copy-pasteable, round-trippable `[theme]` block. ### roles Override any of these keys individually: a per-role override **wins over the preset** (and over the default). The override is applied on top of whichever base `preset` selects. | Role | Used for | Default | |:---------------|:--------------------------------------------------------|:--------------| | `focus` | focused border / cursor / active overlay highlight | `cyan` | | `accent` | header title, help-overlay key hints, palette prompt | `cyan` | | `branch` | branch name in lists and the sidebar identity card | `green` | | `clean` | "working tree is clean" status indicator | `green` | | `dirty` | "working tree is dirty" status indicator | `yellow` | | `main` | main / trunk worktree badge | `yellow` | | `locked` | locked worktree badge (`🔒`) | `magenta` | | `prunable` | prunable worktree badge (`⚠`) | `red` | | `muted` | de-emphasised text: hints, footers, placeholders | `dark_gray` | | `selection_bg` | selection highlight background | `dark_gray` | | `name` | worktree name + `Issue #N` / `PR #N` summary heads | `white` | | `path` | worktree path column in the table | `gray` | | `staged` | staged (index-side) git-status changes | `cyan` | | `modified` | worktree-side git-status modifications | `yellow` | | `untracked` | untracked / created git-status entries (`??`) | `green` | | `section_bg` | compact-mode section header fill (`[tui] layout`) | `236` | ### colour value formats Each role accepts a colour in one of three forms: - **Named**: `cyan`, `Cyan`, `dark_gray`, `bright_blue` (case-insensitive). - **256-palette index**: `0`..=`255` (e.g. `220`). - **Hex**: `#RRGGBB` (six hex digits + leading `#`, e.g. `#89b4fa`). Short `#RGB` form is **not** supported; the parser refuses to guess. **Validation runs at load** (issue #33): an unknown `preset`, an unknown role key, or an unparsable colour value are each a hard `GwmError::Config`, attributed to the offending `theme.` coordinate. Users who omit `[theme]` see the unchanged default scheme. See [TUI → Themes](/tui/themes). ## `[doctor]` Knobs for `gwm doctor`. Currently exposes the trunk list used by the orphan-branch check. ```toml [doctor] # Branches the orphan check treats as "merge destinations". A gwm-style # branch fully reachable from one of these is preserved per CONTRIBUTING # ("never delete the source branch after merge") and NOT flagged as orphan. # Empty list → every unclaimed gwm-style branch is flagged. trunks = ["dev", "main"] ``` Repos using a non-default trunk (`master`, `trunk`, `release-1.x`, …) must list it here to keep the orphan check meaningful. ## `[gitmoji]` (issue #85) Per-repo override of the built-in `branch_type → :shortcode:` table consumed by [`gwm commit-prefix`](/cli/reference#gwm-commit-prefix---branch-name---unicode), [`gwm types --gitmoji`](/cli/reference#gwm-types---gitmoji), and the bundled commit-msg hook ([`gwm hooks install commit-msg`](/cli/reference#gwm-hooks-install-commit-msg---force)). ```toml [gitmoji] feat = ":rocket:" # team uses 🚀 for new features instead of ✨ migration = ":truck:" # custom branch type ``` Defaults (used when `[gitmoji]` is absent or omits a key): | branch type | shortcode | unicode | |:------------|:------------------------|:--------| | `feat` | `:sparkles:` | ✨ | | `fix` | `:bug:` | 🐛 | | `hotfix` | `:ambulance:` | 🚑 | | `docs` | `:memo:` | 📝 | | `test` | `:white_check_mark:` | ✅ | | `refactor` | `:recycle:` | ♻ | | `chore` | `:wrench:` | 🔧 | | `perf` | `:zap:` | ⚡ | | `ci` | `:construction_worker:` | 👷 | | `build` | `:package:` | 📦 | `[gitmoji]` is **additive**: overriding one entry doesn't wipe the other nine. Custom branch types declared under `[[branch_types]]` can carry their own emoji here without redeclaring the built-ins. ### `--unicode` normalisation of overrides Surfaces that render the prefix as a unicode glyph (`gwm commit-prefix --unicode`, the unicode column of `gwm types --gitmoji`, and the installed commit-msg hook) **normalise known `:shortcode:` overrides to their glyph**: ```toml [gitmoji] feat = ":rocket:" ``` ```text $ gwm commit-prefix --branch feat/#1-x :rocket: feat(#1): $ gwm commit-prefix --branch feat/#1-x --unicode 🚀 feat(#1): ``` The known-shortcode set covers the ten built-in mappings plus a curated extension of the most commonly-swapped Gitmoji entries (`:rocket:`, `:fire:`, `:lock:`, `:art:`, `:lipstick:`, `:hammer:`, `:bookmark:`, …). **Unknown shortcodes fall through verbatim**: no panic, no substitution: ```toml [gitmoji] feat = ":foo:" # not in the built-in unicode table ``` ```text $ gwm commit-prefix --branch feat/#1-x --unicode :foo: feat(#1): ``` Without `--unicode`, every override is emitted verbatim regardless of whether the table knows it: the shortcode form is the one downstream consumers (GitHub Markdown, commit linters, gitmoji-cli) parse. ## `[[labels]]` (issue #81) Declarative GitHub label set pushed to the `origin` remote by [`gwm labels push`](/cli/reference#gwm-labels-listpush). ```toml [[labels]] name = "bug" description = "Something isn't working" color = "d73a4a" # optional — deterministic pastel if omitted [[labels]] name = "enhancement" description = "New feature or request" [[labels]] name = "good first issue" description = "Good for newcomers" color = "7057ff" ``` | Field | Type | Required | Meaning | |:--------------|:-------|:---------|:----------------------------------------------------------------------------------------------| | `name` | string | yes | GitHub label name. Whitespace preserved verbatim, so quote it: `name = "good first issue"`. | | `description` | string | no | Empty / absent means "don't change the description on the remote". | | `color` | string | no | 6-hex lowercase, no leading `#` (`#D73A4A` is accepted and normalised). When omitted, gwm derives a deterministic pastel from an FNV-1a hash of `name` so the same label gets the same colour across repos. | Resolution order: 1. **Declared `color`** in `.gwm.toml` wins. 2. **Deterministic pastel** from a hash of the name when `color` is omitted (default). 3. **Random pastel** when the user passes `gwm labels push --random-colors`. Workflow: - `gwm labels list`: print the resolved set plus the diff against the remote (`+ create`, `~ update`, `= match`, `- extra-on-remote`). - `gwm labels push`: apply create + update. - `gwm labels push --dry-run`: plan only, no remote mutations. Still reads remote labels via `gh label list` to compute the diff; only create / update / delete calls are skipped. - `gwm labels push --prune`: also delete labels on the remote that aren't declared in config (destructive, opt-in). Without a `[[labels]]` block, `gwm labels {list|push}` are no-ops (`0 labels declared, nothing to push`) and never shell out to `gh`. Requires `gh` on `$PATH` once labels are declared (the same soft dependency as `gwm status`). ## `[[milestones]]` (issue #82) Declarative GitHub milestone set pushed to the `origin` remote by [`gwm milestones push`](/cli/reference#gwm-milestones-listpush). Mirrors the `[[labels]]` shape; the REST endpoint is used because `gh` has no native `gh milestone` subcommand. ```toml [[milestones]] title = "v0.7.0" description = "Configurability sprint" due_on = "2026-07-15" # YYYY-MM-DD → end-of-day UTC state = "open" # default "open", or "closed" [[milestones]] title = "v0.8.0" due_on = "2026-10-01T17:00:00Z" # full RFC3339 also accepted [[milestones]] title = "v0.6.0" state = "closed" # archive declaratively ``` | Field | Type | Required | Meaning | |:--------------|:-------|:---------|:---------------------------------------------------------------------------------------------------------------------------------| | `title` | string | yes | GitHub milestone title (unique per repo). Whitespace preserved verbatim. | | `description` | string | no | Empty / absent means "don't change the description on the remote". | | `due_on` | string | no | `YYYY-MM-DD` (materialised as 23:59:59 UTC of that day, common-sense "due Friday" semantic) or full RFC3339 (`2026-07-15T17:00:00Z`). Absent means no due date. | | `state` | string | no | `"open"` (default) or `"closed"`. Use `"closed"` to archive a milestone declaratively. | Workflow: - `gwm milestones list`: print the resolved set plus the diff against the remote (`+ create`, `~ update`, `= match`, `- extra-on-remote`). - `gwm milestones push`: apply create + update. - `gwm milestones push --dry-run`: plan only, no remote mutations. Still reads remote milestones via `gh api` to compute the diff; only create / update / delete calls are skipped. - `gwm milestones push --prune`: also delete milestones on the remote that aren't declared in config (destructive, opt-in). Without a `[[milestones]]` block, `gwm milestones {list|push}` are no-ops (`0 milestones declared, nothing to push`) and never shell out to `gh`. Requires `gh` on `$PATH` once milestones are declared (the same soft dependency as `gwm labels` / `gwm status`). ## `[issue_template]` (issue #83) Per-branch-type defaults for `gwm new `. The command renders a GitHub issue-form YAML file from `.github/ISSUE_TEMPLATE/`, creates the issue with `gh issue create`, then creates the worktree using the returned issue number. ```toml [issue_template] default = "feature_request.yml" [issue_template.by_type] feat = { template = "feature_request.yml", surface = "cli", title_prefix = "[Feature]: ", labels = ["enhancement"] } fix = { template = "bug_report.yml", surface = "cli", title_prefix = "[Bug]: " } docs = { template = "task.yml", title_prefix = "[Docs]: " } hotfix = { template = "bug_report.yml", surface = "cli", title_prefix = "[Hotfix]: ", labels = ["priority: high"] } ``` | Field | Type | Meaning | |:---------------|:----------------|:------------------------------------------------------------------------| | `default` | string | fallback file under `.github/ISSUE_TEMPLATE/` | | `template` | string | per-type template override | | `surface` | string | default value for a form field with `id: surface` | | `title_prefix` | string | overrides the issue-form `title:` prefix | | `labels` | list of strings | extra labels appended to labels declared by the issue-form YAML | `labels` is read in both directions since [#617](https://github.com/kbrdn1/gwm-cli/issues/617): `gwm new` writes them onto the issue it creates, and `gwm create --issue ` reads the map backwards to derive the branch type from an issue that already exists. A type that declares no labels is never derivable, and labels that select two types make `gwm create --issue` refuse until `--type` picks one, so it is worth giving each type a label no other type carries. `title_prefix` is symmetric the same way: `gwm create --issue` takes back off exactly what `gwm new` put on, falling back to the issue form's own `title:` when the config sets none. Template bodies support placeholders `{type}`, `{desc}`, and `{repo}`. Issue-form markdown blocks are preserved, text inputs/areas become markdown sections, and configured dropdown defaults render as single-line `**Label:** value` entries. ## `[pr_template]` (issue #84) Per-branch-type PR bodies for `gwm pr [--draft] [--base ] [--render]`. Without the subcommand, `gh pr create` falls back to `.github/pull_request_template.md`; `gwm pr` lets each branch type point at its own body so a `docs/` PR doesn't get the same checklist as a `hotfix/`. ```toml [pr_template] default = ".github/pull_request_template.md" [pr_template.by_type] feat = { path = ".github/pr-templates/feat.md" } fix = { path = ".github/pr-templates/fix.md" } docs = { path = ".github/pr-templates/docs.md" } [pr_template.by_type.chore] body = """ ## Summary {desc} Closes #{issue} ## Test plan - [ ] cargo test """ ``` | Field | Type | Meaning | |:----------|:-------|:-------------------------------------------------------------------------| | `default` | string | fallback Markdown file (workdir-relative path) | | `path` | string | per-type Markdown file (workdir-relative path) | | `body` | string | inline Markdown body, wins over `path` when both are set on one type | Both `default` and `path` are workdir-relative paths; absolute paths, `..` parents, and Windows drive prefixes are rejected to stop a template path from escaping the worktree root. Placeholders the renderer substitutes before handing the body to `gh pr create`: | Placeholder | Source | |:------------------|:------------------------------------------------------------------------------| | `{type}` | branch type parsed from the current branch name | | `{issue}` | issue number parsed from the branch name (empty when none) | | `{desc}` | description slug parsed from the branch name | | `{base}` | resolved trunk from `[doctor].trunks` (first that exists) or `--base` value | | `{head}` | current branch shorthand | | `{repo}` | `owner/repo` slug parsed from the `origin` remote URL | | `{commits}` | `git log --pretty=format:- %s {base}..{head}` (one bullet per commit subject) | | `{files_changed}` | `git diff --stat {base}..{head}`, capped at 30 lines (`… N more lines trimmed`) | `gwm pr --render` prints the rendered body to stdout instead of creating a PR, which is useful for `gwm pr --render | gh pr create --body-file -` when you want to tweak the body in `$EDITOR` first. ## `[aliases]` (issue #86) Repo-level CLI aliases: declarative `git config`-style aliases that follow the repo across machines. Each entry maps an alias name to an argv-substituted expansion run BEFORE clap parses, so `wip = "create feat 0 wip"` makes `gwm wip` behave as `gwm create feat 0 wip`. ```toml [aliases] wip = "create feat 0 wip" ll = "list --format names" sync = "bootstrap" ``` A user-level fallback lives at `~/.config/gwm/aliases.toml` (resolved like the [global config](/configuration/global-config#location): `$XDG_CONFIG_HOME` wins outright, otherwise the first existing of `~/.config` then the platform dir, issue #374); same `[aliases]` block shape. Repo aliases win over user aliases on name collision. Surface the resolved chain with `gwm aliases list`. See [CLI → `gwm aliases list`](/cli/reference#gwm-aliases-list-issue-86) for the rendered output and the per-source flagging. **Rules enforced at load time** (`Config::load_for_repo` returns `GwmError::Config` on violation): | Rule | Reason | |:-------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------| | Alias name MUST NOT shadow a built-in subcommand (`list`, `switch`, …) | Built-in subcommands are the strongest binding, and silent shadowing is a bug factory. | | Alias name MUST NOT shadow a built-in visible alias (`s`, `cd`) | Same reasoning: `gwm s` should always reach `switch`. | | Alias value MUST NOT be empty | Nothing to expand to. | | Alias value MUST NOT contain shell metachars (`&&`, `\|\|`, `\|`, `;`, backticks) | Aliases are argv substitution only, so use a shell alias for shell semantics. | Single-pass expansion: `wip = "ll"` followed by `ll = "list --format names"` expands ONCE and then dispatches; the second hop is not resolved. ## defaults without `.gwm.toml` | Setting | Default | |:-------------------------------------|:---------------------------------| | `[worktree].base` | `{home}/cc-worktree/{repo}` | | `[worktree].path_pattern` | `{type}-{issue}-{desc}` | | `[worktree].branch_pattern` | `{type}/#{issue}-{desc}` | | `[[bootstrap.*]]` | empty, no pipeline | | `[git_tui].command` | `lazygit -p {path}` | | `[git_tui].fullscreen` | `true` | | `[review]` | inert (`R` does nothing) | | `[tui].confirm_countdown_secs` | `3` | | `[tui].sidebar_position` | `right` | | `[tui].sidebar_orientation` | `stacked` | | `[tui].layout` | `compact` | | `[tui].dim_unfocused` | `false` | | `[tui].status_one_line` | `true` | | `[tui].note_vim` | `true` | | `[tui].mux_open_in` | `pane` | | `[tui].mux_pane_direction` | `right` | | `[tui].terminal_browser` | unset (system browser) | | `[tui].terminal_browser_open_in` | `overlay` (gwm hosts it) | | `[tui].clipboard` | `auto` | | `[tui].auto_refresh_secs` | `60` (`0` disables) | | `[tui.macro1]` / `[tui.macro2]` | absent, `h` / `H` are no-ops | | `[tui.keys]` | built-in keymap (see table above) | | `[tui.keys.modal.*]` | built-in modal keymaps (`gwm tui keys`) | | `[tui.open].mode` | `shell` (v0.6, was `finder`) | | `[theme].preset` | none, default hardcoded scheme | | `[doctor].trunks` | `["dev", "main"]` | | `[[labels]]` | empty, `gwm labels {list,push}` are no-ops | | `[issue_template]` | empty, `gwm new` is not configured | | `[pr_template]` | empty, `gwm pr` errors with a hint, `gh pr create` keeps using `.github/pull_request_template.md` | | `[aliases]` | empty, no CLI alias expansion | ## validation rules - Unknown TOML keys are a **hard load error**: the root `[Config]` table and nearly every sub-table (`[worktree]`, `[bootstrap]`, `[hooks]`, `[doctor]`, `[tui]`, `[tui.open]`, `[git_tui]`, `[review]`, `[[labels]]`, `[[milestones]]`, `[[branch_types]]`, `[issue_template]`, `[pr_template]`) reject fields they don't recognise. A stray top-level key (or an unknown key inside a deny-fields table) fails the load with a `Config` error rather than being ignored. The same check runs on the merged result, so a typo in the global `~/.config/gwm/config.toml` fails just as hard. Exceptions: `[theme]` flattens per-role overrides (arbitrary role-named keys are accepted, then validated against the known role set, see below), and `[gitmoji]` / `[aliases]` are open key→value maps. - Unknown `[tui.open].mode`, `[tui].sidebar_position`, `[tui].sidebar_orientation`, `[tui].layout`, `[tui].clipboard` and `[tui].terminal_browser_open_in` values **error at load time**. - `[theme]` errors at load on an unknown `preset`, unknown role key, or unparsable colour value. - `[tui.keys]` errors at load on an unknown action, an unparsable chord, a chord conflict, or a prefix collision. - `[tui.keys.modal.*]` errors at load on an unknown context, an unknown verb, an unparsable or multi-stroke key, binding under a context group instead of a leaf stage, or a per-context conflict. - `[tui.macro1]` / `[tui.macro2]` error at load on an unknown field; `open_in` accepts only `"pty"` or `"mux_pane"`. - `[aliases]` names that shadow a built-in subcommand / visible alias, are empty, or contain shell metachars **error at load time**. - `[[bootstrap.guard]]` references in `[[bootstrap.copy]].guards` are validated by `gwm doctor` (check #2). - `[[bootstrap.command]].when` predicates with unknown keywords default to `true` (so old configs keep running); `gwm doctor` (check #3) surfaces them. Run `gwm doctor` after every edit to catch the catchable mistakes. See [Integrations → `gwm doctor`](/integrations/doctor).