This commit is contained in:
Chris Wanstrath 2026-02-16 20:22:44 -08:00
parent 08f223add7
commit 033331db98
7 changed files with 286 additions and 268 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "sandlot", "name": "sandlot",
"version": "0.1.0", "version": "0.0.1",
"type": "module", "type": "module",
"bin": { "bin": {
"sandlot": "./src/cli.ts" "sandlot": "./src/cli.ts"

View File

@ -1,17 +1,17 @@
import Anthropic from "@anthropic-ai/sdk"; import Anthropic from "@anthropic-ai/sdk"
let client: Anthropic | null = null; let client: Anthropic | null = null
function getClient(): Anthropic { function getClient(): Anthropic {
if (!client) { if (!client) {
client = new Anthropic(); client = new Anthropic()
} }
return client; return client
} }
/** Generate a commit message from a diff. */ /** Generate a commit message from a diff. */
export async function generateCommitMessage(diff: string, model: string): Promise<string> { export async function generateCommitMessage(diff: string, model: string): Promise<string> {
const anthropic = getClient(); const anthropic = getClient()
const response = await anthropic.messages.create({ const response = await anthropic.messages.create({
model, model,
@ -22,11 +22,11 @@ export async function generateCommitMessage(diff: string, model: string): Promis
content: `Generate a git commit message for this diff. The first line must be a single-line summary of 72 characters or less. If the diff is substantial, add a blank line followed by a body with more detail. Return ONLY the commit message, nothing else.\n\n${diff}`, content: `Generate a git commit message for this diff. The first line must be a single-line summary of 72 characters or less. If the diff is substantial, add a blank line followed by a body with more detail. Return ONLY the commit message, nothing else.\n\n${diff}`,
}, },
], ],
}); })
const block = response.content[0]; const block = response.content[0]
if (block.type === "text") return block.text.trim(); if (block.type === "text") return block.text.trim()
throw new Error("Unexpected response from Claude"); throw new Error("Unexpected response from Claude")
} }
/** Resolve a merge conflict in a file. Returns the resolved content and an explanation. */ /** Resolve a merge conflict in a file. Returns the resolved content and an explanation. */
@ -35,7 +35,7 @@ export async function resolveConflict(
conflictContent: string, conflictContent: string,
model: string model: string
): Promise<{ resolved: string; explanation: string }> { ): Promise<{ resolved: string; explanation: string }> {
const anthropic = getClient(); const anthropic = getClient()
const response = await anthropic.messages.create({ const response = await anthropic.messages.create({
model, model,
@ -54,21 +54,21 @@ File with conflicts:
${conflictContent}`, ${conflictContent}`,
}, },
], ],
}); })
const block = response.content[0]; const block = response.content[0]
if (block.type !== "text") throw new Error("Unexpected response from Claude"); if (block.type !== "text") throw new Error("Unexpected response from Claude")
const text = block.text; const text = block.text
const explMatch = text.match(/EXPLANATION:\s*(.+)/); const explMatch = text.match(/EXPLANATION:\s*(.+)/)
const resolvedMatch = text.match(/RESOLVED:\n([\s\S]+)/); const resolvedMatch = text.match(/RESOLVED:\n([\s\S]+)/)
if (!explMatch || !resolvedMatch) { if (!explMatch || !resolvedMatch) {
throw new Error("Could not parse Claude's conflict resolution response"); throw new Error("Could not parse Claude's conflict resolution response")
} }
return { return {
explanation: explMatch[1].trim(), explanation: explMatch[1].trim(),
resolved: resolvedMatch[1].trimEnd() + "\n", resolved: resolvedMatch[1].trimEnd() + "\n",
}; }
} }

View File

