This commit is contained in:
Chris Wanstrath 2026-02-17 08:15:03 -08:00
parent 6f6d921c54
commit 45bb19a8d9
4 changed files with 97 additions and 66 deletions

View File

@ -1,12 +1,12 @@
# sandlot
A CLI for branch-based development using git worktrees and [Apple containers](https://github.com/apple/container). Each branch gets its own worktree and isolated VM.
A CLI for branch-based development using git worktrees and [Lima](https://github.com/lima-vm/lima) VMs. Each branch gets its own worktree and isolated VM.
## Prerequisites
- macOS on Apple Silicon
- [Bun](https://bun.sh)
- [container](https://github.com/apple/container) installed and on PATH
- [Lima](https://github.com/lima-vm/lima) (`brew install lima`)
- Git
## Install
@ -28,7 +28,7 @@ Run all commands from inside a cloned git repo.
sandlot new fix-POST
```
Creates a worktree at `.sandlot/fix-POST/`, boots a VM mapped to it, and drops you into a shell.
Creates a worktree at `.sandlot/fix-POST/`, boots a Lima VM mapped to it, and drops you into a shell.
### Other commands

16
SPEC.md
View File

@ -1,10 +1,10 @@
# sandlot
A CLI for branch-based development using git worktrees and Apple containers. Each branch gets its own worktree and isolated VM.
A CLI for branch-based development using git worktrees and Lima VMs. Each branch gets its own worktree and isolated VM.
## Concepts
**Sandlot** is a thin workflow layer over two things: git worktrees and [Apple containers](https://github.com/apple/container). The idea is that spinning up a branch should give you a fully isolated environment — filesystem and runtime — with zero setup. When you're done, use git to merge and `sandlot rm` to clean up.
**Sandlot** is a thin workflow layer over two things: git worktrees and [Lima](https://github.com/lima-vm/lima). The idea is that spinning up a branch should give you a fully isolated environment — filesystem and runtime — with zero setup. When you're done, use git to merge and `sandlot rm` to clean up.
A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot new <branch>` and destroyed with `sandlot rm <branch>`.
@ -12,7 +12,7 @@ A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are cr
- [Bun](https://bun.sh) runtime
- [Commander](https://github.com/tj/commander.js) for CLI parsing
- [Apple container](https://github.com/apple/container) for VMs
- [Lima](https://github.com/lima-vm/lima) for VMs
## Setup
@ -21,7 +21,7 @@ A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are cr
- macOS on Apple Silicon
- [Bun](https://bun.sh) installed
- Git installed
- [container](https://github.com/apple/container) installed and available on PATH
- [Lima](https://github.com/lima-vm/lima) installed (`brew install lima`)
### Install
@ -42,7 +42,7 @@ Create a new session. This:
1. Checks out the branch if it exists (local or remote), or creates a new branch from current HEAD
2. Creates a git worktree at `.sandlot/<branch>/` (relative to the repo root)
3. Boots an Apple container VM mapped to that worktree
3. Boots a Lima VM mapped to that worktree
4. Drops you into the VM shell
```
@ -59,8 +59,8 @@ Show all active sessions.
```
$ sandlot list
BRANCH VM STATUS WORKTREE
fix-POST running .sandlot/fix-POST/
refactor-auth stopped .sandlot/refactor-auth/
fix-POST Running .sandlot/fix-POST/
refactor-auth Stopped .sandlot/refactor-auth/
```
### `sandlot open <branch>`
@ -108,7 +108,7 @@ Sandlot tracks sessions in `.sandlot/state.json` at the repo root:
"fix-POST": {
"branch": "fix-POST",
"worktree": ".sandlot/fix-POST",
"vm_id": "container-abc123",
"vm_id": "sandlot-fix-POST",
"created_at": "2026-02-16T10:30:00Z",
"status": "running"
}

View File

@ -12,7 +12,7 @@ const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
const program = new Command()
program.name("sandlot").description("Branch-based development with git worktrees and Apple containers").version(pkg.version)
program.name("sandlot").description("Branch-based development with git worktrees and Lima VMs").version(pkg.version)
// ── sandlot new <branch> ──────────────────────────────────────────────
@ -37,7 +37,8 @@ program
await git.createWorktree(branch, worktreeAbs, root)
console.log("Booting VM...")
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm)
const name = vm.limaName(branch)
const vmId = await vm.boot(name, worktreeAbs, config.vm)
await state.setSession(root, {
branch,
@ -108,17 +109,12 @@ program
// Stale VM, reboot
console.log("VM is gone. Rebooting...")
const worktreeAbs = join(root, session.worktree)
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm)
const vmId = await vm.boot(vm.limaName(branch), worktreeAbs, config.vm)
await state.setSession(root, { ...session, vm_id: vmId, status: "running" })
await vm.shell(vmId)
} else if (vmStatus === "stopped") {
console.log("Booting VM...")
// Need to start the existing container
const proc = Bun.spawn(["container", "start", session.vm_id], {
stdout: "inherit",
stderr: "inherit",
})
await proc.exited
await vm.start(session.vm_id)
await state.setSession(root, { ...session, status: "running" })
await vm.shell(session.vm_id)
} else {

127
src/vm.ts
View File

@ -1,66 +1,97 @@
import { $ } from "bun"
import type { VmConfig } from "./config.ts"
/** Boot a container VM mapped to a worktree directory. Returns the container ID. */
/** Sanitize a branch name into a valid Lima instance name. */
export function limaName(branch: string): string {
return "sandlot-" + branch.replace(/[^a-zA-Z0-9-]/g, "-")
}
/** Map a container image string (e.g. "ubuntu:24.04") to a Lima template. */
function mapImageToTemplate(image?: string): string {
const img = image ?? "ubuntu:24.04"
// "ubuntu:24.04" → "template://ubuntu-24.04"
const normalized = img.replace(":", "-")
return `template://${normalized}`
}
/** Create and start a Lima VM mapped to a worktree directory. Returns the instance name. */
export async function boot(
name: string,
worktreePath: string,
config?: VmConfig
): Promise<string> {
const args: string[] = ["container", "run", "--name", name]
const args: string[] = ["limactl", "create", `--name=${name}`]
if (config?.cpus) args.push("--cpus", String(config.cpus))
if (config?.memory) args.push("--memory", config.memory)
if (config?.cpus) args.push(`--cpus=${config.cpus}`)
if (config?.memory) args.push(`--memory=${config.memory}`)
// Mount worktree as /root/work
args.push("--mount", `type=virtiofs,source=${worktreePath},target=/root/work`)
args.push(`--mount-writable=${worktreePath}:/root/work`)
// Additional mounts from config
if (config?.mounts) {
for (const [source, target] of Object.entries(config.mounts)) {
args.push("--mount", `type=virtiofs,source=${source},target=${target}`)
args.push(`--mount-writable=${source}:${target}`)
}
}
const image = config?.image ?? "ubuntu:24.04"
args.push("-d", image)
const template = mapImageToTemplate(config?.image)
args.push(template)
const result = await $`${args}`.text()
return result.trim()
// Don't quiet create so user sees download progress
await $`${args}`
await $`limactl start ${name}`.quiet()
return name
}
/** Stop a running container. */
export async function stop(vmId: string): Promise<void> {
await $`container stop ${vmId}`.nothrow().quiet()
/** Start a stopped Lima instance. */
export async function start(name: string): Promise<void> {
await $`limactl start ${name}`
}
/** Remove a container. */
export async function rm(vmId: string): Promise<void> {
await $`container rm ${vmId}`.nothrow().quiet()
/** Stop a running Lima instance. */
export async function stop(name: string): Promise<void> {
await $`limactl stop ${name}`.nothrow().quiet()
}
/** Stop and remove a container. */
export async function destroy(vmId: string): Promise<void> {
await stop(vmId)
await rm(vmId)
/** Delete a Lima instance. */
export async function rm(name: string): Promise<void> {
await $`limactl delete ${name}`.nothrow().quiet()
}
/** Check if a container is running. Returns "running", "stopped", or "missing". */
export async function status(vmId: string): Promise<"running" | "stopped" | "missing"> {
const result = await $`container inspect ${vmId} --format '{{.State.Status}}'`
.nothrow()
.quiet()
.text()
/** Stop and delete a Lima instance. */
export async function destroy(name: string): Promise<void> {
await stop(name)
await rm(name)
}
/** Check if a Lima instance is running. Returns "running", "stopped", or "missing". */
export async function status(name: string): Promise<"running" | "stopped" | "missing"> {
const result = await $`limactl list --json`.nothrow().quiet().text()
// limactl list --json outputs JSONL (one JSON object per line)
for (const line of result.trim().split("\n")) {
if (!line) continue
try {
const instance = JSON.parse(line)
if (instance.name === name) {
const s = instance.status?.toLowerCase()
if (s === "running") return "running"
return "stopped"
}
} catch {
continue
}
}
const state = result.trim().replace(/'/g, "")
if (state.includes("running")) return "running"
if (state.includes("exited") || state.includes("stopped") || state.includes("created")) return "stopped"
return "missing"
}
/** Exec into a container shell interactively. */
export async function shell(vmId: string): Promise<void> {
const proc = Bun.spawn(["container", "exec", "-it", vmId, "/bin/bash"], {
/** Exec into a Lima instance shell interactively. */
export async function shell(name: string): Promise<void> {
const proc = Bun.spawn(["limactl", "shell", name], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
@ -68,19 +99,23 @@ export async function shell(vmId: string): Promise<void> {
await proc.exited
}
/** List all containers with their names and statuses. */
export async function list(): Promise<Array<{ id: string; name: string; status: string }>> {
const result = await $`container ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}'`
.nothrow()
.quiet()
.text()
/** List all sandlot Lima instances with their names and statuses. */
export async function list(): Promise<Array<{ name: string; status: string }>> {
const result = await $`limactl list --json`.nothrow().quiet().text()
return result
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [id, name, status] = line.replace(/'/g, "").split("\t")
return { id, name, status }
})
const instances: Array<{ name: string; status: string }> = []
for (const line of result.trim().split("\n")) {
if (!line) continue
try {
const instance = JSON.parse(line)
if (instance.name?.startsWith("sandlot-")) {
instances.push({ name: instance.name, status: instance.status ?? "Unknown" })
}
} catch {
continue
}
}
return instances
}