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.exampleconfig.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:

  1. Locates +clone+ via git rev-parse --git-common-dir
  2. Derives +state+ as a sibling of +clone+
  3. Reads +state+/bindings.toml to find which state directory is mapped to the current worktree path
  4. Reads the [env] section of +state+/<name>/thinslice.toml and emits each entry as a bash export statement
  5. 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 env file from the [env] section (KEY=VALUE, one per line) for interop with shells and tools that source env files directly
  • Creates the pids/ and logs/ 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:

  1. Collects all ports declared in [process.*] sections across every existing slot's thinslice.toml
  2. Collects all ports already issued to the current generator invocation (ports are tracked in a session tempfile that slice state new creates and cleans up)
  3. Attempts a bind() on candidate ports to confirm nothing else on the machine is using them
  4. 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.