Skip to content

AutoPallet/wt

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

16 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

wt

Throwaway build sandboxes that are warm from the first second, via per-sandbox ZFS clones.

LLM disclaimer

This project is very much generated by LLMs. I would not recommend using this without backups.

What does it do?

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

Why

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.

Configuring the hooks

wt has no language, toolchain, daemon or cache built into it. Anything project-specific comes from two hooks you configure:

  • WT_HOOK_ENTER runs inside the sandbox namespace, as the target user, once per wt enter, before your command. Whatever KEY=VALUE lines it prints on stdout are exported into the session (direnv-style). Use it to start per-sandbox daemons and inject their env.
  • WT_HOOK_TEARDOWN runs host-side when the last session exits, and on wt rm / wt gc. Must be idempotent. Anything the enter hook left running inside the namespace pins the clone's mount and would otherwise block zfs 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.

Install

git clone https://github.com/AutoPallet/wt && cd wt
sudo ./install.sh                 # /usr/local/bin, or pass a prefix

Then 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 user

Requirements

ZFS. 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.

Editor over SSH, with no listening port

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-setup writes an inetd-style sshd config and rebuilds authorized_keys from your agent plus ~/.ssh/*.pub. Run it from your container's start hook (as wt 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/config emits one Host 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 PATH

Known 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.

Sharp edges

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/A

B 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.

License

MIT. See LICENSE.

About

git worktree wrapper that clones your entire source directory using ZFS to give you near-instant Cargo (or whatever) builds

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages