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",
"version": "0.1.0",
"version": "0.0.1",
"type": "module",
"bin": {
"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 {
if (!client) {
client = new Anthropic();
client = new Anthropic()
}
return client;
return client
}
/** Generate a commit message from a diff. */
export async function generateCommitMessage(diff: string, model: string): Promise<string> {
const anthropic = getClient();
const anthropic = getClient()
const response = await anthropic.messages.create({
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}`,
},
],
});
})
const block = response.content[0];
if (block.type === "text") return block.text.trim();
throw new Error("Unexpected response from Claude");
const block = response.content[0]
if (block.type === "text") return block.text.trim()
throw new Error("Unexpected response from Claude")
}
/** Resolve a merge conflict in a file. Returns the resolved content and an explanation. */
@ -35,7 +35,7 @@ export async function resolveConflict(
conflictContent: string,
model: string
): Promise<{ resolved: string; explanation: string }> {
const anthropic = getClient();
const anthropic = getClient()
const response = await anthropic.messages.create({
model,
@ -54,21 +54,21 @@ File with conflicts:
${conflictContent}`,
},
],
});
})
const block = response.content[0];
if (block.type !== "text") throw new Error("Unexpected response from Claude");
const block = response.content[0]
if (block.type !== "text") throw new Error("Unexpected response from Claude")
const text = block.text;
const explMatch = text.match(/EXPLANATION:\s*(.+)/);
const resolvedMatch = text.match(/RESOLVED:\n([\s\S]+)/);
const text = block.text
const explMatch = text.match(/EXPLANATION:\s*(.+)/)
const resolvedMatch = text.match(/RESOLVED:\n([\s\S]+)/)
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 {
explanation: explMatch[1].trim(),
resolved: resolvedMatch[1].trimEnd() + "\n",
};
}
}

View File

