364 lines
12 KiB
TypeScript
Executable File
364 lines
12 KiB
TypeScript
Executable File
#!/usr/bin/env bun
|
|
|
|
import { Command } from "commander"
|
|
import { $} from "bun"
|
|
import { basename, join } from "path"
|
|
import { homedir } from "os"
|
|
import { mkdir, symlink, unlink } from "fs/promises"
|
|
import * as git from "./git.ts"
|
|
import * as vm from "./vm.ts"
|
|
import * as state from "./state.ts"
|
|
import { spinner } from "./spinner.ts"
|
|
|
|
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 Container").version(pkg.version)
|
|
|
|
// ── sandlot new <branch> ──────────────────────────────────────────────
|
|
|
|
function branchFromPrompt(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, "")
|
|
.trim()
|
|
.replace(/\s+/g, "-")
|
|
.slice(0, 20)
|
|
.replace(/-$/, "")
|
|
}
|
|
|
|
program
|
|
.command("new")
|
|
.argument("[branch]", "branch name or prompt (if it contains spaces)")
|
|
.argument("[prompt]", "initial prompt for Claude")
|
|
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
|
|
.description("Create a new session and launch Claude")
|
|
.action(async (branch: string | undefined, prompt: string | undefined, opts: { print?: string }) => {
|
|
// No branch given — derive from -p prompt
|
|
if (!branch && opts.print) {
|
|
branch = branchFromPrompt(opts.print)
|
|
} else if (!branch) {
|
|
console.error("Branch name or prompt is required.")
|
|
process.exit(1)
|
|
} else if (branch.includes(" ")) {
|
|
// If the "branch" contains spaces, it's actually a prompt — derive the branch name
|
|
prompt = branch
|
|
branch = branchFromPrompt(branch)
|
|
}
|
|
const root = await git.repoRoot()
|
|
const worktreeAbs = join(homedir(), '.sandlot', basename(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)
|
|
}
|
|
|
|
const spin = spinner("Creating worktree")
|
|
try {
|
|
await git.createWorktree(branch, worktreeAbs, root)
|
|
await mkdir(join(root, '.sandlot'), { recursive: true })
|
|
await symlink(worktreeAbs, join(root, '.sandlot', branch))
|
|
|
|
spin.text = "Starting container"
|
|
await vm.ensure((msg) => { spin.text = msg })
|
|
spin.succeed("Session ready")
|
|
} catch (err) {
|
|
spin.fail(String((err as Error).message ?? err))
|
|
await git.removeWorktree(worktreeAbs, root).catch(() => {})
|
|
await git.deleteLocalBranch(branch, root).catch(() => {})
|
|
await unlink(join(root, '.sandlot', branch)).catch(() => {})
|
|
process.exit(1)
|
|
}
|
|
|
|
await state.setSession(root, {
|
|
branch,
|
|
worktree: worktreeAbs,
|
|
created_at: new Date().toISOString(),
|
|
})
|
|
|
|
if (opts.print) console.log(`Running prompt…`)
|
|
await vm.claude(worktreeAbs, { prompt, print: opts.print })
|
|
})
|
|
|
|
// ── sandlot list ──────────────────────────────────────────────────────
|
|
|
|
program
|
|
.command("list")
|
|
.description("Show all active sessions")
|
|
.option("--json", "Output as JSON")
|
|
.action(async (opts: { json?: boolean }) => {
|
|
const root = await git.repoRoot()
|
|
const st = await state.load(root)
|
|
const sessions = Object.values(st.sessions)
|
|
|
|
if (opts.json) {
|
|
console.log(JSON.stringify(sessions, null, 2))
|
|
return
|
|
}
|
|
|
|
if (sessions.length === 0) {
|
|
console.log("No active sessions.")
|
|
return
|
|
}
|
|
|
|
const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length))
|
|
console.log(`${"BRANCH".padEnd(branchWidth)} WORKTREE`)
|
|
for (const s of sessions) {
|
|
console.log(`${s.branch.padEnd(branchWidth)} ${s.worktree}/`)
|
|
}
|
|
})
|
|
|
|
// ── sandlot open <branch> ─────────────────────────────────────────────
|
|
|
|
program
|
|
.command("open")
|
|
.argument("<branch>", "branch name")
|
|
.argument("[prompt]", "initial prompt for Claude")
|
|
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
|
|
.description("Re-enter an existing session")
|
|
.action(async (branch: string, prompt: string | undefined, opts: { print?: string }) => {
|
|
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)
|
|
}
|
|
|
|
const spin = spinner("Starting container")
|
|
await vm.ensure((msg) => { spin.text = msg })
|
|
spin.succeed("Session ready")
|
|
|
|
if (opts.print) console.log(`Running prompt…`)
|
|
await vm.claude(session.worktree, { prompt, print: opts.print })
|
|
})
|
|
|
|
// ── sandlot close <branch> ───────────────────────────────────────────
|
|
|
|
const closeAction = async (branch: string) => {
|
|
const root = await git.repoRoot()
|
|
const session = await state.getSession(root, branch)
|
|
const worktreeAbs = session?.worktree ?? join(homedir(), '.sandlot', basename(root), branch)
|
|
|
|
await git.removeWorktree(worktreeAbs, root)
|
|
.then(() => console.log(`Removed worktree ${worktreeAbs}/`))
|
|
.catch((e) => console.warn(`Failed to remove worktree ${worktreeAbs}: ${e.message}`))
|
|
|
|
await unlink(join(root, '.sandlot', branch))
|
|
.then(() => console.log(`Removed symlink .sandlot/${branch}`))
|
|
.catch(() => {}) // symlink may not exist
|
|
|
|
await git.deleteLocalBranch(branch, root)
|
|
.then(() => console.log(`Deleted local branch ${branch}`))
|
|
.catch((e) => console.warn(`Failed to delete local branch ${branch}: ${e.message}`))
|
|
|
|
if (session) {
|
|
await state.removeSession(root, branch)
|
|
}
|
|
}
|
|
|
|
// ── sandlot merge <branch> ──────────────────────────────────────────
|
|
|
|
program
|
|
.command("merge")
|
|
.argument("<branch>", "branch name")
|
|
.description("Merge a branch into main and close the session")
|
|
.action(async (branch: string) => {
|
|
const root = await git.repoRoot()
|
|
|
|
const conflicts = await git.merge(branch, root)
|
|
|
|
if (conflicts.length === 0) {
|
|
console.log(`Merged ${branch} into current branch`)
|
|
await closeAction(branch)
|
|
return
|
|
}
|
|
|
|
// Resolve conflicts with Claude
|
|
console.log(`Merge conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
|
|
const spin = spinner("Starting container")
|
|
|
|
try {
|
|
await vm.ensure((msg) => { spin.text = msg })
|
|
|
|
for (const file of conflicts) {
|
|
spin.text = `Resolving ${file}`
|
|
const content = await Bun.file(join(root, file)).text()
|
|
|
|
const tmpPath = join(homedir(), '.sandlot', '.conflict-tmp')
|
|
await Bun.write(tmpPath, content)
|
|
|
|
const resolved = await vm.exec(
|
|
join(homedir(), '.sandlot'),
|
|
'cat /sandlot/.conflict-tmp | claude -p "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text."',
|
|
)
|
|
|
|
await Bun.file(tmpPath).unlink().catch(() => {})
|
|
|
|
if (resolved.exitCode !== 0) {
|
|
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`)
|
|
}
|
|
|
|
await Bun.write(join(root, file), resolved.stdout + "\n")
|
|
await git.stageFile(file, root)
|
|
}
|
|
|
|
await git.commitMerge(root)
|
|
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)
|
|
} catch (err) {
|
|
spin.fail(String((err as Error).message ?? err))
|
|
await git.abortMerge(root)
|
|
process.exit(1)
|
|
}
|
|
|
|
await closeAction(branch)
|
|
})
|
|
|
|
program
|
|
.command("close")
|
|
.argument("<branch>", "branch name")
|
|
.description("Remove a worktree and clean up the session")
|
|
.action(closeAction)
|
|
|
|
program
|
|
.command("rm", { hidden: true })
|
|
.argument("<branch>", "branch name")
|
|
.action(closeAction)
|
|
|
|
// ── sandlot save <branch> ───────────────────────────────────────────
|
|
|
|
program
|
|
.command("save")
|
|
.argument("<branch>", "branch name")
|
|
.argument("[message]", "commit message (AI-generated if omitted)")
|
|
.description("Stage all changes and commit")
|
|
.action(async (branch: string, message?: string) => {
|
|
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)
|
|
}
|
|
|
|
const wt = session.worktree
|
|
const spin = spinner("Staging changes")
|
|
|
|
// Run git on the host — the worktree's .git references host paths and
|
|
// ~/dev is mounted read-only in the container, so git must run here.
|
|
await $`git -C ${wt} add .`.nothrow().quiet()
|
|
|
|
const check = await $`git -C ${wt} diff --staged --quiet`.nothrow().quiet()
|
|
if (check.exitCode === 0) {
|
|
spin.fail("No changes to commit")
|
|
process.exit(1)
|
|
}
|
|
|
|
let msg: string
|
|
if (message) {
|
|
msg = message
|
|
} else {
|
|
spin.text = "Starting container"
|
|
await vm.ensure((m) => { spin.text = m })
|
|
|
|
spin.text = "Generating commit message"
|
|
const diff = await $`git -C ${wt} diff --staged`.nothrow().quiet().text()
|
|
const tmpPath = join(homedir(), '.sandlot', '.sandlot-diff-tmp')
|
|
await Bun.write(tmpPath, diff)
|
|
|
|
const gen = await vm.exec(
|
|
join(homedir(), '.sandlot'),
|
|
'cat /sandlot/.sandlot-diff-tmp | claude -p "write a short commit message summarizing these changes. output only the message, no quotes or extra text"',
|
|
)
|
|
await Bun.file(tmpPath).unlink().catch(() => {})
|
|
|
|
if (gen.exitCode !== 0) {
|
|
spin.fail("Failed to generate commit message")
|
|
if (gen.stderr) console.error(gen.stderr)
|
|
process.exit(1)
|
|
}
|
|
msg = gen.stdout
|
|
}
|
|
|
|
spin.text = "Committing"
|
|
const commit = await $`git -C ${wt} commit -m ${msg}`.nothrow().quiet()
|
|
if (commit.exitCode !== 0) {
|
|
spin.fail("Commit failed")
|
|
if (commit.stderr) console.error(commit.stderr.toString().trim())
|
|
process.exit(1)
|
|
}
|
|
|
|
spin.succeed(`Saved: ${msg}`)
|
|
})
|
|
|
|
// ── sandlot diff <branch> ───────────────────────────────────────────
|
|
|
|
program
|
|
.command("diff")
|
|
.argument("<branch>", "branch name")
|
|
.description("Show unstaged changes in a session's worktree")
|
|
.action(async (branch: string) => {
|
|
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)
|
|
}
|
|
|
|
const result = await $`git -C ${session.worktree} diff`.nothrow()
|
|
if (result.exitCode !== 0) {
|
|
console.error("git diff failed")
|
|
process.exit(1)
|
|
}
|
|
})
|
|
|
|
// ── sandlot vm ───────────────────────────────────────────────────────
|
|
|
|
const vmCmd = program.command("vm").description("Manage the sandlot VM")
|
|
|
|
vmCmd
|
|
.command("shell")
|
|
.description("Open a shell in the VM")
|
|
.action(async () => {
|
|
await vm.ensure()
|
|
await vm.shell()
|
|
})
|
|
|
|
vmCmd
|
|
.command("status")
|
|
.description("Show VM status")
|
|
.action(async () => {
|
|
const s = await vm.status()
|
|
console.log(s)
|
|
})
|
|
|
|
vmCmd
|
|
.command("info")
|
|
.description("Show VM system info (via neofetch)")
|
|
.action(async () => {
|
|
await vm.ensure()
|
|
await vm.info()
|
|
})
|
|
|
|
vmCmd
|
|
.command("stop")
|
|
.description("Stop the VM")
|
|
.action(async () => {
|
|
await vm.stop()
|
|
console.log("VM stopped")
|
|
})
|
|
|
|
vmCmd
|
|
.command("destroy")
|
|
.description("Stop and delete the VM")
|
|
.action(async () => {
|
|
await vm.destroy()
|
|
console.log("VM destroyed")
|
|
})
|
|
|
|
program.parseAsync().catch((err) => {
|
|
console.error(err.message ?? err)
|
|
process.exit(1)
|
|
})
|