Add sandlot CLI for branch-based dev with worktrees and Apple containers
Implements all 7 commands: new, save, push, list, open, stop, rm. Uses Commander for CLI parsing, Anthropic SDK for AI commit messages and merge conflict resolution, and Apple container for VMs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ad51746aa1
commit
5d7bf302f2
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
.sandlot/
|
||||||
79
README.md
Normal file
79
README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- macOS on Apple Silicon
|
||||||
|
- [Bun](https://bun.sh)
|
||||||
|
- [container](https://github.com/apple/container) installed and on PATH
|
||||||
|
- Git
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your/sandlot
|
||||||
|
cd sandlot
|
||||||
|
bun install
|
||||||
|
bun link
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
Set your Anthropic API key:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export ANTHROPIC_API_KEY=sk-ant-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally add a `sandlot.json` to your repo root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vm": {
|
||||||
|
"cpus": 4,
|
||||||
|
"memory": "8GB",
|
||||||
|
"image": "ubuntu:24.04",
|
||||||
|
"mounts": { "/path/to/deps": "/deps" }
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"model": "claude-sonnet-4-20250514"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Start a session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 <branch> # re-enter a session's VM
|
||||||
|
sandlot stop <branch> # stop a VM without destroying it
|
||||||
|
sandlot rm <branch> # tear down without merging
|
||||||
|
```
|
||||||
226
SPEC.md
Normal file
226
SPEC.md
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot <branch>` and destroyed on merge.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh) runtime
|
||||||
|
- [Commander](https://github.com/tj/commander.js) for CLI parsing
|
||||||
|
- [Apple container](https://github.com/apple/container) for VMs
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- macOS on Apple Silicon
|
||||||
|
- [Bun](https://bun.sh) installed
|
||||||
|
- Git installed
|
||||||
|
- [container](https://github.com/apple/container) installed and available on PATH
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your/sandlot
|
||||||
|
cd sandlot
|
||||||
|
bun install
|
||||||
|
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 <branch>`
|
||||||
|
|
||||||
|
Create a new session. This:
|
||||||
|
|
||||||
|
1. Checks out the branch if it exists (local or remote), or creates a new branch from current HEAD
|
||||||
|
2. Creates a git worktree at `.sandlot/<branch>/` (relative to the repo root)
|
||||||
|
3. Boots an Apple container VM mapped to that worktree
|
||||||
|
4. Drops you into the VM shell
|
||||||
|
|
||||||
|
```
|
||||||
|
$ sandlot new fix-POST
|
||||||
|
Creating worktree at .sandlot/fix-POST/
|
||||||
|
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 <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`
|
||||||
|
|
||||||
|
Show all active sessions.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ sandlot list
|
||||||
|
BRANCH VM STATUS WORKTREE
|
||||||
|
fix-POST running .sandlot/fix-POST/
|
||||||
|
refactor-auth stopped .sandlot/refactor-auth/
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sandlot open <branch>`
|
||||||
|
|
||||||
|
Re-enter an existing session's VM. If the VM is stopped, boots it first.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ sandlot open fix-POST
|
||||||
|
Booting VM...
|
||||||
|
root@fix-POST:~#
|
||||||
|
```
|
||||||
|
|
||||||
|
### `sandlot stop <branch>`
|
||||||
|
|
||||||
|
Stop a session's VM without destroying it. The worktree and branch remain.
|
||||||
|
|
||||||
|
### `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.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Optional `sandlot.json` at the repo root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vm": {
|
||||||
|
"cpus": 4,
|
||||||
|
"memory": "8GB",
|
||||||
|
"image": "ubuntu:24.04",
|
||||||
|
"mounts": {
|
||||||
|
"/path/to/shared/deps": "/deps"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"model": "claude-sonnet-4-20250514"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
Sandlot tracks sessions in `.sandlot/state.json` at the repo root:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sessions": {
|
||||||
|
"fix-POST": {
|
||||||
|
"branch": "fix-POST",
|
||||||
|
"worktree": ".sandlot/fix-POST",
|
||||||
|
"vm_id": "container-abc123",
|
||||||
|
"created_at": "2026-02-16T10:30:00Z",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`.sandlot/` should be added to `.gitignore`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- No multi-user collaboration features. This is a single-developer workflow tool.
|
||||||
97
bun.lock
Normal file
97
bun.lock
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "sandlot",
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.39.0",
|
||||||
|
"commander": "^13.1.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.39.0", "https://npm.nose.space/@anthropic-ai/sdk/-/sdk-0.39.0.tgz", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.9", "https://npm.nose.space/@types/bun/-/bun-1.3.9.tgz", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@18.19.130", "https://npm.nose.space/@types/node/-/node-18.19.130.tgz", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
|
||||||
|
|
||||||
|
"@types/node-fetch": ["@types/node-fetch@2.6.13", "https://npm.nose.space/@types/node-fetch/-/node-fetch-2.6.13.tgz", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
|
||||||
|
|
||||||
|
"abort-controller": ["abort-controller@3.0.0", "https://npm.nose.space/abort-controller/-/abort-controller-3.0.0.tgz", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||||
|
|
||||||
|
"agentkeepalive": ["agentkeepalive@4.6.0", "https://npm.nose.space/agentkeepalive/-/agentkeepalive-4.6.0.tgz", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||||
|
|
||||||
|
"asynckit": ["asynckit@0.4.0", "https://npm.nose.space/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.9", "https://npm.nose.space/bun-types/-/bun-types-1.3.9.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
|
||||||
|
|
||||||
|
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://npm.nose.space/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||||
|
|
||||||
|
"combined-stream": ["combined-stream@1.0.8", "https://npm.nose.space/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
|
"commander": ["commander@13.1.0", "https://npm.nose.space/commander/-/commander-13.1.0.tgz", {}, "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw=="],
|
||||||
|
|
||||||
|
"delayed-stream": ["delayed-stream@1.0.0", "https://npm.nose.space/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||||
|
|
||||||
|
"dunder-proto": ["dunder-proto@1.0.1", "https://npm.nose.space/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
|
"es-define-property": ["es-define-property@1.0.1", "https://npm.nose.space/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
|
|
||||||
|
"es-errors": ["es-errors@1.3.0", "https://npm.nose.space/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"es-object-atoms": ["es-object-atoms@1.1.1", "https://npm.nose.space/es-object-atoms/-/es-object-atoms-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||||
|
|
||||||
|
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "https://npm.nose.space/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||||
|
|
||||||
|
"event-target-shim": ["event-target-shim@5.0.1", "https://npm.nose.space/event-target-shim/-/event-target-shim-5.0.1.tgz", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
|
||||||
|
|
||||||
|
"form-data": ["form-data@4.0.5", "https://npm.nose.space/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||||
|
|
||||||
|
"form-data-encoder": ["form-data-encoder@1.7.2", "https://npm.nose.space/form-data-encoder/-/form-data-encoder-1.7.2.tgz", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
|
||||||
|
|
||||||
|
"formdata-node": ["formdata-node@4.4.1", "https://npm.nose.space/formdata-node/-/formdata-node-4.4.1.tgz", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "https://npm.nose.space/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"get-intrinsic": ["get-intrinsic@1.3.0", "https://npm.nose.space/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
|
|
||||||
|
"get-proto": ["get-proto@1.0.1", "https://npm.nose.space/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||||
|
|
||||||
|
"gopd": ["gopd@1.2.0", "https://npm.nose.space/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||||
|
|
||||||
|
"has-symbols": ["has-symbols@1.1.0", "https://npm.nose.space/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||||
|
|
||||||
|
"has-tostringtag": ["has-tostringtag@1.0.2", "https://npm.nose.space/has-tostringtag/-/has-tostringtag-1.0.2.tgz", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.2", "https://npm.nose.space/hasown/-/hasown-2.0.2.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"humanize-ms": ["humanize-ms@1.2.1", "https://npm.nose.space/humanize-ms/-/humanize-ms-1.2.1.tgz", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
|
||||||
|
|
||||||
|
"math-intrinsics": ["math-intrinsics@1.1.0", "https://npm.nose.space/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.52.0", "https://npm.nose.space/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@2.1.35", "https://npm.nose.space/mime-types/-/mime-types-2.1.35.tgz", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "https://npm.nose.space/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"node-domexception": ["node-domexception@1.0.0", "https://npm.nose.space/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||||
|
|
||||||
|
"node-fetch": ["node-fetch@2.7.0", "https://npm.nose.space/node-fetch/-/node-fetch-2.7.0.tgz", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||||
|
|
||||||
|
"tr46": ["tr46@0.0.3", "https://npm.nose.space/tr46/-/tr46-0.0.3.tgz", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@5.26.5", "https://npm.nose.space/undici-types/-/undici-types-5.26.5.tgz", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
|
||||||
|
|
||||||
|
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "https://npm.nose.space/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
|
||||||
|
|
||||||
|
"webidl-conversions": ["webidl-conversions@3.0.1", "https://npm.nose.space/webidl-conversions/-/webidl-conversions-3.0.1.tgz", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"whatwg-url": ["whatwg-url@5.0.0", "https://npm.nose.space/whatwg-url/-/whatwg-url-5.0.0.tgz", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
15
package.json
Normal file
15
package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "sandlot",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"sandlot": "./src/cli.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.39.0",
|
||||||
|
"commander": "^13.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/ai.ts
Normal file
74
src/ai.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
331
src/cli.ts
Executable file
331
src/cli.ts
Executable file
|
|
@ -0,0 +1,331 @@
|
||||||
|
#!/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";
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program.name("sandlot").description("Branch-based development with git worktrees and Apple containers").version("0.1.0");
|
||||||
|
|
||||||
|
// ── sandlot new <branch> ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("new")
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// Check for stale directory
|
||||||
|
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.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);
|
||||||
|
|
||||||
|
await state.setSession(root, {
|
||||||
|
branch,
|
||||||
|
worktree: worktreeRel,
|
||||||
|
vm_id: vmId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
status: "running",
|
||||||
|
});
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log("No active sessions.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check actual VM statuses
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print table
|
||||||
|
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> ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("open")
|
||||||
|
.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);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
console.error(`No session found for branch "${branch}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
} else if (vmStatus === "stopped") {
|
||||||
|
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);
|
||||||
|
} else {
|
||||||
|
await vm.shell(session.vm_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── sandlot stop <branch> ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("stop")
|
||||||
|
.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);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── sandlot rm <branch> ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("rm")
|
||||||
|
.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);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
console.error(`No session found for branch "${branch}".`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
console.log(`Deleted local branch ${branch}`);
|
||||||
|
|
||||||
|
await state.removeSession(root, branch);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 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();
|
||||||
35
src/config.ts
Normal file
35
src/config.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
export interface VmConfig {
|
||||||
|
cpus?: number;
|
||||||
|
memory?: string;
|
||||||
|
image?: string;
|
||||||
|
mounts?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SandlotConfig> {
|
||||||
|
const path = join(repoRoot, "sandlot.json");
|
||||||
|
const file = Bun.file(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
const userConfig = await file.json();
|
||||||
|
return {
|
||||||
|
vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm },
|
||||||
|
ai: { ...DEFAULT_CONFIG.ai, ...userConfig.ai },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return DEFAULT_CONFIG;
|
||||||
|
}
|
||||||
121
src/git.ts
Normal file
121
src/git.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
|
||||||
|
if (exists === "local") {
|
||||||
|
await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd);
|
||||||
|
} else if (exists === "remote") {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a worktree. */
|
||||||
|
export async function removeWorktree(worktreePath: string, cwd: string): Promise<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a remote 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> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
50
src/state.ts
Normal file
50
src/state.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
branch: string;
|
||||||
|
worktree: string;
|
||||||
|
vm_id: string;
|
||||||
|
created_at: string;
|
||||||
|
status: "running" | "stopped";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
sessions: Record<string, Session>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statePath(repoRoot: string): string {
|
||||||
|
return join(repoRoot, ".sandlot", "state.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function load(repoRoot: string): Promise<State> {
|
||||||
|
const path = statePath(repoRoot);
|
||||||
|
const file = Bun.file(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
return await file.json();
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
|
||||||
|
const state = await load(repoRoot);
|
||||||
|
delete state.sessions[branch];
|
||||||
|
await save(repoRoot, state);
|
||||||
|
}
|
||||||
86
src/vm.ts
Normal file
86
src/vm.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
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(
|
||||||
|
name: string,
|
||||||
|
worktreePath: string,
|
||||||
|
config?: VmConfig
|
||||||
|
): Promise<string> {
|
||||||
|
const args: string[] = ["container", "run", "--name", name];
|
||||||
|
|
||||||
|
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`);
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = config?.image ?? "ubuntu:24.04";
|
||||||
|
args.push("-d", image);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a container. */
|
||||||
|
export async function rm(vmId: string): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a container is running. Returns "running", "stopped", or "missing". */
|
||||||
|
export async function status(vmId: string): Promise<"running" | "stopped" | "missing"> {
|
||||||
|
const result = await $`container inspect ${vmId} --format '{{.State.Status}}'`
|
||||||
|
.nothrow()
|
||||||
|
.quiet()
|
||||||
|
.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";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exec into a container shell interactively. */
|
||||||
|
export async function shell(vmId: string): Promise<void> {
|
||||||
|
const proc = Bun.spawn(["container", "exec", "-it", vmId, "/bin/bash"], {
|
||||||
|
stdin: "inherit",
|
||||||
|
stdout: "inherit",
|
||||||
|
stderr: "inherit",
|
||||||
|
});
|
||||||
|
await proc.exited;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all containers with their names and statuses. */
|
||||||
|
export async function list(): Promise<Array<{ id: string; name: string; status: string }>> {
|
||||||
|
const result = await $`container ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}'`
|
||||||
|
.nothrow()
|
||||||
|
.quiet()
|
||||||
|
.text();
|
||||||
|
|
||||||
|
return result
|
||||||
|
.trim()
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const [id, name, status] = line.replace(/'/g, "").split("\t");
|
||||||
|
return { id, name, status };
|
||||||
|
});
|
||||||
|
}
|
||||||
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"types": ["bun-types"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user