@ -1,16 +1,34 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Command } from "commander"; import { homedir } from "os"
import { join } from "path"; import { Command } from "commander"
import * as git from "./git.ts"; import { join } from "path"
import * as vm from "./vm.ts";
import * as state from "./state.ts";
import { loadConfig } from "./config.ts";
import { generateCommitMessage, resolveConflict } from "./ai.ts";
const program = new Command(); // Load ~/.env into process.env
const envFile = Bun.file(join(homedir(), ".env"))
if (await envFile.exists()) {
const text = await envFile.text()
for (const line of text.split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const eq = trimmed.indexOf("=")
if (eq === -1) continue
const key = trimmed.slice(0, eq)
const val = trimmed.slice(eq + 1).replace(/^["']|["']$/g, "")
process.env[key] ??= val
}
}
import * as git from "./git.ts"
import * as vm from "./vm.ts"
import * as state from "./state.ts"
import { loadConfig } from "./config.ts"
import { generateCommitMessage, resolveConflict } from "./ai.ts"
program.name("sandlot").description("Branch-based development with git worktrees and Apple containers").version("0.1.0"); 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)
// ── sandlot new <branch> ────────────────────────────────────────────── // ── sandlot new <branch> ──────────────────────────────────────────────
@ -19,23 +37,23 @@ program
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Create a new session with a worktree and VM") .description("Create a new session with a worktree and VM")
.action(async (branch: string) => { .action(async (branch: string) => {
const root = await git.repoRoot(); const root = await git.repoRoot()
const config = await loadConfig(root); const config = await loadConfig(root)
const worktreeRel = `.sandlot/${branch}`; const worktreeRel = `.sandlot/${branch}`
const worktreeAbs = join(root, worktreeRel); const worktreeAbs = join(root, worktreeRel)
// Check for stale directory // Check for stale directory
const existing = await state.getSession(root, branch); const existing = await state.getSession(root, branch)
if (existing) { if (existing) {
console.error(`Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`); console.error(`Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`)
process.exit(1); process.exit(1)
} }
console.log(`Creating worktree at ${worktreeRel}/`); console.log(`Creating worktree at ${worktreeRel}/`)
await git.createWorktree(branch, worktreeAbs, root); await git.createWorktree(branch, worktreeAbs, root)
console.log("Booting VM..."); console.log("Booting VM...")
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm); const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm)
await state.setSession(root, { await state.setSession(root, {
branch, branch,
@ -43,10 +61,10 @@ program
vm_id: vmId, vm_id: vmId,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
status: "running", status: "running",
}); })
await vm.shell(vmId); await vm.shell(vmId)
}); })
// ── sandlot save [message] ──────────────────────────────────────────── // ── sandlot save [message] ────────────────────────────────────────────
@ -55,35 +73,35 @@ program
.argument("[message]", "commit message (auto-generated if omitted)") .argument("[message]", "commit message (auto-generated if omitted)")
.description("Stage and commit all changes, push to origin") .description("Stage and commit all changes, push to origin")
.action(async (message?: string) => { .action(async (message?: string) => {
const root = await git.repoRoot(); const root = await git.repoRoot()
const config = await loadConfig(root); const config = await loadConfig(root)
const branch = await detectBranch(root); const branch = await detectBranch(root)
const session = await state.getSession(root, branch); const session = await state.getSession(root, branch)
const cwd = session ? join(root, session.worktree) : root; const cwd = session ? join(root, session.worktree) : root
if (!(await git.hasChanges(cwd))) { if (!(await git.hasChanges(cwd))) {
console.log("No changes to commit."); console.log("No changes to commit.")
return; return
} }
const staged = await git.stageAll(cwd); const staged = await git.stageAll(cwd)
console.log(`Staged ${staged} files`); console.log(`Staged ${staged} files`)
let commitMsg: string; let commitMsg: string
if (message) { if (message) {
commitMsg = message; commitMsg = message
} else { } else {
const diff = await git.stagedDiff(cwd); const diff = await git.stagedDiff(cwd)
commitMsg = await generateCommitMessage(diff, config.ai?.model ?? "claude-sonnet-4-20250514"); commitMsg = await generateCommitMessage(diff, config.ai?.model ?? "claude-sonnet-4-20250514")
} }
await git.commit(commitMsg, cwd); await git.commit(commitMsg, cwd)
const firstLine = commitMsg.split("\n")[0]; const firstLine = commitMsg.split("\n")[0]
console.log(`Commit: ${firstLine}`); console.log(`Commit: ${firstLine}`)
await git.push(branch, cwd); await git.push(branch, cwd)
console.log(`Pushed ${branch} → origin/${branch}`); console.log(`Pushed ${branch} → origin/${branch}`)
}); })
// ── sandlot push <target> ───────────────────────────────────────────── // ── sandlot push <target> ─────────────────────────────────────────────
@ -92,84 +110,84 @@ program
.argument("<target>", "target branch to merge into") .argument("<target>", "target branch to merge into")
.description("Merge session branch into target, then tear down") .description("Merge session branch into target, then tear down")
.action(async (target: string) => { .action(async (target: string) => {
const root = await git.repoRoot(); const root = await git.repoRoot()
const config = await loadConfig(root); const config = await loadConfig(root)
const branch = await detectBranch(root); const branch = await detectBranch(root)
const session = await state.getSession(root, branch); const session = await state.getSession(root, branch)
if (!session) { if (!session) {
console.error(`No session found for branch "${branch}".`); console.error(`No session found for branch "${branch}".`)
process.exit(1); process.exit(1)
} }
const worktreeCwd = join(root, session.worktree); const worktreeCwd = join(root, session.worktree)
// Check for uncommitted changes // Check for uncommitted changes
if (await git.hasChanges(worktreeCwd)) { if (await git.hasChanges(worktreeCwd)) {
console.error(`Uncommitted changes in worktree. Run "sandlot save" first.`); console.error(`Uncommitted changes in worktree. Run "sandlot save" first.`)
process.exit(1); process.exit(1)
} }
console.log(`Pushing ${branch}${target}...`); console.log(`Pushing ${branch}${target}...`)
// Checkout target in main working tree // Checkout target in main working tree
await git.checkout(target, root); await git.checkout(target, root)
// Merge // Merge
const merged = await git.merge(branch, root); const merged = await git.merge(branch, root)
if (!merged) { if (!merged) {
// Handle conflicts // Handle conflicts
const conflicts = await git.conflictedFiles(root); const conflicts = await git.conflictedFiles(root)
console.log(`${conflicts.length} conflicts found. Resolving with Claude...\n`); console.log(`${conflicts.length} conflicts found. Resolving with Claude...\n`)
const model = config.ai?.model ?? "claude-sonnet-4-20250514"; const model = config.ai?.model ?? "claude-sonnet-4-20250514"
const resolutions: Array<{ file: string; explanation: string }> = []; const resolutions: Array<{ file: string; explanation: string }> = []
for (const file of conflicts) { for (const file of conflicts) {
const content = await git.conflictContent(file, root); const content = await git.conflictContent(file, root)
const { resolved, explanation } = await resolveConflict(file, content, model); const { resolved, explanation } = await resolveConflict(file, content, model)
await Bun.write(join(root, file), resolved); await Bun.write(join(root, file), resolved)
await git.stageFile(file, root); await git.stageFile(file, root)
resolutions.push({ file, explanation }); resolutions.push({ file, explanation })
} }
for (const { file, explanation } of resolutions) { for (const { file, explanation } of resolutions) {
console.log(` ${file}`); console.log(` ${file}`)
console.log(`${explanation}\n`); console.log(`${explanation}\n`)
} }
// Prompt for confirmation // Prompt for confirmation
process.stdout.write("Accept Claude's resolutions? [Y/n] "); process.stdout.write("Accept Claude's resolutions? [Y/n] ")
const answer = await readLine(); const answer = await readLine()
if (answer.toLowerCase() === "n") { if (answer.toLowerCase() === "n") {
await git.abortMerge(root); await git.abortMerge(root)
console.log("Merge aborted. Resolve conflicts manually, then run sandlot push again."); console.log("Merge aborted. Resolve conflicts manually, then run sandlot push again.")
return; return
} }
await git.commitMerge(root); await git.commitMerge(root)
console.log("Committed merge"); console.log("Committed merge")
} }
// Push target // Push target
await git.push(target, root); await git.push(target, root)
console.log(`Pushed ${target} → origin/${target}`); console.log(`Pushed ${target} → origin/${target}`)
// Tear down // Tear down
await vm.destroy(session.vm_id); await vm.destroy(session.vm_id)
console.log(`Stopped VM ${branch}`); console.log(`Stopped VM ${branch}`)
await git.removeWorktree(join(root, session.worktree), root); await git.removeWorktree(join(root, session.worktree), root)
console.log(`Removed worktree ${session.worktree}/`); console.log(`Removed worktree ${session.worktree}/`)
await git.deleteLocalBranch(branch, root); await git.deleteLocalBranch(branch, root)
await git.deleteRemoteBranch(branch, root); await git.deleteRemoteBranch(branch, root)
console.log(`Deleted branch ${branch} (local + remote)`); console.log(`Deleted branch ${branch} (local + remote)`)
await state.removeSession(root, branch); await state.removeSession(root, branch)
}); })
// ── sandlot list ────────────────────────────────────────────────────── // ── sandlot list ──────────────────────────────────────────────────────
@ -177,35 +195,35 @@ program
.command("list") .command("list")
.description("Show all active sessions") .description("Show all active sessions")
.action(async () => { .action(async () => {
const root = await git.repoRoot(); const root = await git.repoRoot()
const st = await state.load(root); const st = await state.load(root)
const sessions = Object.values(st.sessions); const sessions = Object.values(st.sessions)
if (sessions.length === 0) { if (sessions.length === 0) {
console.log("No active sessions."); console.log("No active sessions.")
return; return
} }
// Check actual VM statuses // Check actual VM statuses
const rows: Array<{ branch: string; vmStatus: string; worktree: string }> = []; const rows: Array<{ branch: string; vmStatus: string; worktree: string }> = []
for (const s of sessions) { for (const s of sessions) {
const vmStatus = await vm.status(s.vm_id); const vmStatus = await vm.status(s.vm_id)
rows.push({ branch: s.branch, vmStatus, worktree: s.worktree }); rows.push({ branch: s.branch, vmStatus, worktree: s.worktree })
} }
// Print table // Print table
const branchWidth = Math.max(6, ...rows.map((r) => r.branch.length)); const branchWidth = Math.max(6, ...rows.map((r) => r.branch.length))
const statusWidth = Math.max(9, ...rows.map((r) => r.vmStatus.length)); const statusWidth = Math.max(9, ...rows.map((r) => r.vmStatus.length))
console.log( console.log(
`${"BRANCH".padEnd(branchWidth)} ${"VM STATUS".padEnd(statusWidth)} WORKTREE` `${"BRANCH".padEnd(branchWidth)} ${"VM STATUS".padEnd(statusWidth)} WORKTREE`
); )
for (const row of rows) { for (const row of rows) {
console.log( console.log(
`${row.branch.padEnd(branchWidth)} ${row.vmStatus.padEnd(statusWidth)} ${row.worktree}/` `${row.branch.padEnd(branchWidth)} ${row.vmStatus.padEnd(statusWidth)} ${row.worktree}/`
); )
} }
}); })
// ── sandlot open <branch> ───────────────────────────────────────────── // ── sandlot open <branch> ─────────────────────────────────────────────
@ -214,38 +232,38 @@ program
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Re-enter an existing session's VM") .description("Re-enter an existing session's VM")
.action(async (branch: string) => { .action(async (branch: string) => {
const root = await git.repoRoot(); const root = await git.repoRoot()
const config = await loadConfig(root); const config = await loadConfig(root)
const session = await state.getSession(root, branch); const session = await state.getSession(root, branch)
if (!session) { if (!session) {
console.error(`No session found for branch "${branch}".`); console.error(`No session found for branch "${branch}".`)
process.exit(1); process.exit(1)
} }
const vmStatus = await vm.status(session.vm_id); const vmStatus = await vm.status(session.vm_id)
if (vmStatus === "missing") { if (vmStatus === "missing") {
// Stale VM, reboot // Stale VM, reboot
console.log("VM is gone. Rebooting..."); console.log("VM is gone. Rebooting...")
const worktreeAbs = join(root, session.worktree); const worktreeAbs = join(root, session.worktree)
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm); const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm)
await state.setSession(root, { ...session, vm_id: vmId, status: "running" }); await state.setSession(root, { ...session, vm_id: vmId, status: "running" })
await vm.shell(vmId); await vm.shell(vmId)
} else if (vmStatus === "stopped") { } else if (vmStatus === "stopped") {
console.log("Booting VM..."); console.log("Booting VM...")
// Need to start the existing container // Need to start the existing container
const proc = Bun.spawn(["container", "start", session.vm_id], { const proc = Bun.spawn(["container", "start", session.vm_id], {
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
}); })
await proc.exited; await proc.exited
await state.setSession(root, { ...session, status: "running" }); await state.setSession(root, { ...session, status: "running" })
await vm.shell(session.vm_id); await vm.shell(session.vm_id)
} else { } else {
await vm.shell(session.vm_id); await vm.shell(session.vm_id)
} }
}); })
// ── sandlot stop <branch> ───────────────────────────────────────────── // ── sandlot stop <branch> ─────────────────────────────────────────────
@ -254,18 +272,18 @@ program
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Stop a session's VM without destroying it") .description("Stop a session's VM without destroying it")
.action(async (branch: string) => { .action(async (branch: string) => {
const root = await git.repoRoot(); const root = await git.repoRoot()
const session = await state.getSession(root, branch); const session = await state.getSession(root, branch)
if (!session) { if (!session) {
console.error(`No session found for branch "${branch}".`); console.error(`No session found for branch "${branch}".`)
process.exit(1); process.exit(1)
} }
await vm.stop(session.vm_id); await vm.stop(session.vm_id)
await state.setSession(root, { ...session, status: "stopped" }); await state.setSession(root, { ...session, status: "stopped" })
console.log(`Stopped VM for ${branch}`); console.log(`Stopped VM for ${branch}`)
}); })
// ── sandlot rm <branch> ─────────────────────────────────────────────── // ── sandlot rm <branch> ───────────────────────────────────────────────
@ -274,25 +292,25 @@ program
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Tear down a session without merging") .description("Tear down a session without merging")
.action(async (branch: string) => { .action(async (branch: string) => {
const root = await git.repoRoot(); const root = await git.repoRoot()
const session = await state.getSession(root, branch); const session = await state.getSession(root, branch)
if (!session) { if (!session) {
console.error(`No session found for branch "${branch}".`); console.error(`No session found for branch "${branch}".`)
process.exit(1); process.exit(1)
} }
await vm.destroy(session.vm_id); await vm.destroy(session.vm_id)
console.log(`Stopped VM ${branch}`); console.log(`Stopped VM ${branch}`)
await git.removeWorktree(join(root, session.worktree), root); await git.removeWorktree(join(root, session.worktree), root)
console.log(`Removed worktree ${session.worktree}/`); console.log(`Removed worktree ${session.worktree}/`)
await git.deleteLocalBranch(branch, root); await git.deleteLocalBranch(branch, root)
console.log(`Deleted local branch ${branch}`); console.log(`Deleted local branch ${branch}`)
await state.removeSession(root, branch); await state.removeSession(root, branch)
}); })
// ── Helpers ─────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────
@ -300,32 +318,32 @@ program
async function detectBranch(root: string): Promise<string> { async function detectBranch(root: string): Promise<string> {
// Check env var first (set when inside a VM) // Check env var first (set when inside a VM)
if (process.env.SANDLOT_BRANCH) { if (process.env.SANDLOT_BRANCH) {
return process.env.SANDLOT_BRANCH; return process.env.SANDLOT_BRANCH
} }
// Check if cwd is inside a worktree // Check if cwd is inside a worktree
const cwd = process.cwd(); const cwd = process.cwd()
const sandlotDir = join(root, ".sandlot"); const sandlotDir = join(root, ".sandlot")
if (cwd.startsWith(sandlotDir)) { if (cwd.startsWith(sandlotDir)) {
const rel = cwd.slice(sandlotDir.length + 1); const rel = cwd.slice(sandlotDir.length + 1)
const branch = rel.split("/")[0]; const branch = rel.split("/")[0]
if (branch) return branch; if (branch) return branch
} }
// Fall back to current git branch // Fall back to current git branch
return await git.currentBranch(); return await git.currentBranch()
} }
/** Read a line from stdin. */ /** Read a line from stdin. */
function readLine(): Promise<string> { function readLine(): Promise<string> {
return new Promise((resolve) => { return new Promise((resolve) => {
process.stdin.setRawMode?.(false); process.stdin.setRawMode?.(false)
process.stdin.resume(); process.stdin.resume()
process.stdin.once("data", (chunk) => { process.stdin.once("data", (chunk) => {
process.stdin.pause(); process.stdin.pause()
resolve(chunk.toString().trim()); resolve(chunk.toString().trim())
}); })
}); })
} }
program.parse(); program.parse()

