updates
This commit is contained in:
parent
08f223add7
commit
033331db98
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "sandlot",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"sandlot": "./src/cli.ts"
|
||||
|
|
|
|||
36
src/ai.ts
36
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<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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
314
src/cli.ts
314
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 <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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
74
src/git.ts
74
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<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)
|
||||
}
|
||||
|
|
|
|||
48
src/state.ts
48
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<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)
|
||||
}
|
||||
|
|
|
|||
52
src/vm.ts
52
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<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 }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user