Configuration¶
treeman reads config from two files that merge into one effective config, last-write-wins:
~/.config/treeman/config.yaml— user-global, machine-wide defaults shared by every repo. Scaffold withtreeman init --global.<repo>/.treeman.yaml— per-repo, committed. Plus an optional.treeman.local.yamloverlay (gitignored).
Each top-level key has a scope that decides which file it may live
in. This is a hard rule — a key in the wrong file is an error at load
time, with no flag to relax it (the layered merge made a misplaced key
silently inert, so it's surfaced loudly instead). Editors enforce it too:
.treeman.yaml validates against the repo-scoped schema and the global
config against the global-scoped one (treeman schema install /
treeman init --global wire the right modeline).
| Scope | Keys | Lives in |
|---|---|---|
| global | daemon, snapshots, logs, status, notifications |
~/.config/treeman/config.yaml only |
| repo | databases, patches, hooks, main_worktree, env_sources |
.treeman.yaml only |
| both | connections, worktrees, auto_fetch, ports, frameworks, debounce_ms |
either (global = default, repo overrides) |
For the full per-key reference + auto-generated examples see config-reference.md.
Per-repo .treeman.yaml¶
# yaml-language-server: $schema=https://raw.githubusercontent.com/stubbedev/treeman/master/schemas/treeman.schema.json
worktrees:
root: .worktrees # default
copies: [".env"] # copied into each worktree (patched per-branch; never shared)
links: ["node_modules"] # symlinked from main (shared read-only cache)
env_sources: # credential-resolver READ list
- .env
- .env.testing
# The whole `connections:` block is optional — treeman auto-resolves
# credentials from `.env` / `.env.testing` (Laravel `DB_*`,
# Spring-Boot `SPRING_DATASOURCE_*`, MySQL/PG/Redis component vars).
# Declare anything below only when you want to override the auto-
# detected value. `connections` is scope "both": set machine-wide
# defaults in the global config, override per-repo here.
connections:
mysql:
host: 127.0.0.1
port: 3306
user: root
password: $MYSQL_ROOT_PASSWORD # overrides .env DB_PASSWORD
postgres:
host: 127.0.0.1
port: 5432
user: postgres
password: $PGPASSWORD
mongodb: { uri: "mongodb://127.0.0.1:27017" }
redis: { url: "redis://127.0.0.1:6379" }
elasticsearch: { url: "http://127.0.0.1:9200" }
databases:
- engine: mysql # mysql|mariadb|tidb|postgres|postgresql|mongodb|redis|elasticsearch|opensearch
name_template: "myapp_testing_{slug}"
dump: { path: storage/dumps/seed.sql.gz } # resolved in the worktree; a gitignored dump is symlinked from main
fanout: 0 # 0 = safe per-engine default (mysql 4, pg GOMAXPROCS, mongo 6, es 8).
# raise only if the server is provisioned (max_connections bumped, etc.).
prewarm: 0 # Postgres only: keep N spare clones pre-restored from the cached
# template; a cache-hit create claims one via ALTER DATABASE … RENAME
# (milliseconds, size-independent) instead of a full template copy.
# The pool replenishes in the background and is dropped with its
# template on eviction. See `treeman snapshots list` (SPARES column).
migrate: # shell command + env-var redirects (point the CLI at the per-run template DB)
run: "php artisan migrate --force"
env:
DB_DATABASE: "{target_db}"
DB_TEST_DATABASE: "{target_db}"
inputs: # files folded into the snapshot key; also watched for changes
- { glob: "database/migrations/**/*.php", label: migrations }
- composer.lock # bare string = glob with no label
test_clones: # parallel-test-runner fan-out
clones: auto # auto = one clone per CPU when the detected framework parallelizes per-worker
name_template: "myapp_testing_{slug}_test_{n}"
hooks:
# Every entry under a trigger key is an action; actions in one
# list run in PARALLEL. `run:` itself takes a string (one step) or
# a list (steps chain sequentially with `&&` — failure short-circuits).
create-before-engines: # fires after patches + bring-in, BEFORE engine prepare
# NB: engine DBs don't exist yet in this phase. Dependency installs
# belong here (so migrate finds vendor/), but anything that BOOTS the
# app and queries the DB will fail with "Unknown database" — move that
# to create-after-engines. A failure here leaves the worktree in
# `failed`; recover with `treeman worktree finalize`.
- run: "git pull --ff-only"
create-after-engines: # fires after engine prepare
- run: composer install --no-interaction --prefer-dist
- run: yarn install --frozen-lockfile
- cwd: frontend # multi-step action — cd + build chain sequentially
run:
- yarn install
- yarn build:dev
delete-before-engines: # fires BEFORE engine drop + git remove
- run: "echo dropping caches"
file-change: # filtered by `match:` against input labels
- match: migrations # accepts string or list (e.g. [migrations, seeders])
run: "echo migrations changed"
debounce_ms: 500 # file-watcher coalesce window (scope "both")
User-global ~/.config/treeman/config.yaml¶
Machine-wide settings. daemon, snapshots, logs, status, and
notifications are only valid here — putting them in a repo
.treeman.yaml is a hard error.
daemon:
log_level: info # debug | info | warn | error
# (socket path is derived from $XDG_RUNTIME_DIR — not configurable)
snapshots:
cap_per_repo: 8 # hard cap per repo, LRU evicts on new generation
keep_per_source: 500 # max templates kept per migration-content source
max_age_days: 30 # drop templates unused this long (0 = off)
max_total_gb: 50 # evict largest-first above this total (0 = off)
gc_interval_minutes: 60 # daemon background sweep cadence
logs:
keep_days: 14 # daemon prunes the shared event log; 0 keeps forever
durables: # scope "both" — a repo may override
max_age_days: 30 # drop branch_scoped durables unused this long; negative keeps forever
auto_fetch: # scope "both" — global default cadence; a repo may opt out
enabled: true
interval_minutes: 15
notifications:
enabled: false # desktop banners on worktree lifecycle changes
Templated names¶
Several config fields are template strings rendered with the worktree's slug:
| Token | Example |
|---|---|
{slug} |
proj_123_33a85ab4 |
{slug_dash} |
proj-123-33a85ab4 |
{slug_redis_queue} |
9 — checksum-derived Redis DB index (6–15) |
{slug_redis_cache} |
12 — checksum-derived Redis DB index (6–15), distinct from queue |
{port_<name>} |
allocated port for the ports: slot <name> (e.g. {port_octane}) |
{target_db} |
the rendered per-run DB name (only in migrate.env / seed.env / rollback.env) |
{db:<index>} |
the rendered name of databases[<index>] in the current context (only in migrate.env / seed.env / rollback.env) |
{n} |
test-clone index, 1-based (only in test_clones.name_template) |
A databases[] entry's name_template (or key_prefix for Redis,
Elasticsearch and S3) must contain {slug} or {slug_dash}, so each
worktree gets its own namespace. The file engines are exempt, because
their path resolves inside the worktree. A test_clones.name_template
must contain {n} exactly once. The main_worktree.databases[] overlay
may bind bare names (app_dev), since the main checkout is a single
worktree. Teardown drops exactly the names it owns: the source database,
plus the databases whose names match the test-clone pattern.
{db:<index>} is for a step that migrates more than one engine at once
(Laravel runs MySQL and MongoDB migrations in one php artisan migrate).
Every connection the step does not redirect falls through to the
worktree's .env, i.e. the dev databases, so point each sibling at its
own entry. It resolves after the main_worktree.databases overlay, so
the main worktree gets its overlay name and linked worktrees the
slugged one:
databases:
- engine: mysql
name_template: "app_testing_{slug}"
migrate:
run: "php artisan migrate --force"
env:
DB_DATABASE: "{target_db}"
MONGO_DB_DATABASE: "{db:1}" # databases[1] in this context
- engine: mongodb
name_template: "mongodb_testing_{slug}"
Fully declarative — no hidden defaults¶
treeman runs against what .treeman.yaml says, not what it
guesses from filesystem markers. Init helpers (treeman init,
treeman frameworks detect) inspect the repo and write the corresponding
YAML; once written, those fields are authoritative. If a migration
glob lives somewhere unusual, change inputs: and the next prepare
sees it — no recompile, no rebuild of treeman, no per-framework
code path. The same applies to env files: only what
env_sources: lists is read.
The built-in framework presets exist solely as init-time templates
and are listed by treeman frameworks detect. Copy fields in by hand for
custom layouts.
Hooks¶
Each entry under a trigger key (e.g. create-after-engines) is
one action. Actions in the same list run in parallel. The
run: field inside one action is the action's step list — steps
chain sequentially with && so the first non-zero exit aborts the
remaining steps of that action.
Every action must be a mapping with at minimum a run: field
(bare-string entries like - "composer install" were removed in
2.x — wrap them as - run: "composer install").
hooks:
create-after-engines:
# single-step action — `run:` is a string
- run: "composer install"
# action that pins cwd (host-side) and runs ONE step
- cwd: frontend
run: "yarn build"
# multi-step action — steps chain with &&, abort on first failure
- cwd: frontend
run:
- "yarn install"
- "yarn build:dev"
Available triggers: create-before-engines,
create-after-engines, delete-before-engines,
delete-after-engines, checkout, file-change. The
*-before-engines variants run before treeman touches its managed
engines, the *-after-engines variants after. Worktree creation is
async-dispatched: the CLI returns a queued result, while the daemon waits
for each hook phase before proceeding to the next lifecycle phase.
Optional hook failures¶
Hooks are fatal at lifecycle barriers by default: a failed
create-before-engines action prevents database preparation, and a failed
create-after-engines action fails finalize. Set continue_on_error: true
on an independent action to allow its non-zero command exit without
aborting subsequent phases:
hooks:
create-before-engines:
- run: "composer install --no-interaction --no-scripts"
- cwd: frontend
run: "npm ci"
continue_on_error: true
create-after-engines:
- run: "composer install --no-interaction"
Here an invalid frontend lockfile still fails npm ci, but databases are
prepared and after-engines hooks run if the required Composer install
succeeds. Keep prerequisites for database migrations fatal. The setting
applies to the whole action, not individual steps. Steps still short-circuit
on failure, sibling actions still run in parallel, and the daemon still
waits for completion: this is non-fatal, not fire-and-forget execution.
Omitting the field or setting it to false preserves fail-fast behavior.
Rendering/container-resolution errors, process launch errors, and lifecycle
cancellation are not suppressed.
The failed exit code and captured output remain in hook history. The daemon's
create completion summary (worktree:create:end in treeman logs tail --follow)
reports non-fatal failures with commands and hook log IDs. treeman worktree show
displays ready (warnings) with that summary and recent failed hook runs;
treeman logs hooks --all --show ID retrieves the full output. The immediate
create response only reports that setup was queued, since hooks finish later.
A successful retry with treeman worktree finalize clears the current warning
state without erasing historical failures. Standalone hook runs continue to
report actual exit codes, including opted-in failures.
The create-before-engines contract¶
create-before-engines runs after patches + bring-in but before the
engine databases exist. Dependency installs belong there so migrate
finds vendor/ — but a package manager whose post-install script boots
the framework (Laravel's post-autoload-dump → artisan
package:discover / vendor:publish, an npm postinstall that calls the
framework CLI) connects to a database that is not there yet and dies with
SQLSTATE[HY000] [1049] Unknown database.
It fails intermittently: only the boots that happen to touch the DB
break, so the same config can pass eight creates and fail the ninth —
after paying the full cold install first. treeman doctor now warns on
this shape (hooks check) instead of letting you discover it from a
two-minute failure.
Two working recipes for a Laravel repo. Pick one:
# A — split it: resolve deps early (so migrate sees vendor/), run the
# boot scripts once the databases exist.
hooks:
create-before-engines:
- run: "composer install --no-interaction --no-scripts"
create-after-engines:
- run: "composer install --no-interaction" # scripts armed, DB exists
- run: "yarn install --frozen-lockfile"
# B — move it: the whole install after engines. Simpler; only valid when
# nothing in the migrate/seed step needs vendor/ before it runs.
hooks:
create-after-engines:
- run: "composer install --no-interaction"
- run: "yarn install --frozen-lockfile"
--no-scripts (composer) / --ignore-scripts (npm, yarn, pnpm, bun) is
what disarms the boot. An inline env guard on the step
(DB_DATABASE=… composer install) counts as deliberate too and silences
the doctor warning.
Per-step env vars are only available on databases[].migrate.env
and databases[].seed.env (the framework-runner step), not on
hook actions. Use the inherited shell environment + cwd: to
control hook subprocesses.
Running hooks inside a container¶
Hook entries accept a container: (or compose_service:) directive
that wraps every step in <engine> exec so the command runs inside
the named container rather than on the host. Useful for
composer install, npm ci, php artisan migrate,
bundle install, … that depend on the dev container's toolchain.
hooks:
create-after-engines:
# Single-step action, in a named container.
- { run: "composer install", container: myapp-php }
# Multi-step action, in a compose service.
- compose_service: app
compose_project: myapp
run:
- "npm ci"
- "php artisan migrate"
action.cwd becomes -w <cwd> on the exec call (interpreted
inside the container's filesystem). Leave unset to use the
container's WORKDIR.
Hook environment variables¶
Every hook subprocess inherits the user's worktree create-time env (PATH,
shims, .env-loaded vars). When direnv is on that PATH, the environment of
the directory the command runs in is applied on top, exactly as direnv's shell
hook would on cd, so tools that only a .envrc provides (use flake,
layout php, …) resolve without wrapping each command in direnv exec. The
same environment applies to every databases[] migrate, rollback and
seed step. A .envrc that direnv refuses to load (blocked, or failing)
fails the hook or step with direnv's message; run direnv allow in the
worktree, or whitelist the worktree root in direnv.toml.
Hooks additionally get four scoping variables treeman sets unconditionally:
| Variable | Value |
|---|---|
TREEMAN_MAIN_ROOT |
Absolute path of the repo root (git rev-parse --show-toplevel). |
TREEMAN_WORKTREE |
Absolute path of the worktree firing the hook. |
TREEMAN_SLUG |
The slug used to name resources for this run (e.g. kon_1234_1a2b3c4d, main_develop_5e6f7a8b). |
TREEMAN_IS_MAIN |
"1" when the firing worktree is the repo root with main-wt enabled, else "0". Branch on this to skip dev-only setup on linked worktrees, or vice versa. |
file-change hooks additionally receive TREEMAN_WATCH_PATH,
TREEMAN_WATCH_LABEL, TREEMAN_WATCH_ENGINE, TREEMAN_WATCH_DB_NAME
so the script can branch on the trigger details.
hooks:
create-after-engines:
- run: |
if [ "$TREEMAN_IS_MAIN" = "1" ]; then
composer install --no-dev
else
composer install
fi
Main worktree¶
By default, treeman only manages linked worktrees under
worktrees.root. The repo root checkout is invisible to the
watcher — branch switches there don't trigger prepare, and the
checkout doesn't get its own per-branch databases.
main_worktree: opts the repo root into the same lifecycle. Once
enabled, flipping a branch at the repo root produces per-branch
databases keyed by main_<branch>_<tag>, where the tag is a hash of
the exact branch name (instead of every non-ticket
branch collapsing to one path-hash slug), and the checkout /
file-change / create-* hooks fire against the repo root the
same way they do for linked worktrees.
main_worktree:
enabled: true
# Optional: per-context overlay for the databases[] block.
# Sparse, index-aligned with databases[]. Set fields replace the
# base entry's field; unset fields inherit.
databases:
- name_template: "app_dev_{slug}" # different DB name in main wt
test_clones:
clones: 0 # no paratest fanout in main
name_template: ""
Flip on with treeman main enable — this patches .treeman.yaml,
asks the daemon to reload, then dispatches a finalize against the
repo root so create-* hooks run immediately. Reverse with
treeman main disable (config flag flips, watcher stops, databases
remain). Add --purge to drop every per-branch DB the
main_<branch> slug owns across all local branches.
treeman main status reports the current enrollment state +
branch-aware slug.
Example: per-stack databases: blocks¶
treeman init writes these for you when it detects the matching
framework. Copy + paste into the databases: array of an existing
.treeman.yaml to add a stack.
Rails + Postgres (db/migrate, Gemfile.lock invalidates):
- engine: postgres
name_template: "myapp_test_{slug}"
migrate:
run: "bin/rails db:migrate"
env: { DB_NAME: "{target_db}" }
inputs:
- { glob: "db/migrate/**/*.rb", label: migrations }
- Gemfile.lock
test_clones:
clones: auto # auto = one clone per CPU (framework detected, not worker-config parsed)
name_template: "myapp_test_{slug}_w{n}"
Django + Postgres (auto-discovered app migrations/ dirs):
- engine: postgres
name_template: "myapp_test_{slug}"
migrate:
run: "python manage.py migrate --noinput"
env: { DB_NAME: "{target_db}" }
inputs:
- { glob: "**/migrations/[0-9]*_*.py", label: migrations }
- poetry.lock
- Pipfile.lock
- requirements.txt
test_clones:
clones: auto # auto = one clone per CPU (framework detected, not worker-config parsed)
name_template: "myapp_test_{slug}_w{n}"
golang-migrate + Postgres:
- engine: postgres
name_template: "svc_test_{slug}"
migrate:
# The CLI needs -path + -database; pull the DSN from
# DATABASE_URL so treeman's per-run substitution can swap the
# database portion.
run: 'migrate -path migrations -database "$DATABASE_URL" up'
env:
DATABASE_URL: "postgres://user:password@127.0.0.1:5432/{target_db}?sslmode=disable"
inputs:
- { glob: "migrations/**/*.up.sql", label: migrations }
- { glob: "services/*/migrations/**/*.up.sql", label: migrations }
- go.sum
test_clones:
clones: 4 # explicit count; Go's `-parallel` is per-package
name_template: "svc_test_{slug}_w{n}"
sqlx-cli + Postgres (sqlx allows in-place migration edits — content hashing catches them):
- engine: postgres
name_template: "app_test_{slug}"
migrate:
run: "sqlx migrate run"
env:
# sqlx-cli reads DATABASE_URL natively.
DATABASE_URL: "postgres://user:password@127.0.0.1:5432/{target_db}?sslmode=disable"
inputs:
# every matched file is content-hashed, so an in-place edit
# moves the fingerprint and rebuilds the snapshot.
- "migrations/**/*.sql"
- "crates/*/migrations/**/*.sql"
- Cargo.lock
test_clones:
clones: auto # auto = one clone per CPU (framework detected, not worker-config parsed)
name_template: "app_test_{slug}_w{n}"
Credential resolution from .env¶
treeman reads env files only when env_sources: is declared.
There is no implicit default — the runtime never reads files you
didn't list. treeman init writes an env_sources: block matching
the framework it detected (Laravel → .env.testing etc.); edit the
list to suit.
env_sources: # files read in order, last wins
- .env
- .env.testing # baseline for tests
- .env.testing.local # per-dev overrides
Relative paths resolve against the repo root; absolute paths are honoured as-is so you can pull from outside the repo (e.g. a shared secret store).
When env_sources: resolves to a non-empty environment, the
resolver fills any unset fields in the connections: block.
Supported flavours per engine:
| Engine | Env vars treeman reads (first non-empty wins) |
|---|---|
| MySQL | DB_TEST_* → DB_* (Laravel); MYSQL_URL; SPRING_DATASOURCE_URL (jdbc:mysql://…) + SPRING_DATASOURCE_{USERNAME,PASSWORD}; MYSQL_PASSWORD / MYSQL_PWD |
| Postgres | DB_TEST_* → DB_* (Laravel); DATABASE_URL; PG* (PGHOST, PGPORT, PGUSER, PGPASSWORD); SPRING_DATASOURCE_URL (jdbc:postgresql://…) |
| MongoDB | MONGODB_URI / MONGO_URL / DATABASE_URL; MONGO_DB_HOST + MONGO_DB_PORT composed; SPRING_DATA_MONGODB_* |
| Redis | REDIS_URL; REDIS_HOST + REDIS_PORT + REDIS_PASSWORD composed; literal null / (null) passwords skipped |
| Elasticsearch | ELASTICSEARCH_URL / ELASTICSEARCH_HOSTS (auto-prefixes http:// if missing) |
Practical effect: a Laravel repo whose .env.testing carries the
canonical DB_HOST=mysql / DB_USERNAME=root / DB_PASSWORD=secret
needs no connections: block in .treeman.yaml. The same
goes for Spring-Boot apps with SPRING_DATASOURCE_URL, etc.
YAML always wins where present — declare a field only when you want to override the env value.
Connecting to DBs in a container¶
If your MySQL / Postgres / Mongo / Redis / Elasticsearch runs in a
container, set container: (raw container name) or
compose_service: (docker-compose service name) on the connection
block. treeman runs <engine> inspect and dials whichever of these
works:
- Published host port —
127.0.0.1:<HOST_PORT>when-p HOST:CONTAINER/ports:publishes the in-container port. Cross-platform; works on macOS, Windows, Linux, Colima, OrbStack, Docker Desktop, Podman, nerdctl, finch, lima. - Bridge-network IP — falls back to the container's IP on the chosen network (Linux + OrbStack route this directly).
host.docker.internal— final fallback when treeman is running inside a container and the configured host isn't reachable.
Lookups are cached for 30s; a connection failure auto-invalidates so a container restart settles within one retry.
connections:
mysql:
container: myapp-mysql # raw container name (`docker run --name`)
container_engine: docker # docker | podman | nerdctl | finch | …
container_network: myapp_net # optional: pin which network's IP to use
port: 3306 # internal port; defaults to engine default
user: root
password: $MYSQL_ROOT_PASSWORD # or omit — see "Container env" below
postgres:
compose_service: db # docker-compose service name (alternative to container:)
compose_project: myapp # defaults to $COMPOSE_PROJECT_NAME if unset
user: postgres
mongodb:
container: myapp-mongo
uri: "mongodb://placeholder:27017" # host:port rewritten at dial time
Container env autodiscovery. When password: is unset and
the resolver still has no password from env files, treeman pulls
MYSQL_ROOT_PASSWORD / POSTGRES_PASSWORD straight out of the
container's Config.Env. Skip the password block when you've
already declared the secret on the container itself.
Engines. Anything that supports inspect and ps --filter
label=... works — docker, podman, nerdctl, finch. Default
is docker. OrbStack users keep the default — its CLI is a docker
symlink, so container_engine: docker already drives it.
Running treeman inside a devcontainer. treeman detects
/.dockerenv, /run/.containerenv, REMOTE_CONTAINERS=true and
DEVCONTAINER=true. When inside a container, the inspect path is
skipped if the engine socket isn't reachable — your configured
host: is used as-is, so on a shared compose network just write
the sibling service name (host: db) and it resolves via the
compose-provided DNS. As a last resort host.docker.internal is
probed before erroring.
Alternate global config¶
Use treeman --config ./config.yaml <command> or
TREEMAN_CONFIG=./config.yaml treeman <command> to replace (not add to)
the user-global layer. A non-empty flag wins over TREEMAN_CONFIG; empty
values fall through to the next source, then to
$XDG_CONFIG_HOME/treeman/config.yaml (or ~/.config/treeman/config.yaml).
Relative override paths resolve from the invocation's working directory.
An explicitly selected missing file is an error; a missing default file
still uses built-in defaults. Repo / repo-local / worktree-local precedence
is unchanged.
The default global file accepts only global and shared keys. An explicit
file may also carry repo-scoped keys (databases, patches, hooks,
main_worktree, env_sources), so a checkout without a .treeman.yaml
of its own, such as a CI build, can be driven by one committed file:
treeman --config ci/treeman.yaml prepare --no-daemon. The repo's
.treeman.yaml and .treeman.local.yaml still load on top of it.
The override applies only to config loaded by that foreground invocation.
It is not sent over RPC: an already-running daemon, its watchers, and
jobs executed by it retain the daemon's own config. Auto-started daemons
(and treeman daemon start) do not inherit TREEMAN_CONFIG from the CLI.
To deliberately configure a daemon, start TREEMAN_CONFIG=/absolute/config.yaml
treemand directly or set that environment in its service definition.