ADR-0004 — One machine + worktrees as namespaced compose projects (not VM-per-worktree)¶
- Status: Accepted (2026-06-08, with the spike) — implemented; default-branch forking fixed
2026-07-06 (
de962868), pinned offsets added with PR #21 - Sources: SPIKE.md ("Worktrees — one machine, namespaced projects"), PLAN.md §4.1,
src/foldyard/worktree.py,src/foldyard/stack.py,src/foldyard/machine.py
Context¶
Developers run several parallel checkouts of one repo — git worktrees — and each needs its own
full stack (DB, emulators, app) plus its own dev box, simultaneously. Cloud CDEs answer this with
one VM per workspace. Podman can mirror that locally: podman machine init <name> gives a VM per
worktree.
But on a laptop that model is wrong by default. Each machine VM costs multi-GB RAM, a duplicated
image store and build/package caches on disk, and a slower boot; and — decisive for podman on
macOS — only one podman machine can run at a time (upstream #26281; machine.py start() refuses
to start beside another running VM). More fundamentally, worktree-vs-worktree kernel isolation is
not in the threat model: the asset being protected is the Mac, and the one rootless, restricted
machine already walls that off (ADR-0001). Two worktrees are your own code at equal trust —
putting a VM between them buys nothing the threat model asks for.
Decision¶
One machine; worktrees are namespaced compose projects on it.
- The worktrees root is a machine mount. The VM's isolation mount set is exactly two entries —
the main checkout and the worktrees root (
machine.py _volumes()), host path == guest path — so a new worktree needs no machine change:fy worktree add <name>creates a git worktree under the root and it is already visible to the engine. - Each worktree is its own compose project.
stack.pysetsCOMPOSE_PROJECT_NAME/PODMAN_PROJECTto<prefix>-<worktree>(bare<prefix>for main), so containers, networks, and named volumes are namespaced per worktree. Teardown honors the boundary:fy nukein a worktree drops only that project's volumes; the shared pnpm/Playwright caches and the one image store are kept (stack.pynuke path). - Deterministic host-port offsets. Every
[ports]base gets+offset(worktree), a stable 1..89 derived from the worktree name viacksum(stack.py _offset()), so a given worktree always lands on the same host ports and simultaneous stacks usually avoid colliding on localhost. The offset is a hash into 89 slots, so two worktree names can collide; the pin below is the escape hatch when they do. Precedence: an explicitWT_OFFSETenv → a pin in the main checkout's gitignoredfoldyard.local.toml[worktree-offsets]table (PR #21;_pinned_offset(), read frommain_repo()so it resolves identically whereverfyruns) → the cksum hash. Pins exist for worktrees that must sit on a known port, e.g. one a third-party service's OAuth-callback allowlist already accepts. - New worktree branches fork off the project's default branch (
de962868).git worktree add -bwith no base ref forks from the primary checkout's current HEAD — usually some other in-flight branch, whose commits then silently leak into every new worktree.worktree.py _default_base_ref()now resolves an explicit--from, then[machine].worktree_base/FOLDYARD_WORKTREE_BASE, then auto-detects (origin/HEAD, thenmain/master), falling back to current HEAD only when nothing resolves. - Worktree lifecycle verbs:
fy worktree add|list|remove.removetears down the worktree's box and stack, archives its Claude transcripts to the durable host-side store before git deletes the checkout, and refuses (without--force) if archiving fails.
Escape hatch: for a genuinely untrusted worktree — reviewing unknown code — spin a throwaway dedicated machine for just that one. Isolation is a spectrum, light by default; the heavy shape is one command away when the trust assumption ("two worktrees are your own code") doesn't hold.
Consequences¶
- N stacks + N boxes share one VM's RAM, one image store, and the shared caches — bringing up a second worktree costs seconds, not a VM boot, and everything stays visible in one Podman Desktop view.
- The namespacing carries beyond the stack: per-worktree posture state and per-worktree proxy/minter ports (ADR-0016) reuse exactly this worktree key and port offset, so a worktree's daemons, containers, and posture always agree on identity.
- The hashed offset can collide between two worktree names (89 slots);
[worktree-offsets]pins are also the manual fix for that. - Cloud parity is preserved as a deployment knob, not a fork: the same compose files run one-VM-per-workspace in the cloud (where VMs are cheap at rest) and one-shared-machine locally.
- Mount scope is the blast-radius trade-off: the VM sees the narrowest directory containing all worktrees, so a box can read sibling worktrees. Accepted — they are equal-trust code by the same argument that rejected VM-per-worktree; the escape hatch covers the exception.
Rejected alternatives¶
- VM-per-worktree — multi-GB per worktree, duplicated images/caches, slow boots, and impossible under podman-on-macOS's one-machine-at-a-time constraint (ADR-0011); defends against a threat (worktree-vs-worktree escape) that isn't in the model.
- One compose project with prefixed service names — loses compose-level lifecycle isolation
(
down/nukescoping, default networks) that project namespacing gives for free. - Dynamic (first-free) port allocation — ports would change across restarts; the deterministic hash keeps a worktree's URLs stable, and pins cover the cases that need exact values.