Throwaway build sandboxes that are warm from the first second, via per-sandbox ZFS clones.
This project is very much generated by LLMs. I would not recommend using this without backups.
wt new foo gives you an isolated checkout of your repo — sources and build outputs — near
instantly. Build in it immediately: nothing recompiles, because it is a copy-on-write clone of
the tree you were just working in. Break it, wt rm foo, and it is gone. Your main checkout was
never touched.
wt new <name> [ref] snapshot+clone your current tree (uncommitted changes and all)
wt enter <name> [-- cmd...] enter the sandbox (default: bash)
wt list sandboxes, and the bytes each clone alone is holding down
wt rm <name> clone+snapshot destroyed; the branch is archived, not deleted
wt gc reclaim orphans whose sandbox is gone
wt status config + dataset state
The usual approaches to "a second checkout" all cost you the build cache:
| approach | why the build cache dies |
|---|---|
git worktree |
different path → absolute paths in build artifacts miss; a fresh target/ |
cp -r the repo |
different path, and you pay a full copy |
| container per branch | different path and different filesystem |
| shared cache dir | the compiler still re-checks and re-links everything |
wt sidesteps all of it with one trick: the sandbox lives at the same path as your main
checkout. Inside its own mount namespace, the ZFS clone is mounted over $WT_CANONICAL.
So every absolute path a build system baked into its artifacts still resolves, and the sandbox
is byte-for-byte what your working tree was — including target/, build/, node_modules/.
Sources and build outputs come from one atomic snapshot, so their mtimes stay mutually consistent. An mtime-based build system (cargo, make, ninja) therefore sees a fully warm tree and rebuilds exactly what you edit — no content-hashing flag, no mtime-aging hack, no path remapping.
zfs snapshot and zfs clone take milliseconds and are copy-on-write, so a sandbox costs only
what it diverges by. wt list shows you that number.
wt has no language, toolchain, daemon or cache built into it. Anything project-specific
comes from two hooks you configure:
WT_HOOK_ENTERruns inside the sandbox namespace, as the target user, once perwt enter, before your command. WhateverKEY=VALUElines it prints on stdout are exported into the session (direnv-style). Use it to start per-sandbox daemons and inject their env.WT_HOOK_TEARDOWNruns host-side when the last session exits, and onwt rm/wt gc. Must be idempotent. Anything the enter hook left running inside the namespace pins the clone's mount and would otherwise blockzfs destroy.
The enter hook's exit code gates the session: non-zero aborts wt enter. That is deliberate,
and it is the one rule worth internalising before you write a hook. A sandbox that starts without
what its hook guarantees is the expensive failure — if the hook's job was to hand out a
per-sandbox build-daemon socket, a session that skips it falls back to the shared one, and
then one daemon writes every sandbox's build outputs into the wrong clone. You would not learn
that from an error message; you would learn it from a corrupt artifact, days later. So make
anything advisory || true, and let a real failure stop the session. The hook's stderr goes to
your terminal, so you can see why.
The teardown hook is the opposite: its exit code is ignored. A sandbox whose teardown hook is broken must still be removable.
git clone https://github.com/AutoPallet/wt && cd wt
sudo ./install.sh # /usr/local/bin, or pass a prefixThen write a config. wt reads $WT_CONFIG if set, else ~/.config/wt/config, else
/etc/wt/config — and the environment overrides whatever the file says. Start from
wt.conf.example:
WT_CANONICAL=/workspaces/myrepo # required: the checkout wt clones
WT_DS_SRC=rpool/data/myrepo-src # required: the dataset holding it
WT_DS_PARENT=rpool/data/myrepo-wt # required: where per-sandbox clones go
WT_SNAPSHOT_EXCLUDE=logs # subpaths that must never enter a snapshot
WT_HOOK_ENTER='/opt/myrepo/wt-hook enter'
WT_HOOK_TEARDOWN='/opt/myrepo/wt-hook teardown'Config in hand, build the datasets. Run this on the host and as yourself — it re-execs itself under sudo for the parts that need root, and it reads the config you just wrote:
./host-zfs-setup.sh # once per host: builds the datasets, delegates ZFS to your userZFS. Not an implementation detail to be abstracted away later — millisecond copy-on-write
snapshots are the tool. Also: Linux, for mount namespaces; sudo, because mounting a ZFS
dataset needs real root (the ZFS module refuses the mount from inside a user namespace, so
unprivileged containers alone will not do); and git ≥ 2.17, for git worktree remove.
wt is not a privilege boundary. It assumes you already have sudo, and it uses it to mount a
clone and then immediately drop back to your own uid — so it gives you nothing you did not
already have. But wt-setup.sh runs as root and takes the paths it mounts, chowns and deletes
from its environment. Do not hand it to someone you would not hand root to, and do not write a
"narrow" NOPASSWD sudoers rule for it believing that contains it: whoever can invoke it chooses
what root mounts and what root deletes. Gate who may run wt at all, not what it does once run.
wt-ssh runs on your machine, not in the container, and hands your editor an SSH session
into a sandbox over docker exec: sshd speaks its protocol over the pipe (sshd -i), so no
port is bound anywhere. Two machines, three pieces:
- Inside the container, once:
wt ssh-setupwrites an inetd-style sshd config and rebuildsauthorized_keysfrom your agent plus~/.ssh/*.pub. Run it from your container's start hook (aswt ssh-setup || true) so it survives a container restart — and don't hand-edit the generated file, the next start rewrites it. - On your machine, once:
wt-ssh config >> ~/.ssh/configemits oneHost wt-<name>block per sandbox. Run it from inside your project's checkout — that is how wt-ssh knows which repo, and so which devcontainer, it serves. Each generated block bakes in both the repo path and the absolute path of the wt-ssh that generated it, so ssh itself can then run from anywhere and needs nothing on PATH. - Every
ssh wt-<name>(or a VS Code / JetBrains remote pointed at it) then flows: ssh →wt-ssh proxy(a ProxyCommand) →docker exec→ inetd sshd in the container →wt enter <name>.
wt-ssh is a standalone script and installing it is optional — putting it on PATH is a
convenience, not a requirement:
cd ~/code/myrepo && ~/src/wt/wt-ssh config >> ~/.ssh/config # straight from a wt checkout
sudo ./install.sh --with-ssh # or: put `wt-ssh` on PATHKnown limitation: interactive sessions and remote editors work; scp and sftp do not
("subsystem request failed"). Every session is forced through wt enter, and no SFTP subsystem
is wired through it. Move files with git, or with tar/cat over the ssh pipe.
Forking a sandbox. Sandboxes always clone main's tree; there is deliberately no
clone-of-a-clone (a ZFS dependency chain would make wt rm of the forked-from sandbox need
zfs promote). To fork sandbox A's work: commit inside A — branch wt/A is immediately
visible from main, the .git is shared — then, from main:
wt new B wt/AB starts from main's current (warm) build tree and materializes A's tree on first enter, so only what A's diff touched rebuilds.
A brand-new sandbox may report files as modified that main calls clean. That is not wt
corrupting the tree: those files genuinely disagree with what their clean filter (LFS,
line-ending normalization) would produce today — typically content committed before the filter
rule existed. Main hides the divergence behind its warm stat cache; a freshly seeded sandbox
index re-checks content and reports it. Fix the repository itself (git add --renormalize .
and commit), and until then know that git commit -a in a sandbox would commit that
renormalization.
MIT. See LICENSE.