diff --git a/README.md b/README.md index 5afe854..2aa9cef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # sandlot -A CLI for branch-based development using git worktrees and [Apple containers](https://github.com/apple/container). Each branch gets its own worktree and isolated VM. Commits are auto-summarized by Claude. Merging cleans everything up. +A CLI for branch-based development using git worktrees and [Apple containers](https://github.com/apple/container). Each branch gets its own worktree and isolated VM. ## Prerequisites @@ -18,16 +18,6 @@ bun install bun link ``` -## Configure - -Set your Anthropic API key in `~/.env`: - -``` -ANTHROPIC_API_KEY=sk-ant-... -``` - -This file is loaded automatically on startup. Variables already set in the environment take precedence. - ## Usage Run all commands from inside a cloned git repo. @@ -40,32 +30,17 @@ sandlot new fix-POST Creates a worktree at `.sandlot/fix-POST/`, boots a VM mapped to it, and drops you into a shell. -### Save your work - -```bash -sandlot save # AI-generated commit message -sandlot save "wip: rough validation" # manual message -``` - -Stages all changes, commits, and pushes to origin. - -### Merge and tear down - -```bash -sandlot push main -``` - -Merges the session branch into the target, pushes, then tears down the VM, worktree, and branch. If there are merge conflicts, Claude resolves them and asks for confirmation. - ### Other commands ```bash sandlot list # show all sessions sandlot open # re-enter a session's VM sandlot stop # stop a VM without destroying it -sandlot rm # tear down without merging +sandlot rm # tear down session (VM, worktree, local branch) ``` +Use git directly for commits, pushes, merges, etc. The worktree is a normal git checkout. + ## Project config Optionally add a `sandlot.json` to your repo root: @@ -77,9 +52,6 @@ Optionally add a `sandlot.json` to your repo root: "memory": "8GB", "image": "ubuntu:24.04", "mounts": { "/path/to/deps": "/deps" } - }, - "ai": { - "model": "claude-sonnet-4-20250514" } } ``` diff --git a/SPEC.md b/SPEC.md index e0d1459..334e144 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,12 +1,12 @@ # sandlot -A CLI for branch-based development using git worktrees and Apple containers. Each branch gets its own worktree and isolated VM. Commits are auto-summarized by Claude. Merging cleans everything up. +A CLI for branch-based development using git worktrees and Apple containers. Each branch gets its own worktree and isolated VM. ## Concepts -**Sandlot** is a thin workflow layer over three things: git worktrees, [Apple containers](https://github.com/apple/container), and the Claude API. The idea is that spinning up a branch should give you a fully isolated environment — filesystem and runtime — with zero setup. When you're done, you merge and everything tears down. +**Sandlot** is a thin workflow layer over two things: git worktrees and [Apple containers](https://github.com/apple/container). The idea is that spinning up a branch should give you a fully isolated environment — filesystem and runtime — with zero setup. When you're done, use git to merge and `sandlot rm` to clean up. -A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot ` and destroyed on merge. +A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot new ` and destroyed with `sandlot rm `. ## Tech Stack @@ -34,16 +34,6 @@ bun link This makes `sandlot` available globally. -### Configure - -Set your Anthropic API key: - -```bash -export ANTHROPIC_API_KEY=sk-ant-... -``` - -Add to your shell profile (`.zshrc`, `.bashrc`, etc.) to persist it. - ## CLI ### `sandlot new ` @@ -62,87 +52,6 @@ Booting VM... root@fix-POST:~# ``` -### `sandlot save [message]` - -Stage and commit all changes. If a message is provided, use it. If not, generate one with Claude. - -1. Runs `git add .` in the worktree -2. If no message provided: diffs staged changes against the last commit, sends the diff to Claude API (claude-sonnet-4-20250514) to generate a commit message -3. Commits with the message -4. Pushes the branch to origin - -If there are no changes, does nothing. - -The AI-generated commit message is a single-line summary (≤72 chars) followed by an optional body with more detail if the diff is substantial. - -``` -$ sandlot save -Staged 3 files -Commit: Fix POST handler to validate request body before processing -Pushed fix-POST → origin/fix-POST - -$ sandlot save "wip: rough cut of validation" -Staged 3 files -Commit: wip: rough cut of validation -Pushed fix-POST → origin/fix-POST -``` - -### `sandlot push ` - -Push the current session's branch into ``, then tear everything down. - -1. Checks out `` in the main working tree -2. Merges the session branch (fast-forward if possible, merge commit otherwise) -3. Pushes `` to origin -4. Stops and removes the VM -5. Removes the worktree -6. Deletes the local branch -7. Deletes the remote branch - -If there are uncommitted changes in the worktree, prompts to `sandlot save` first. - -If the merge has conflicts: - -1. Collects all conflicted files -2. For each file, sends the full conflict diff (ours, theirs, and base) to the Claude API -3. Claude resolves the conflict and returns the merged file -4. Writes the resolved file and stages it -5. Shows a summary of what Claude chose and why -6. Prompts for confirmation before committing the merge - -If you reject Claude's resolution, drops you into the main working tree to resolve manually. Run `sandlot push ` again to complete cleanup. - -``` -$ sandlot push main -Pushing fix-POST → main... -Pushed main → origin/main -Stopped VM fix-POST -Removed worktree .sandlot/fix-POST/ -Deleted branch fix-POST (local + remote) -``` - -With conflicts: - -``` -$ sandlot push main -Pushing fix-POST → main... -2 conflicts found. Resolving with Claude... - - src/handlers/post.ts - ✓ Kept the new validation logic from fix-POST, - preserved the logging added in main - - src/routes.ts - ✓ Combined route definitions from both branches - -Accept Claude's resolutions? [Y/n] y -Committed merge -Pushed main → origin/main -Stopped VM fix-POST -Removed worktree .sandlot/fix-POST/ -Deleted branch fix-POST (local + remote) -``` - ### `sandlot list` Show all active sessions. @@ -170,7 +79,7 @@ Stop a session's VM without destroying it. The worktree and branch remain. ### `sandlot rm ` -Tear down a session without merging. Stops the VM, removes the worktree, deletes the local branch. Does not touch the remote branch. +Tear down a session. Stops the VM, removes the worktree, deletes the local branch. Does not touch the remote branch. ## Configuration @@ -185,9 +94,6 @@ Optional `sandlot.json` at the repo root: "mounts": { "/path/to/shared/deps": "/deps" } - }, - "ai": { - "model": "claude-sonnet-4-20250514" } } ``` @@ -214,13 +120,12 @@ Sandlot tracks sessions in `.sandlot/state.json` at the repo root: ## Edge Cases -- **Nested sandlot calls**: `sandlot save` and `sandlot push` detect the current session from the working directory or a `SANDLOT_BRANCH` env var set when entering the VM. -- **Stale VMs**: If a VM crashes, `sandlot open` should detect the dead VM and reboot it. +- **Stale VMs**: If a VM crashes, `sandlot open` detects the dead VM and reboots it. - **Multiple repos**: State is per-repo. No global daemon. - **Branch name conflicts**: If `.sandlot//` already exists as a directory but the session state is missing, prompt to clean up or recover. ## Non-Goals - Not a CI/CD tool. No pipelines, no test runners. -- Not a replacement for git. All git state lives in the real repo. Sandlot is sugar on top. +- Not a replacement for git. All git state lives in the real repo. Sandlot manages worktrees and VMs only. - No multi-user collaboration features. This is a single-developer workflow tool. diff --git a/package.json b/package.json index 9b1272e..18207ff 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "sandlot": "./src/cli.ts" }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0", "commander": "^13.1.0" }, "devDependencies": { diff --git a/src/ai.ts b/src/ai.ts deleted file mode 100644 index 7c172d1..0000000 --- a/src/ai.ts +++ /dev/null @@ -1,74 +0,0 @@ -import Anthropic from "@anthropic-ai/sdk" - -let client: Anthropic | null = null - -function getClient(): Anthropic { - if (!client) { - client = new Anthropic() - } - return client -} - -/** Generate a commit message from a diff. */ -export async function generateCommitMessage(diff: string, model: string): Promise { - const anthropic = getClient() - - const response = await anthropic.messages.create({ - model, - max_tokens: 256, - messages: [ - { - role: "user", - 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") -} - -/** Resolve a merge conflict in a file. Returns the resolved content and an explanation. */ -export async function resolveConflict( - filePath: string, - conflictContent: string, - model: string -): Promise<{ resolved: string; explanation: string }> { - const anthropic = getClient() - - const response = await anthropic.messages.create({ - model, - max_tokens: 4096, - messages: [ - { - role: "user", - content: `You are resolving a git merge conflict in the file "${filePath}". The file contains conflict markers (<<<<<<< HEAD, =======, >>>>>>> branch). Resolve the conflict by intelligently combining changes from both sides. - -Return your response in this exact format: -EXPLANATION: -RESOLVED: - - -File with conflicts: -${conflictContent}`, - }, - ], - }) - - 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]+)/) - - if (!explMatch || !resolvedMatch) { - 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 d8f3316..020630c 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,28 +1,12 @@ #!/usr/bin/env bun -import { homedir } from "os" import { Command } from "commander" import { join } from "path" -// 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" const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json() @@ -66,129 +50,6 @@ program await vm.shell(vmId) }) -// ── sandlot save [message] ──────────────────────────────────────────── - -program - .command("save") - .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 - - if (!(await git.hasChanges(cwd))) { - console.log("No changes to commit.") - return - } - - const staged = await git.stageAll(cwd) - console.log(`Staged ${staged} files`) - - let commitMsg: string - if (message) { - commitMsg = message - } else { - 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.push(branch, cwd) - console.log(`Pushed ${branch} → origin/${branch}`) - }) - -// ── sandlot push ───────────────────────────────────────────── - -program - .command("push") - .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) - - if (!session) { - console.error(`No session found for branch "${branch}".`) - process.exit(1) - } - - 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.log(`Pushing ${branch} → ${target}...`) - - // Checkout target in main working tree - await git.checkout(target, root) - - // Merge - 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 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 }) - } - - for (const { file, explanation } of resolutions) { - console.log(` ${file}`) - console.log(` ✓ ${explanation}\n`) - } - - // Prompt for confirmation - 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.commitMerge(root) - console.log("Committed merge") - } - - // Push 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 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 state.removeSession(root, branch) - }) - // ── sandlot list ────────────────────────────────────────────────────── program @@ -285,12 +146,12 @@ program console.log(`Stopped VM for ${branch}`) }) -// ── sandlot rm ─────────────────────────────────────────────── +// ── sandlot close ──────────────────────────────────────────── program - .command("rm") + .command("close") .argument("", "branch name") - .description("Tear down a session without merging") + .description("Tear down a session and switch back to main") .action(async (branch: string) => { const root = await git.repoRoot() const session = await state.getSession(root, branch) @@ -310,40 +171,9 @@ program console.log(`Deleted local branch ${branch}`) await state.removeSession(root, branch) + + await git.checkout("main", root) + console.log(`Switched to main`) }) -// ── Helpers ─────────────────────────────────────────────────────────── - -/** Detect the current session branch from env or working directory. */ -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 - } - - // Check if cwd is inside a worktree - 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 - } - - // Fall back to current git branch - 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.once("data", (chunk) => { - process.stdin.pause() - resolve(chunk.toString().trim()) - }) - }) -} - program.parse() diff --git a/src/config.ts b/src/config.ts index f7bc6e2..bafe53f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,18 +7,12 @@ export interface VmConfig { mounts?: Record } -export interface AiConfig { - model?: string -} - export interface SandlotConfig { 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 { @@ -28,7 +22,6 @@ export async function loadConfig(repoRoot: string): Promise { const userConfig = await file.json() return { vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm }, - ai: { ...DEFAULT_CONFIG.ai, ...userConfig.ai }, } } return DEFAULT_CONFIG diff --git a/src/git.ts b/src/git.ts index 408b85b..9d724cd 100644 --- a/src/git.ts +++ b/src/git.ts @@ -49,73 +49,7 @@ export async function deleteLocalBranch(branch: string, cwd: string): Promise { - 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 -} - -/** 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 -} - -/** Get the diff of staged changes. */ -export async function stagedDiff(cwd: string): Promise { - 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) -} - -/** Push a branch to origin. */ -export async function push(branch: string, cwd: string): Promise { - await $`git push -u origin ${branch}`.cwd(cwd) -} - -/** Checkout a branch in a working tree. */ +/** Checkout a branch. */ export async function checkout(branch: string, cwd: string): Promise { 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 -} - -/** 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) -} - -/** 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() -} - -/** Stage a resolved file. */ -export async function stageFile(filePath: string, cwd: string): Promise { - 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) -} - -/** Abort a merge. */ -export async function abortMerge(cwd: string): Promise { - await $`git merge --abort`.cwd(cwd) -}