thinslice: bare+worktrees layout with named state
Problem
A normal git checkout conflates two things: the code you are working on and the runtime state those processes need (databases, config, ports, secrets). When you switch branches you have to manually re-point all your tools at the right state, or you share one pile of state across every branch and hope for the best.
The goal here is to make code and state independently addressable, so you can have multiple workstreams open at once — a feature branch, a PR under review, a scratch experiment — each with its own isolated state, without touching each other.
Central invariant
Each state slot can be bound to at most one worktree at a time.
You will typically have a small number of slots — two or three, maybe five. They are named, reusable resources. When you want to run the processes in a worktree you attach a slot to it. You cannot accidentally run two servers against the same postgres data directory because the mapping is explicit and exclusive.
Layout
slice setup converts a normal git checkout into this structure:
~/src/foo/
+clone+/ bare git repo — the canonical source of objects and refs
+state+/ a small number of named state slots
main/ git worktree, checked out at branch 'main'
local/ personal worktrees (unmanaged by thinslice)
pr/ worktrees for reviewing pull requests
tmp/ throwaway worktrees
The + delimiters are chosen to sort to the top of a directory listing and
make it visually obvious these are infrastructure directories rather than
worktrees.
+clone+
A bare git repository. All objects and refs live here. Worktrees registered
against it can be added and removed without affecting each other or the history.
git rev-parse --git-common-dir from inside any worktree returns this path,
which is how slice source locates the rest of the structure without any
additional config.
+state+
A directory of named state slots. Each slot is a self-contained bag of runtime
state: databases, config files, ports, secrets — everything the processes in a
worktree need that is not in the source code. Slots are named arbitrarily
(e.g. template, slot-a, slot-b) and there are usually only a handful.
One slot serves as a template: a known-good starting point with migrations run and seed data loaded. When you start a new workstream you copy the template to a fresh slot, attach it to your worktree, and go. The template itself is never attached to a worktree; it just sits there as a clean base to copy from.
Worktree categories
local/, pr/, and tmp/ are conventional namespaces for organizing
worktrees by purpose. They are plain directories; thinslice has no opinion about
what goes inside them beyond that convention.
bindings.toml
+state+/bindings.toml maps worktree paths to state directory names:
[bind]
"/Users/you/src/foo/main" = "slot-a"
"/Users/you/src/foo/pr/123" = "slot-b"
Keys are absolute paths to worktree directories. Values are slot names inside
+state+. Each slot may appear at most once — the 1:1 binding is enforced by
convention in this file. A worktree with no entry has no state attached;
slice source will tell you clearly when that is the case.
State directories
Each slot inside +state+ is a self-contained home for all the runtime state
a set of processes needs. Nothing that would normally be gitignored lives in the
worktree itself — there is no config.toml.example → config.toml copy step,
no local database at a fixed system path. The config file lives in the slot.
The postgres data directory lives in the slot. The worktree stays clean.
The required convention is a thinslice.toml manifest at the slot root:
+state+/slot-a/
thinslice.toml manifest: env vars, processes, ports, log paths
env KEY=VALUE derived from thinslice.toml [env]; for shell interop
pids/ one .pid file per managed process
logs/ one .log file per managed process
postgres/ postgres data directory (PGDATA points here)
config.toml application config (an env var points here)
myapp.db sqlite database
... anything else processes need to persist
thinslice.toml has two sections. The [env] section is a flat KEY=VALUE mapping
read by slice source at direnv time. The [process.*] sections declare
managed processes — what to run, which ports each one claims, and where its log
goes. This makes thinslice.toml the single source of truth for both runtime
configuration and process management.
# slot-a/thinslice.toml
[env]
DATABASE_PORT = "5433"
DATABASE_URL = "postgres://localhost:5433/myapp"
PGDATA = "/Users/you/src/foo/+state+/slot-a/postgres"
PORT = "3001"
[process.postgres]
start = "postgres -D $SLICE_STATE_DIR/postgres"
ports = [5433]
log = "$SLICE_STATE_DIR/logs/postgres.log"
[process.server]
start = "npm run dev"
ports = [3001]
log = "$SLICE_STATE_DIR/logs/server.log"
Ports and paths differ between slots so that multiple worktrees can run simultaneously without colliding.
Processes read their configuration from environment variables and write their
state to paths under SLICE_STATE_DIR. They need no knowledge of the thinslice
structure beyond those two variables. PID files are stored at
$SLICE_STATE_DIR/pids/<name>.pid by convention; slice derives this path
from the process name without requiring it to be declared explicitly.
depends_on_worktree
By default every process restarts on a worktree swap: when slice use <state> is
run from another worktree, the old worktree's processes are stopped during the
unbind and started again by the next slice up. Set depends_on_worktree = false
on a process that doesn't depend on the worktree's code, and a swap leaves it
running instead of tearing it down and recreating it:
[process.postgres]
start = "postgres -D $SLICE_STATE_DIR/postgres"
ports = [5433]
log = "$SLICE_STATE_DIR/logs/postgres.log"
depends_on_worktree = false
This is an optimization. A swap should cost no more than restarting the code
you're actually changing; anything shared or slow to recreate — a local database,
a cache, kubectl port-forwards to services you aren't editing — can stay up
across it. It matters most when you can't run the whole system locally and lean
on shared backends to fill in the rest.
The key defaults to true. When it's false, the process keeps running while the
state is unbound; the new worktree's slice up finds it already alive by its
$SLICE_STATE_DIR/pids/<name>.pid file and leaves it in place rather than
starting a second copy. slice down and slice detach still stop it — the flag
only skips the restart a swap would cause, not a teardown you asked for.
slice source
slice source is the runtime bridge between a worktree and its state. It is
designed to be called by direnv from a .envrc in any worktree directory:
# .envrc
eval "$(slice source)"
When invoked, it:
- Locates
+clone+viagit rev-parse --git-common-dir - Derives
+state+as a sibling of+clone+ - Reads
+state+/bindings.tomlto find which state directory is mapped to the current worktree path - Reads the
[env]section of+state+/<name>/thinslice.tomland emits each entry as a bashexportstatement - Appends two thinslice-managed variables:
export SLICE_CODE_DIR="/abs/path/to/worktree"
export SLICE_STATE_DIR="/abs/path/to/+state+/name"
Errors go to stderr so that stdout stays clean for eval. Each failure mode
produces a diagnostic explaining what was expected, what was found, and the
exact steps to fix it.
SLICE_CODE_DIR and SLICE_STATE_DIR are the only variables processes need in
order to locate their own state without any knowledge of the surrounding
structure. A process that wants to find its data directory computes
$SLICE_STATE_DIR/myprogram/ and stops there.
slice state new
slice state new <slot-name> [generator-script] creates a new state slot by running
a generator script and writing its output as +state+/<slot-name>/thinslice.toml.
The generator is an executable file — any language — that prints a valid
thinslice.toml to stdout. It calls back into slice give-me-a-port to obtain
ports that are guaranteed not to conflict with any existing slot.
If no generator path is given, slice state new searches the repository for one in
this order:
config/new-state
src/new-state
A minimal bash generator looks like this:
#!/usr/bin/env bash
DB_PORT=$(slice give-me-a-port)
APP_PORT=$(slice give-me-a-port)
cat <<EOF
[env]
DATABASE_PORT = "$DB_PORT"
DATABASE_URL = "postgres://localhost:$DB_PORT/myapp"
PGDATA = "\$SLICE_STATE_DIR/postgres"
PORT = "$APP_PORT"
[process.postgres]
start = "postgres -D \$SLICE_STATE_DIR/postgres"
ports = [$DB_PORT]
log = "\$SLICE_STATE_DIR/logs/postgres.log"
[process.server]
start = "npm run dev"
ports = [$APP_PORT]
log = "\$SLICE_STATE_DIR/logs/server.log"
EOF
Because the generator is just a program, it can contain conditional logic, read existing files, or call other tools — without any DSL to learn.
After writing thinslice.toml, slice state new:
- Derives a flat
envfile from the[env]section (KEY=VALUE, one per line) for interop with shells and tools that source env files directly - Creates the
pids/andlogs/subdirectories
thinslice.toml is the source of truth; env is a derived artifact regenerated
whenever thinslice.toml changes. Tools that only need environment variables can
use env without knowing anything about the thinslice structure. slice source
reads thinslice.toml directly.
slice state new does not run any processes or perform database initialization; that
is left to the user's own setup scripts, which can use $SLICE_STATE_DIR to
locate the right place.
slice give-me-a-port
slice give-me-a-port finds an unused port and prints it to stdout. It is
designed to be called from inside a generator script during slice state new.
To select a port it:
- Collects all ports declared in
[process.*]sections across every existing slot'sthinslice.toml - Collects all ports already issued to the current generator invocation (ports
are tracked in a session tempfile that
slice state newcreates and cleans up) - Attempts a
bind()on candidate ports to confirm nothing else on the machine is using them - Prints the chosen port and appends it to the session list so the next call within the same script picks a different one
Errors go to stderr; the port number is the only thing on stdout, so
PORT=$(slice give-me-a-port) works without further parsing.