View File

@ -1,35 +1,35 @@
import { join } from "path"; import { join } from "path"
export interface VmConfig { export interface VmConfig {
cpus?: number; cpus?: number
memory?: string; memory?: string
image?: string; image?: string
mounts?: Record<string, string>; mounts?: Record<string, string>
} }
export interface AiConfig { export interface AiConfig {
model?: string; model?: string
} }
export interface SandlotConfig { export interface SandlotConfig {
vm?: VmConfig; vm?: VmConfig
ai?: AiConfig; ai?: AiConfig
} }
const DEFAULT_CONFIG: SandlotConfig = { const DEFAULT_CONFIG: SandlotConfig = {
vm: { image: "ubuntu:24.04" }, vm: { image: "ubuntu:24.04" },
ai: { model: "claude-sonnet-4-20250514" }, ai: { model: "claude-sonnet-4-20250514" },
}; }
export async function loadConfig(repoRoot: string): Promise<SandlotConfig> { export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
const path = join(repoRoot, "sandlot.json"); const path = join(repoRoot, "sandlot.json")
const file = Bun.file(path); const file = Bun.file(path)
if (await file.exists()) { if (await file.exists()) {
const userConfig = await file.json(); const userConfig = await file.json()
return { return {
vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm }, vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm },
ai: { ...DEFAULT_CONFIG.ai, ...userConfig.ai }, ai: { ...DEFAULT_CONFIG.ai, ...userConfig.ai },
}; }
} }
return DEFAULT_CONFIG; return DEFAULT_CONFIG
} }

