This commit is contained in:
Chris Wanstrath 2026-02-17 07:57:24 -08:00
parent b5c507570e
commit 6f6d921c54
7 changed files with 17 additions and 458 deletions

View File

@ -1,6 +1,6 @@
# sandlot # 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 ## Prerequisites
@ -18,16 +18,6 @@ bun install
bun link 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 ## Usage
Run all commands from inside a cloned git repo. 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. 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 ### Other commands
```bash ```bash
sandlot list # show all sessions sandlot list # show all sessions
sandlot open <branch> # re-enter a session's VM sandlot open <branch> # re-enter a session's VM
sandlot stop <branch> # stop a VM without destroying it sandlot stop <branch> # stop a VM without destroying it
sandlot rm <branch> # tear down without merging sandlot rm <branch> # tear down session (VM, worktree, local branch)
``` ```
Use git directly for commits, pushes, merges, etc. The worktree is a normal git checkout.
## Project config ## Project config
Optionally add a `sandlot.json` to your repo root: Optionally add a `sandlot.json` to your repo root:
@ -77,9 +52,6 @@ Optionally add a `sandlot.json` to your repo root:
"memory": "8GB", "memory": "8GB",
"image": "ubuntu:24.04", "image": "ubuntu:24.04",
"mounts": { "/path/to/deps": "/deps" } "mounts": { "/path/to/deps": "/deps" }
},
"ai": {
"model": "claude-sonnet-4-20250514"
} }
} }
``` ```

107
SPEC.md
View File

@ -1,12 +1,12 @@
# sandlot # 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 ## 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 <branch>` and destroyed on merge. A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot new <branch>` and destroyed with `sandlot rm <branch>`.
## Tech Stack ## Tech Stack
@ -34,16 +34,6 @@ bun link
This makes `sandlot` available globally. 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 ## CLI
### `sandlot new <branch>` ### `sandlot new <branch>`
@ -62,87 +52,6 @@ Booting VM...
root@fix-POST:~# 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 <target>`
Push the current session's branch into `<target>`, then tear everything down.
1. Checks out `<target>` in the main working tree
2. Merges the session branch (fast-forward if possible, merge commit otherwise)
3. Pushes `<target>` 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 <target>` 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` ### `sandlot list`
Show all active sessions. Show all active sessions.
@ -170,7 +79,7 @@ Stop a session's VM without destroying it. The worktree and branch remain.
### `sandlot rm <branch>` ### `sandlot rm <branch>`
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 ## Configuration
@ -185,9 +94,6 @@ Optional `sandlot.json` at the repo root:
"mounts": { "mounts": {
"/path/to/shared/deps": "/deps" "/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 ## 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` detects the dead VM and reboots it.
- **Stale VMs**: If a VM crashes, `sandlot open` should detect the dead VM and reboot it.
- **Multiple repos**: State is per-repo. No global daemon. - **Multiple repos**: State is per-repo. No global daemon.
- **Branch name conflicts**: If `.sandlot/<branch>/` already exists as a directory but the session state is missing, prompt to clean up or recover. - **Branch name conflicts**: If `.sandlot/<branch>/` already exists as a directory but the session state is missing, prompt to clean up or recover.
## Non-Goals ## Non-Goals
- Not a CI/CD tool. No pipelines, no test runners. - 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. - No multi-user collaboration features. This is a single-developer workflow tool.

View File

@ -6,7 +6,6 @@
"sandlot": "./src/cli.ts" "sandlot": "./src/cli.ts"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"commander": "^13.1.0" "commander": "^13.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -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<string> {
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: <one line explaining what you chose and why>
RESOLVED:
<the full resolved file content with no conflict markers>
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",
}
}

View File

@ -1,28 +1,12 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { homedir } from "os"
import { Command } from "commander" import { Command } from "commander"
import { join } from "path" 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 git from "./git.ts"
import * as vm from "./vm.ts" import * as vm from "./vm.ts"
import * as state from "./state.ts" import * as state from "./state.ts"
import { loadConfig } from "./config.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() const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
@ -66,129 +50,6 @@ program
await vm.shell(vmId) 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 <target> ─────────────────────────────────────────────
program
.command("push")
.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)
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 ────────────────────────────────────────────────────── // ── sandlot list ──────────────────────────────────────────────────────
program program
@ -285,12 +146,12 @@ program
console.log(`Stopped VM for ${branch}`) console.log(`Stopped VM for ${branch}`)
}) })
// ── sandlot rm <branch> ─────────────────────────────────────────────── // ── sandlot close <branch> ────────────────────────────────────────────
program program
.command("rm") .command("close")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Tear down a session without merging") .description("Tear down a session and switch back to main")
.action(async (branch: string) => { .action(async (branch: string) => {
const root = await git.repoRoot() const root = await git.repoRoot()
const session = await state.getSession(root, branch) const session = await state.getSession(root, branch)
@ -310,40 +171,9 @@ program
console.log(`Deleted local branch ${branch}`) console.log(`Deleted local branch ${branch}`)
await state.removeSession(root, 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<string> {
// 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<string> {
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() program.parse()

View File

@ -7,18 +7,12 @@ export interface VmConfig {
mounts?: Record<string, string> mounts?: Record<string, string>
} }
export interface AiConfig {
model?: string
}
export interface SandlotConfig { export interface SandlotConfig {
vm?: VmConfig vm?: VmConfig
ai?: AiConfig
} }
const DEFAULT_CONFIG: SandlotConfig = { const DEFAULT_CONFIG: SandlotConfig = {
vm: { image: "ubuntu:24.04" }, vm: { image: "ubuntu:24.04" },
ai: { model: "claude-sonnet-4-20250514" },
} }
export async function loadConfig(repoRoot: string): Promise<SandlotConfig> { export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
@ -28,7 +22,6 @@ export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
const userConfig = await file.json() const userConfig = await file.json()
return { return {
vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm }, vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm },
ai: { ...DEFAULT_CONFIG.ai, ...userConfig.ai },
} }
} }
return DEFAULT_CONFIG return DEFAULT_CONFIG

View File

@ -49,73 +49,7 @@ export async function deleteLocalBranch(branch: string, cwd: string): Promise<vo
await $`git branch -D ${branch}`.cwd(cwd).nothrow() await $`git branch -D ${branch}`.cwd(cwd).nothrow()
} }
/** Delete a remote branch. */ /** Checkout a branch. */
export async function deleteRemoteBranch(branch: string, cwd: string): Promise<void> {
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
}
/** 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
}
/** Get the diff of staged changes. */
export async function stagedDiff(cwd: string): Promise<string> {
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)
}
/** Push a branch to origin. */
export async function push(branch: string, cwd: string): Promise<void> {
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> { 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
}
/** 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)
}
/** 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()
}
/** Stage a resolved file. */
export async function stageFile(filePath: string, cwd: string): Promise<void> {
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)
}
/** Abort a merge. */
export async function abortMerge(cwd: string): Promise<void> {
await $`git merge --abort`.cwd(cwd)
}