@ -1,16 +1,34 @@
#!/usr/bin/env bun
import { Command } from "commander";
import { join } from "path";
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";
import { homedir } from "os"
import { Command } from "commander"
import { join } from "path"
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> ──────────────────────────────────────────────
@ -19,23 +37,23 @@ program
.argument("<branch>", "branch name")
.description("Create a new session with a worktree and VM")
.action(async (branch: string) => {
const root = await git.repoRoot();
const config = await loadConfig(root);
const worktreeRel = `.sandlot/${branch}`;
const worktreeAbs = join(root, worktreeRel);
const root = await git.repoRoot()
const config = await loadConfig(root)
const worktreeRel = `.sandlot/${branch}`
const worktreeAbs = join(root, worktreeRel)
// Check for stale directory
const existing = await state.getSession(root, branch);
const existing = await state.getSession(root, branch)
if (existing) {
console.error(`Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`);
process.exit(1);
console.error(`Session "${branch}" already exists. Use "sandlot open ${branch}" to re-enter it.`)
process.exit(1)
}
console.log(`Creating worktree at ${worktreeRel}/`);
await git.createWorktree(branch, worktreeAbs, root);
console.log(`Creating worktree at ${worktreeRel}/`)
await git.createWorktree(branch, worktreeAbs, root)
console.log("Booting VM...");
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm);
console.log("Booting VM...")
const vmId = await vm.boot(`sandlot-${branch}`, worktreeAbs, config.vm)
await state.setSession(root, {
branch,
@ -43,10 +61,10 @@ program
vm_id: vmId,
created_at: new Date().toISOString(),
status: "running",
});
})
await vm.shell(vmId);
});
await vm.shell(vmId)
})
// ── sandlot save [message] ────────────────────────────────────────────
@ -55,35 +73,35 @@ program
.argument("[message]", "commit message (auto-generated if omitted)")
.description("Stage and commit all changes, push to origin")
.action(async (message?: string) => {
const root = await git.repoRoot();
const config = await loadConfig(root);
const branch = await detectBranch(root);
const session = await state.getSession(root, branch);
const cwd = session ? join(root, session.worktree) : root;
const root = await git.repoRoot()
const config = await loadConfig(root)
const branch = await detectBranch(root)
const session = await state.getSession(root, branch)
const cwd = session ? join(root, session.worktree) : root
if (!(await git.hasChanges(cwd))) {
console.log("No changes to commit.");
return;
console.log("No changes to commit.")
return
}
const staged = await git.stageAll(cwd);
console.log(`Staged ${staged} files`);
const staged = await git.stageAll(cwd)
console.log(`Staged ${staged} files`)
let commitMsg: string;
let commitMsg: string
if (message) {
commitMsg = message;
commitMsg = message
} else {
const diff = await git.stagedDiff(cwd);
commitMsg = await generateCommitMessage(diff, config.ai?.model ?? "claude-sonnet-4-20250514");
const diff = await git.stagedDiff(cwd)
commitMsg = await generateCommitMessage(diff, config.ai?.model ?? "claude-sonnet-4-20250514")
}
await git.commit(commitMsg, cwd);
const firstLine = commitMsg.split("\n")[0];
console.log(`Commit: ${firstLine}`);
await git.commit(commitMsg, cwd)
const firstLine = commitMsg.split("\n")[0]
console.log(`Commit: ${firstLine}`)
await git.push(branch, cwd);
console.log(`Pushed ${branch} → origin/${branch}`);
});
await git.push(branch, cwd)
console.log(`Pushed ${branch} → origin/${branch}`)
})
// ── sandlot push <target> ─────────────────────────────────────────────
@ -92,84 +110,84 @@ program
.argument("<target>", "target branch to merge into")
.description("Merge session branch into target, then tear down")
.action(async (target: string) => {
const root = await git.repoRoot();
const config = await loadConfig(root);
const branch = await detectBranch(root);
const session = await state.getSession(root, branch);
const root = await git.repoRoot()
const config = await loadConfig(root)
const branch = await detectBranch(root)
const session = await state.getSession(root, branch)
if (!session) {
console.error(`No session found for branch "${branch}".`);
process.exit(1);
console.error(`No session found for branch "${branch}".`)
process.exit(1)
}
const worktreeCwd = join(root, session.worktree);
const worktreeCwd = join(root, session.worktree)
// Check for uncommitted changes
if (await git.hasChanges(worktreeCwd)) {
console.error(`Uncommitted changes in worktree. Run "sandlot save" first.`);
process.exit(1);
console.error(`Uncommitted changes in worktree. Run "sandlot save" first.`)
process.exit(1)
}
console.log(`Pushing ${branch}${target}...`);
console.log(`Pushing ${branch}${target}...`)
// Checkout target in main working tree
await git.checkout(target, root);
await git.checkout(target, root)
// Merge
const merged = await git.merge(branch, root);
const merged = await git.merge(branch, root)
if (!merged) {
// Handle conflicts
const conflicts = await git.conflictedFiles(root);
console.log(`${conflicts.length} conflicts found. Resolving with Claude...\n`);
const conflicts = await git.conflictedFiles(root)
console.log(`${conflicts.length} conflicts found. Resolving with Claude...\n`)
const model = config.ai?.model ?? "claude-sonnet-4-20250514";
const resolutions: Array<{ file: string; explanation: string }> = [];
const model = config.ai?.model ?? "claude-sonnet-4-20250514"
const resolutions: Array<{ file: string; explanation: string }> = []
for (const file of conflicts) {
const content = await git.conflictContent(file, root);
const { resolved, explanation } = await resolveConflict(file, content, model);
await Bun.write(join(root, file), resolved);
await git.stageFile(file, root);
resolutions.push({ file, explanation });
const content = await git.conflictContent(file, root)
const { resolved, explanation } = await resolveConflict(file, content, model)
await Bun.write(join(root, file), resolved)
await git.stageFile(file, root)
resolutions.push({ file, explanation })
}
for (const { file, explanation } of resolutions) {
console.log(` ${file}`);
console.log(`${explanation}\n`);
console.log(` ${file}`)
console.log(`${explanation}\n`)
}
// Prompt for confirmation
process.stdout.write("Accept Claude's resolutions? [Y/n] ");
const answer = await readLine();
process.stdout.write("Accept Claude's resolutions? [Y/n] ")
const answer = await readLine()
if (answer.toLowerCase() === "n") {
await git.abortMerge(root);
console.log("Merge aborted. Resolve conflicts manually, then run sandlot push again.");
return;
await git.abortMerge(root)
console.log("Merge aborted. Resolve conflicts manually, then run sandlot push again.")
return
}
await git.commitMerge(root);
console.log("Committed merge");
await git.commitMerge(root)
console.log("Committed merge")
}
// Push target
await git.push(target, root);
console.log(`Pushed ${target} → origin/${target}`);
await git.push(target, root)
console.log(`Pushed ${target} → origin/${target}`)
// Tear down
await vm.destroy(session.vm_id);
console.log(`Stopped VM ${branch}`);
await vm.destroy(session.vm_id)
console.log(`Stopped VM ${branch}`)
await git.removeWorktree(join(root, session.worktree), root);
console.log(`Removed worktree ${session.worktree}/`);
await git.removeWorktree(join(root, session.worktree), root)
console.log(`Removed worktree ${session.worktree}/`)
await git.deleteLocalBranch(branch, root);
await git.deleteRemoteBranch(branch, root);
console.log(`Deleted branch ${branch} (local + remote)`);
await git.deleteLocalBranch(branch, root)
await git.deleteRemoteBranch(branch, root)
console.log(`Deleted branch ${branch} (local + remote)`)
await state.removeSession(root, branch);
});
await state.removeSession(root, branch)
})
// ── sandlot list ──────────────────────────────────────────────────────
@ -177,35 +195,35 @@ program
.command("list")
.description("Show all active sessions")
.action(async () => {
const root = await git.repoRoot();
const st = await state.load(root);
const sessions = Object.values(st.sessions);
const root = await git.repoRoot()
const st = await state.load(root)
const sessions = Object.values(st.sessions)
if (sessions.length === 0) {
console.log("No active sessions.");
return;
console.log("No active sessions.")
return
}
// 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) {
const vmStatus = await vm.status(s.vm_id);
rows.push({ branch: s.branch, vmStatus, worktree: s.worktree });
const vmStatus = await vm.status(s.vm_id)
rows.push({ branch: s.branch, vmStatus, worktree: s.worktree })
}
// Print table
const branchWidth = Math.max(6, ...rows.map((r) => r.branch.length));
const statusWidth = Math.max(9, ...rows.map((r) => r.vmStatus.length));
const branchWidth = Math.max(6, ...rows.map((r) => r.branch.length))
const statusWidth = Math.max(9, ...rows.map((r) => r.vmStatus.length))
console.log(
`${"BRANCH".padEnd(branchWidth)} ${"VM STATUS".padEnd(statusWidth)} WORKTREE`
);
)
for (const row of rows) {
console.log(
`${row.branch.padEnd(branchWidth)} ${row.vmStatus.padEnd(statusWidth)} ${row.worktree}/`
);
)
}
});
})
// ── sandlot open <branch> ─────────────────────────────────────────────
@ -214,38 +232,38 @@ program
.argument("<branch>", "branch name")
.description("Re-enter an existing session's VM")
.action(async (branch: string) => {
const root = await git.repoRoot();
const config = await loadConfig(root);
const session = await state.getSession(root, branch);
const root = await git.repoRoot()
const config = await loadConfig(root)
const session = await state.getSession(root, branch)
if (!session) {
console.error(`No session found for branch "${branch}".`);
process.exit(1);
console.error(`No session found for branch "${branch}".`)
process.exit(1)
}
const vmStatus = await vm.status(session.vm_id);
const vmStatus = await vm.status(session.vm_id)
if (vmStatus === "missing") {
// 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);
await state.setSession(root, { ...session, vm_id: vmId, status: "running" });
await vm.shell(vmId);
console.log("VM is gone. Rebooting...")
const worktreeAbs = join(root, session.worktree)
const vmId = await vm.boot(`sandlot-${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...");
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 state.setSession(root, { ...session, status: "running" });
await vm.shell(session.vm_id);
})
await proc.exited
await state.setSession(root, { ...session, status: "running" })
await vm.shell(session.vm_id)
} else {
await vm.shell(session.vm_id);
await vm.shell(session.vm_id)
}
});
})
// ── sandlot stop <branch> ─────────────────────────────────────────────
@ -254,18 +272,18 @@ program
.argument("<branch>", "branch name")
.description("Stop a session's VM without destroying it")
.action(async (branch: string) => {
const root = await git.repoRoot();
const session = await state.getSession(root, branch);
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
console.error(`No session found for branch "${branch}".`);
process.exit(1);
console.error(`No session found for branch "${branch}".`)
process.exit(1)
}
await vm.stop(session.vm_id);
await state.setSession(root, { ...session, status: "stopped" });
console.log(`Stopped VM for ${branch}`);
});
await vm.stop(session.vm_id)
await state.setSession(root, { ...session, status: "stopped" })
console.log(`Stopped VM for ${branch}`)
})
// ── sandlot rm <branch> ───────────────────────────────────────────────
@ -274,25 +292,25 @@ program
.argument("<branch>", "branch name")
.description("Tear down a session without merging")
.action(async (branch: string) => {
const root = await git.repoRoot();
const session = await state.getSession(root, branch);
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (!session) {
console.error(`No session found for branch "${branch}".`);
process.exit(1);
console.error(`No session found for branch "${branch}".`)
process.exit(1)
}
await vm.destroy(session.vm_id);
console.log(`Stopped VM ${branch}`);
await vm.destroy(session.vm_id)
console.log(`Stopped VM ${branch}`)
await git.removeWorktree(join(root, session.worktree), root);
console.log(`Removed worktree ${session.worktree}/`);
await git.removeWorktree(join(root, session.worktree), root)
console.log(`Removed worktree ${session.worktree}/`)
await git.deleteLocalBranch(branch, root);
console.log(`Deleted local branch ${branch}`);
await git.deleteLocalBranch(branch, root)
console.log(`Deleted local branch ${branch}`)
await state.removeSession(root, branch);
});
await state.removeSession(root, branch)
})
// ── Helpers ───────────────────────────────────────────────────────────
@ -300,32 +318,32 @@ program
async function detectBranch(root: string): Promise<string> {
// Check env var first (set when inside a VM)
if (process.env.SANDLOT_BRANCH) {
return process.env.SANDLOT_BRANCH;
return process.env.SANDLOT_BRANCH
}
// Check if cwd is inside a worktree
const cwd = process.cwd();
const sandlotDir = join(root, ".sandlot");
const cwd = process.cwd()
const sandlotDir = join(root, ".sandlot")
if (cwd.startsWith(sandlotDir)) {
const rel = cwd.slice(sandlotDir.length + 1);
const branch = rel.split("/")[0];
if (branch) return branch;
const rel = cwd.slice(sandlotDir.length + 1)
const branch = rel.split("/")[0]
if (branch) return branch
}
// Fall back to current git branch
return await git.currentBranch();
return await git.currentBranch()
}
/** Read a line from stdin. */
function readLine(): Promise<string> {
return new Promise((resolve) => {
process.stdin.setRawMode?.(false);
process.stdin.resume();
process.stdin.setRawMode?.(false)
process.stdin.resume()
process.stdin.once("data", (chunk) => {
process.stdin.pause();
resolve(chunk.toString().trim());
});
});
process.stdin.pause()
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 {
cpus?: number;
memory?: string;
image?: string;
mounts?: Record<string, string>;
cpus?: number
memory?: string
image?: string
mounts?: Record<string, string>
}
export interface AiConfig {
model?: string;
model?: string
}
export interface SandlotConfig {
vm?: VmConfig;
ai?: AiConfig;
vm?: VmConfig
ai?: AiConfig
}
const DEFAULT_CONFIG: SandlotConfig = {
vm: { image: "ubuntu:24.04" },
ai: { model: "claude-sonnet-4-20250514" },
};
}
export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
const path = join(repoRoot, "sandlot.json");
const file = Bun.file(path);
const path = join(repoRoot, "sandlot.json")
const file = Bun.file(path)
if (await file.exists()) {
const userConfig = await file.json();
const userConfig = await file.json()
return {
vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm },
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. */
export async function repoRoot(cwd?: string): Promise<string> {
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").text();
return result.trim();
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").text()
return result.trim()
}
/** Get the current branch name. */
export async function currentBranch(cwd?: string): Promise<string> {
const result = await $`git rev-parse --abbrev-ref HEAD`.cwd(cwd ?? ".").text();
return result.trim();
const result = await $`git rev-parse --abbrev-ref HEAD`.cwd(cwd ?? ".").text()
return result.trim()
}
/** 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> {
const dir = cwd ?? ".";
const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet();
if (local.exitCode === 0) return "local";
const dir = cwd ?? "."
const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet()
if (local.exitCode === 0) return "local"
await $`git fetch origin`.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";
await $`git fetch origin`.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"
return null;
return null
}
/** Create a worktree for the given branch. */
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") {
await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd);
await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd)
} 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 {
// 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. */
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. */
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. */
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. */
export async function stageAll(cwd: string): Promise<number> {
await $`git add .`.cwd(cwd);
const status = await $`git diff --cached --name-only`.cwd(cwd).text();
const files = status.trim().split("\n").filter(Boolean);
return files.length;
await $`git add .`.cwd(cwd)
const status = await $`git diff --cached --name-only`.cwd(cwd).text()
const files = status.trim().split("\n").filter(Boolean)
return files.length
}
/** Check if there are any uncommitted changes (staged or unstaged). */
export async function hasChanges(cwd: string): Promise<boolean> {
const result = await $`git status --porcelain`.cwd(cwd).text();
return result.trim().length > 0;
const result = await $`git status --porcelain`.cwd(cwd).text()
return result.trim().length > 0
}
/** Get the diff of staged changes. */
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. */
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. */
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. */
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. */
export async function merge(branch: string, cwd: string): Promise<boolean> {
const result = await $`git merge ${branch}`.cwd(cwd).nothrow();
return result.exitCode === 0;
const result = await $`git merge ${branch}`.cwd(cwd).nothrow()
return result.exitCode === 0
}
/** Get list of conflicted files. */
export async function conflictedFiles(cwd: string): Promise<string[]> {
const result = await $`git diff --name-only --diff-filter=U`.cwd(cwd).text();
return result.trim().split("\n").filter(Boolean);
const result = await $`git diff --name-only --diff-filter=U`.cwd(cwd).text()
return result.trim().split("\n").filter(Boolean)
}
/** Get the conflict content of a file (with markers). */
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. */
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). */
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. */
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 {
branch: string;
worktree: string;
vm_id: string;
created_at: string;
status: "running" | "stopped";
branch: string
worktree: string
vm_id: string
created_at: string
status: "running" | "stopped"
}
export interface State {
sessions: Record<string, Session>;
sessions: Record<string, Session>
}
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> {
const path = statePath(repoRoot);
const file = Bun.file(path);
const path = statePath(repoRoot)
const file = Bun.file(path)
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> {
const path = statePath(repoRoot);
const dir = join(repoRoot, ".sandlot");
await Bun.write(join(dir, ".gitkeep"), ""); // ensure dir exists
await Bun.write(path, JSON.stringify(state, null, 2) + "\n");
const path = statePath(repoRoot)
const dir = join(repoRoot, ".sandlot")
await Bun.write(join(dir, ".gitkeep"), "") // ensure dir exists
await Bun.write(path, JSON.stringify(state, null, 2) + "\n")
}
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {
const state = await load(repoRoot);
return state.sessions[branch];
const state = await load(repoRoot)
return state.sessions[branch]
}
export async function setSession(repoRoot: string, session: Session): Promise<void> {
const state = await load(repoRoot);
state.sessions[session.branch] = session;
await save(repoRoot, state);
const state = await load(repoRoot)
state.sessions[session.branch] = session
await save(repoRoot, state)
}
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
const state = await load(repoRoot);
delete state.sessions[branch];
await save(repoRoot, state);
const state = await load(repoRoot)
delete state.sessions[branch]
await save(repoRoot, state)
}

View File

@ -1,5 +1,5 @@
import { $ } from "bun";
import type { VmConfig } from "./config.ts";
import { $ } from "bun"
import type { VmConfig } from "./config.ts"
/** Boot a container VM mapped to a worktree directory. Returns the container ID. */
export async function boot(
@ -7,42 +7,42 @@ export async function boot(
worktreePath: string,
config?: VmConfig
): 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?.memory) args.push("--memory", config.memory);
if (config?.cpus) args.push("--cpus", String(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", `type=virtiofs,source=${worktreePath},target=/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", `type=virtiofs,source=${source},target=${target}`)
}
}
const image = config?.image ?? "ubuntu:24.04";
args.push("-d", image);
const image = config?.image ?? "ubuntu:24.04"
args.push("-d", image)
const result = await $`${args}`.text();
return result.trim();
const result = await $`${args}`.text()
return result.trim()
}
/** Stop a running container. */
export async function stop(vmId: string): Promise<void> {
await $`container stop ${vmId}`.nothrow().quiet();
await $`container stop ${vmId}`.nothrow().quiet()
}
/** Remove a container. */
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. */
export async function destroy(vmId: string): Promise<void> {
await stop(vmId);
await rm(vmId);
await stop(vmId)
await rm(vmId)
}
/** 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}}'`
.nothrow()
.quiet()
.text();
.text()
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";
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. */
@ -64,8 +64,8 @@ export async function shell(vmId: string): Promise<void> {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
await proc.exited;
})
await proc.exited
}
/** 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}}'`
.nothrow()
.quiet()
.text();
.text()
return result
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [id, name, status] = line.replace(/'/g, "").split("\t");
return { id, name, status };
});
const [id, name, status] = line.replace(/'/g, "").split("\t")
return { id, name, status }
})
}