Configuration
Settings live in one place for all your workspaces, at
~/.config/ws/config.yaml ($XDG_CONFIG_HOME is honoured; WS_CONFIG points
somewhere else, and WS_CONFIG="" turns the layer off). ws init --global
writes it with every key documented and its default filled in.
A workspace’s ws.yaml then stays small — what it actually owns:
name: acmebranch_dirs: nested # an override, when this project differs
repos: - url: "git@github.com:org/api.git" setup: cp .env.example .env # or a list; once per worktree, after the install
- url: "git@github.com:org/web.git" path: services/web # where it sits inside a branch directory setup: | # a script: every line runs, a failing one stops it cp {root}/shared/web.env .env echo "API_TOKEN={env:API_TOKEN}" >> .env ./scripts/codegen "$WS_WORKTREE"Setup commands are a string or a list of strings, and any one of them may
span several lines. Each runs through sh -e in the repository’s worktree, so
a failing line stops the setup just as a failing list entry does. They run
once per worktree, after the local files and the
package managers; --rerun-setup runs them again.
Before running, these are replaced:
{root} |
the workspace root, where ws.yaml is |
{branch} |
the branch, as git names it (feature/x) |
{branch_dir} |
the branch directory |
{worktree} |
this repository’s worktree — also the working directory |
{repo} |
the repository’s name |
{env:NAME} |
the environment variable NAME; an unset one fails the setup |
{env:NAME:-default} |
NAME, or default when it is unset or empty — like the shell’s ${NAME:-default} |
setup: | echo "API_URL={env:API_URL:-http://localhost:8080}" > .env echo "LOG_LEVEL={env:LOG_LEVEL:-}" >> .env # an empty default is fineA default runs to the closing brace, so it may hold spaces, colons or :-,
but not a }.
Escaping. To hand a placeholder to the command as written — a template of
your own that uses the same syntax, say — double its braces: {{env:TOKEN}}
becomes the literal {env:TOKEN}, and {{repo}} becomes {repo}.
setup: envsubst-like-tool --template '{{env:TOKEN}}' # receives {env:TOKEN}Nothing else needs escaping. Braces around anything that is not a placeholder
are left alone, so ${HOME}, awk’s '{print $1}', '{if(x){print}}' and
brace expansions like cp x.{a,b} y reach the shell as written — and a
doubled pair only counts as an escape when what it encloses is a placeholder.
Values are inserted verbatim — quote the placeholder if a path may hold a
space. The same values are exported as WS_ROOT, WS_BRANCH,
WS_BRANCH_DIR, WS_WORKTREE and WS_REPO, which the shell quotes for you
as "$WS_WORKTREE".
Merging is key by key, not file by file: a workspace that sets one key of
mux keeps the rest of your global mux. Sequences are replaced whole, never
appended to. Repositories belong to the workspace — repos in the global file
is an error rather than a silently ignored block. ws status prints the files
a workspace was built from, in the order they were applied.
Overrides go anywhere in the file, in any order; the one ws init writes
offers them commented out. A URL holding a colon is safe either way, but the
file ws init writes quotes the values that came from you.
bare_dir and worktree_dir accept . for the workspace root, a relative
path, an absolute one, or ~. They resolve per workspace, so an absolute
worktree_dir in the global file would point every workspace at the same
directory — set that one per workspace.
Launcher commands are configurable, which is also how the tools are tested without installing them:
mux: herdr: command: [nix, run, "nixpkgs#herdr", --]WS_TMUX_CMD, WS_HERDR_CMD, WS_ZOXIDE_CMD, WS_YARN_CMD, WS_PNPM_CMD,
WS_NPM_CMD, WS_UV_CMD, WS_JUST_CMD, WS_TASK_CMD and WS_PICKER_CMD
override those at runtime.