View File

@ -1,121 +1,121 @@
import { $ } from "bun"; import { $ } from "bun"
/** Get the repo root from a working directory. */ /** Get the repo root from a working directory. */
export async function repoRoot(cwd?: string): Promise<string> { export async function repoRoot(cwd?: string): Promise<string> {
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").text(); const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").text()
return result.trim(); return result.trim()
} }
/** Get the current branch name. */ /** Get the current branch name. */
export async function currentBranch(cwd?: string): Promise<string> { export async function currentBranch(cwd?: string): Promise<string> {
const result = await $`git rev-parse --abbrev-ref HEAD`.cwd(cwd ?? ".").text(); const result = await $`git rev-parse --abbrev-ref HEAD`.cwd(cwd ?? ".").text()
return result.trim(); return result.trim()
} }
/** Check if a branch exists locally or remotely. Returns "local", "remote", or null. */ /** Check if a branch exists locally or remotely. Returns "local", "remote", or null. */
export async function branchExists(branch: string, cwd?: string): Promise<"local" | "remote" | null> { export async function branchExists(branch: string, cwd?: string): Promise<"local" | "remote" | null> {
const dir = cwd ?? "."; const dir = cwd ?? "."
const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet(); const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet()
if (local.exitCode === 0) return "local"; if (local.exitCode === 0) return "local"
await $`git fetch origin`.cwd(dir).nothrow().quiet(); await $`git fetch origin`.cwd(dir).nothrow().quiet()
const remote = await $`git show-ref --verify --quiet refs/remotes/origin/${branch}`.cwd(dir).nothrow().quiet(); const remote = await $`git show-ref --verify --quiet refs/remotes/origin/${branch}`.cwd(dir).nothrow().quiet()
if (remote.exitCode === 0) return "remote"; if (remote.exitCode === 0) return "remote"
return null; return null
} }
/** Create a worktree for the given branch. */ /** Create a worktree for the given branch. */
export async function createWorktree(branch: string, worktreePath: string, cwd: string): Promise<void> { export async function createWorktree(branch: string, worktreePath: string, cwd: string): Promise<void> {
const exists = await branchExists(branch, cwd); const exists = await branchExists(branch, cwd)
if (exists === "local") { if (exists === "local") {
await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd); await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd)
} else if (exists === "remote") { } else if (exists === "remote") {
await $`git worktree add ${worktreePath} -b ${branch} origin/${branch}`.cwd(cwd); await $`git worktree add ${worktreePath} -b ${branch} origin/${branch}`.cwd(cwd)
} else { } else {
// New branch from current HEAD // New branch from current HEAD
await $`git worktree add -b ${branch} ${worktreePath}`.cwd(cwd); await $`git worktree add -b ${branch} ${worktreePath}`.cwd(cwd)
} }
} }
/** Remove a worktree. */ /** Remove a worktree. */
export async function removeWorktree(worktreePath: string, cwd: string): Promise<void> { export async function removeWorktree(worktreePath: string, cwd: string): Promise<void> {
await $`git worktree remove ${worktreePath} --force`.cwd(cwd); await $`git worktree remove ${worktreePath} --force`.cwd(cwd)
} }
/** Delete a local branch. */ /** Delete a local branch. */
export async function deleteLocalBranch(branch: string, cwd: string): Promise<void> { export async function deleteLocalBranch(branch: string, cwd: string): Promise<void> {
await $`git branch -D ${branch}`.cwd(cwd).nothrow(); await $`git branch -D ${branch}`.cwd(cwd).nothrow()
} }
/** Delete a remote branch. */ /** Delete a remote branch. */
export async function deleteRemoteBranch(branch: string, cwd: string): Promise<void> { export async function deleteRemoteBranch(branch: string, cwd: string): Promise<void> {
await $`git push origin --delete ${branch}`.cwd(cwd).nothrow().quiet(); await $`git push origin --delete ${branch}`.cwd(cwd).nothrow().quiet()
} }
/** Stage all changes and return the number of staged files. */ /** Stage all changes and return the number of staged files. */
export async function stageAll(cwd: string): Promise<number> { export async function stageAll(cwd: string): Promise<number> {
await $`git add .`.cwd(cwd); await $`git add .`.cwd(cwd)
const status = await $`git diff --cached --name-only`.cwd(cwd).text(); const status = await $`git diff --cached --name-only`.cwd(cwd).text()
const files = status.trim().split("\n").filter(Boolean); const files = status.trim().split("\n").filter(Boolean)
return files.length; return files.length
} }
/** Check if there are any uncommitted changes (staged or unstaged). */ /** Check if there are any uncommitted changes (staged or unstaged). */
export async function hasChanges(cwd: string): Promise<boolean> { export async function hasChanges(cwd: string): Promise<boolean> {
const result = await $`git status --porcelain`.cwd(cwd).text(); const result = await $`git status --porcelain`.cwd(cwd).text()
return result.trim().length > 0; return result.trim().length > 0
} }
/** Get the diff of staged changes. */ /** Get the diff of staged changes. */
export async function stagedDiff(cwd: string): Promise<string> { export async function stagedDiff(cwd: string): Promise<string> {
return await $`git diff --cached`.cwd(cwd).text(); return await $`git diff --cached`.cwd(cwd).text()
} }
/** Commit with a message. */ /** Commit with a message. */
export async function commit(message: string, cwd: string): Promise<void> { export async function commit(message: string, cwd: string): Promise<void> {
await $`git commit -m ${message}`.cwd(cwd); await $`git commit -m ${message}`.cwd(cwd)
} }
/** Push a branch to origin. */ /** Push a branch to origin. */
export async function push(branch: string, cwd: string): Promise<void> { export async function push(branch: string, cwd: string): Promise<void> {
await $`git push -u origin ${branch}`.cwd(cwd); await $`git push -u origin ${branch}`.cwd(cwd)
} }
/** Checkout a branch in a working tree. */ /** Checkout a branch in a working tree. */
export async function checkout(branch: string, cwd: string): Promise<void> { export async function checkout(branch: string, cwd: string): Promise<void> {
await $`git checkout ${branch}`.cwd(cwd); await $`git checkout ${branch}`.cwd(cwd)
} }
/** Merge a branch into the current branch. Returns true if successful, false if conflicts. */ /** Merge a branch into the current branch. Returns true if successful, false if conflicts. */
export async function merge(branch: string, cwd: string): Promise<boolean> { export async function merge(branch: string, cwd: string): Promise<boolean> {
const result = await $`git merge ${branch}`.cwd(cwd).nothrow(); const result = await $`git merge ${branch}`.cwd(cwd).nothrow()
return result.exitCode === 0; return result.exitCode === 0
} }
/** Get list of conflicted files. */ /** Get list of conflicted files. */
export async function conflictedFiles(cwd: string): Promise<string[]> { export async function conflictedFiles(cwd: string): Promise<string[]> {
const result = await $`git diff --name-only --diff-filter=U`.cwd(cwd).text(); const result = await $`git diff --name-only --diff-filter=U`.cwd(cwd).text()
return result.trim().split("\n").filter(Boolean); return result.trim().split("\n").filter(Boolean)
} }
/** Get the conflict content of a file (with markers). */ /** Get the conflict content of a file (with markers). */
export async function conflictContent(filePath: string, cwd: string): Promise<string> { export async function conflictContent(filePath: string, cwd: string): Promise<string> {
return await Bun.file(`${cwd}/${filePath}`).text(); return await Bun.file(`${cwd}/${filePath}`).text()
} }
/** Stage a resolved file. */ /** Stage a resolved file. */
export async function stageFile(filePath: string, cwd: string): Promise<void> { export async function stageFile(filePath: string, cwd: string): Promise<void> {
await $`git add ${filePath}`.cwd(cwd); await $`git add ${filePath}`.cwd(cwd)
} }
/** Commit a merge (no message needed, uses default merge message). */ /** Commit a merge (no message needed, uses default merge message). */
export async function commitMerge(cwd: string): Promise<void> { export async function commitMerge(cwd: string): Promise<void> {
await $`git commit --no-edit`.cwd(cwd); await $`git commit --no-edit`.cwd(cwd)
} }
/** Abort a merge. */ /** Abort a merge. */
export async function abortMerge(cwd: string): Promise<void> { export async function abortMerge(cwd: string): Promise<void> {
await $`git merge --abort`.cwd(cwd); await $`git merge --abort`.cwd(cwd)
} }

