diff --git a/package.json b/package.json index 8f1a125..9b1272e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sandlot", - "version": "0.1.0", + "version": "0.0.1", "type": "module", "bin": { "sandlot": "./src/cli.ts" diff --git a/src/ai.ts b/src/ai.ts index 0b1494d..7c172d1 100644 --- a/src/ai.ts +++ b/src/ai.ts @@ -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 { - 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", - }; + } } diff --git a/src/cli.ts b/src/cli.ts index 9c82f89..d8f3316 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ────────────────────────────────────────────── @@ -19,23 +37,23 @@ program .argument("", "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 ───────────────────────────────────────────── @@ -92,84 +110,84 @@ program .argument("", "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 ───────────────────────────────────────────── @@ -214,38 +232,38 @@ program .argument("", "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 ───────────────────────────────────────────── @@ -254,18 +272,18 @@ program .argument("", "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 ─────────────────────────────────────────────── @@ -274,25 +292,25 @@ program .argument("", "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 { // 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 { 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() diff --git a/src/config.ts b/src/config.ts index fd243da..f7bc6e2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,35 +1,35 @@ -import { join } from "path"; +import { join } from "path" export interface VmConfig { - cpus?: number; - memory?: string; - image?: string; - mounts?: Record; + cpus?: number + memory?: string + image?: string + mounts?: Record } 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 { - 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 } diff --git a/src/git.ts b/src/git.ts index b27694f..408b85b 100644 --- a/src/git.ts +++ b/src/git.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - await $`git commit --no-edit`.cwd(cwd); + await $`git commit --no-edit`.cwd(cwd) } /** Abort a merge. */ export async function abortMerge(cwd: string): Promise { - await $`git merge --abort`.cwd(cwd); + await $`git merge --abort`.cwd(cwd) } diff --git a/src/state.ts b/src/state.ts index 2fe919d..2115aba 100644 --- a/src/state.ts +++ b/src/state.ts @@ -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; + sessions: Record } function statePath(repoRoot: string): string { - return join(repoRoot, ".sandlot", "state.json"); + return join(repoRoot, ".sandlot", "state.json") } export async function load(repoRoot: string): Promise { - 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 { - 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 { - 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 { - 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 { - 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) } diff --git a/src/vm.ts b/src/vm.ts index ff7c3da..5357450 100644 --- a/src/vm.ts +++ b/src/vm.ts @@ -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 { - 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 { - await $`container stop ${vmId}`.nothrow().quiet(); + await $`container stop ${vmId}`.nothrow().quiet() } /** Remove a container. */ export async function rm(vmId: string): Promise { - await $`container rm ${vmId}`.nothrow().quiet(); + await $`container rm ${vmId}`.nothrow().quiet() } /** Stop and remove a container. */ export async function destroy(vmId: string): Promise { - 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 { 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 { - 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 } + }) }