View File

@ -1,50 +1,50 @@
import { join } from "path"; import { join } from "path"
export interface Session { export interface Session {
branch: string; branch: string
worktree: string; worktree: string
vm_id: string; vm_id: string
created_at: string; created_at: string
status: "running" | "stopped"; status: "running" | "stopped"
} }
export interface State { export interface State {
sessions: Record<string, Session>; sessions: Record<string, Session>
} }
function statePath(repoRoot: string): string { function statePath(repoRoot: string): string {
return join(repoRoot, ".sandlot", "state.json"); return join(repoRoot, ".sandlot", "state.json")
} }
export async function load(repoRoot: string): Promise<State> { export async function load(repoRoot: string): Promise<State> {
const path = statePath(repoRoot); const path = statePath(repoRoot)
const file = Bun.file(path); const file = Bun.file(path)
if (await file.exists()) { if (await file.exists()) {
return await file.json(); return await file.json()
} }
return { sessions: {} }; return { sessions: {} }
} }
export async function save(repoRoot: string, state: State): Promise<void> { export async function save(repoRoot: string, state: State): Promise<void> {
const path = statePath(repoRoot); const path = statePath(repoRoot)
const dir = join(repoRoot, ".sandlot"); const dir = join(repoRoot, ".sandlot")
await Bun.write(join(dir, ".gitkeep"), ""); // ensure dir exists await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists
await Bun.write(path, JSON.stringify(state, null, 2) + "\n"); await Bun.write(path, JSON.stringify(state, null, 2) + "\n")
} }
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> { export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {
const state = await load(repoRoot); const state = await load(repoRoot)
return state.sessions[branch]; return state.sessions[branch]
} }
export async function setSession(repoRoot: string, session: Session): Promise<void> { export async function setSession(repoRoot: string, session: Session): Promise<void> {
const state = await load(repoRoot); const state = await load(repoRoot)
state.sessions[session.branch] = session; state.sessions[session.branch] = session
await save(repoRoot, state); await save(repoRoot, state)
} }
export async function removeSession(repoRoot: string, branch: string): Promise<void> { export async function removeSession(repoRoot: string, branch: string): Promise<void> {
const state = await load(repoRoot); const state = await load(repoRoot)
delete state.sessions[branch]; delete state.sessions[branch]
await save(repoRoot, state); await save(repoRoot, state)
} }

View File

@ -1,5 +1,5 @@
import { $ } from "bun"; import { $ } from "bun"
import type { VmConfig } from "./config.ts"; import type { VmConfig } from "./config.ts"
/** Boot a container VM mapped to a worktree directory. Returns the container ID. */ /** Boot a container VM mapped to a worktree directory. Returns the container ID. */
export async function boot( export async function boot(
@ -7,42 +7,42 @@ export async function boot(
worktreePath: string, worktreePath: string,
config?: VmConfig config?: VmConfig
): Promise<string> { ): Promise<string> {
const args: string[] = ["container", "run", "--name", name]; const args: string[] = ["container", "run", "--name", name]
if (config?.cpus) args.push("--cpus", String(config.cpus)); if (config?.cpus) args.push("--cpus", String(config.cpus))
if (config?.memory) args.push("--memory", config.memory); if (config?.memory) args.push("--memory", config.memory)
// Mount worktree as /root/work // Mount worktree as /root/work
args.push("--mount", `type=virtiofs,source=${worktreePath},target=/root/work`); args.push("--mount", `type=virtiofs,source=${worktreePath},target=/root/work`)
// Additional mounts from config // Additional mounts from config
if (config?.mounts) { if (config?.mounts) {
for (const [source, target] of Object.entries(config.mounts)) { for (const [source, target] of Object.entries(config.mounts)) {
args.push("--mount", `type=virtiofs,source=${source},target=${target}`); args.push("--mount", `type=virtiofs,source=${source},target=${target}`)
} }
} }
const image = config?.image ?? "ubuntu:24.04"; const image = config?.image ?? "ubuntu:24.04"
args.push("-d", image); args.push("-d", image)
const result = await $`${args}`.text(); const result = await $`${args}`.text()
return result.trim(); return result.trim()
} }
/** Stop a running container. */ /** Stop a running container. */
export async function stop(vmId: string): Promise<void> { export async function stop(vmId: string): Promise<void> {
await $`container stop ${vmId}`.nothrow().quiet(); await $`container stop ${vmId}`.nothrow().quiet()
} }
/** Remove a container. */ /** Remove a container. */
export async function rm(vmId: string): Promise<void> { export async function rm(vmId: string): Promise<void> {
await $`container rm ${vmId}`.nothrow().quiet(); await $`container rm ${vmId}`.nothrow().quiet()
} }
/** Stop and remove a container. */ /** Stop and remove a container. */
export async function destroy(vmId: string): Promise<void> { export async function destroy(vmId: string): Promise<void> {
await stop(vmId); await stop(vmId)
await rm(vmId); await rm(vmId)
} }
/** Check if a container is running. Returns "running", "stopped", or "missing". */ /** Check if a container is running. Returns "running", "stopped", or "missing". */
@ -50,12 +50,12 @@ export async function status(vmId: string): Promise<"running" | "stopped" | "mis
const result = await $`container inspect ${vmId} --format '{{.State.Status}}'` const result = await $`container inspect ${vmId} --format '{{.State.Status}}'`
.nothrow() .nothrow()
.quiet() .quiet()
.text(); .text()
const state = result.trim().replace(/'/g, ""); const state = result.trim().replace(/'/g, "")
if (state.includes("running")) return "running"; if (state.includes("running")) return "running"
if (state.includes("exited") || state.includes("stopped") || state.includes("created")) return "stopped"; if (state.includes("exited") || state.includes("stopped") || state.includes("created")) return "stopped"
return "missing"; return "missing"
} }
/** Exec into a container shell interactively. */ /** Exec into a container shell interactively. */
@ -64,8 +64,8 @@ export async function shell(vmId: string): Promise<void> {
stdin: "inherit", stdin: "inherit",
stdout: "inherit", stdout: "inherit",
stderr: "inherit", stderr: "inherit",
}); })
await proc.exited; await proc.exited
} }
/** List all containers with their names and statuses. */ /** List all containers with their names and statuses. */
@ -73,14 +73,14 @@ export async function list(): Promise<Array<{ id: string; name: string; status:
const result = await $`container ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}'` const result = await $`container ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}'`
.nothrow() .nothrow()
.quiet() .quiet()
.text(); .text()
return result return result
.trim() .trim()
.split("\n") .split("\n")
.filter(Boolean) .filter(Boolean)
.map((line) => { .map((line) => {
const [id, name, status] = line.replace(/'/g, "").split("\t"); const [id, name, status] = line.replace(/'/g, "").split("\t")
return { id, name, status }; return { id, name, status }
}); })
} }