Compare commits
1 Commits
main
...
rust-rewri
| Author | SHA1 | Date | |
|---|---|---|---|
| 92b64fcf3c |
38
CLAUDE.md
38
CLAUDE.md
|
|
@ -69,7 +69,7 @@ Each module has a single responsibility. No classes — only exported functions
|
||||||
2. Creates symlink `<repo-root>/.sandlot/<branch>` → worktree path
|
2. Creates symlink `<repo-root>/.sandlot/<branch>` → worktree path
|
||||||
3. `vm.ensure()` → start/create/provision the container
|
3. `vm.ensure()` → start/create/provision the container
|
||||||
4. `state.setSession()` → write to `.sandlot/state.json`
|
4. `state.setSession()` → write to `.sandlot/state.json`
|
||||||
5. `vm.pi()` → launch Pi in container at worktree path
|
5. `vm.claude()` → launch Claude Code in container at worktree path
|
||||||
6. `saveChanges()` → auto-save on exit (stage all, AI-generated commit message) unless `--no-save`
|
6. `saveChanges()` → auto-save on exit (stage all, AI-generated commit message) unless `--no-save`
|
||||||
|
|
||||||
**Worktree location**: `~/.sandlot/<repo-name>/<branch>/` (outside the repo)
|
**Worktree location**: `~/.sandlot/<repo-name>/<branch>/` (outside the repo)
|
||||||
|
|
@ -85,13 +85,11 @@ Each module has a single responsibility. No classes — only exported functions
|
||||||
- Mounts: `~/dev` **read-only** at `/host`, `~/.sandlot` read-write at `/sandlot`
|
- Mounts: `~/dev` **read-only** at `/host`, `~/.sandlot` read-write at `/sandlot`
|
||||||
- Host symlinks: creates `~/dev` → `/host` and `~/.sandlot` → `/sandlot` inside the container so host-absolute worktree paths resolve correctly
|
- Host symlinks: creates `~/dev` → `/host` and `~/.sandlot` → `/sandlot` inside the container so host-absolute worktree paths resolve correctly
|
||||||
- `containerPath()` in `vm.ts` translates host paths to container paths (`~/.sandlot/…` → `/sandlot/…`, `~/dev/…` → `/host/…`)
|
- `containerPath()` in `vm.ts` translates host paths to container paths (`~/.sandlot/…` → `/sandlot/…`, `~/dev/…` → `/host/…`)
|
||||||
- Provisioned once on first use: apt installs `curl git fish unzip`, then installs Bun, neofetch, and Neovim
|
- Provisioned once on first use: apt installs `curl git fish unzip`, then installs Bun, Claude Code, neofetch, and Neovim
|
||||||
- Binary caching: after first install, bun/neofetch/nvim are cached in `~/.sandlot/.cache/` — subsequent `vm create` uses cached copies, skipping downloads
|
- Binary caching: after first install, binaries are cached in `~/.sandlot/.cache/` (bun, claude, neofetch, nvim.tar.gz) — subsequent `vm create` uses cached copies, skipping downloads
|
||||||
- Persistent tooling: Pi, Rust, Go, and sccache are installed to `/sandlot/` paths that survive container recreates
|
- API key: read from `~/.env` on host (`ANTHROPIC_API_KEY=...`), written to a temp file and copied as `~/.claude/api-key-helper.sh` in the container (never passed as a process argument)
|
||||||
- Pi is installed as a standalone binary from GitHub releases (`pi-linux-arm64.tar.gz`) to `/sandlot/.pi-bin/`
|
- Claude settings: `skipDangerousModePermissionPrompt: true`, activity tracking hooks (`UserPromptSubmit` / `Stop`) in container
|
||||||
- API key: read from `~/.env` on host (`ANTHROPIC_API_KEY=...`), written to `~/.pi/agent/auth.json` in the container via temp file (never passed as a process argument)
|
- Also writes `~/.claude.json` with `hasCompletedOnboarding: true` and `effortCalloutDismissed: true`
|
||||||
- Pi settings: `~/.pi/agent/settings.json` with `defaultProvider: anthropic`, `defaultModel: claude-opus-4-6`, `defaultThinkingLevel: high`, `quietStartup: true`
|
|
||||||
- Activity tracking: a Pi extension at `~/.pi/agent/extensions/sandlot-activity.ts` writes activity markers on `before_agent_start` and `tool_call` events
|
|
||||||
|
|
||||||
## Shell Command Pattern
|
## Shell Command Pattern
|
||||||
|
|
||||||
|
|
@ -140,21 +138,23 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
|
||||||
|
|
||||||
## Key Implementation Notes
|
## Key Implementation Notes
|
||||||
|
|
||||||
- `vm.pi()` uses `Bun.spawn` with `stdin/stdout/stderr: "inherit"` for interactive TTY; in print mode (`-p`), captures stdout via pipe and returns the output
|
- `vm.exec()` prepends `export PATH=$HOME/.local/bin:$PATH` so `claude` binary is found
|
||||||
- `vm.pi()` runs Pi with `--append-system-prompt` (system prompt describes the container environment); model and thinking level are configured via `~/.pi/agent/settings.json`
|
- `vm.claude()` uses `Bun.spawn` with `stdin/stdout/stderr: "inherit"` for interactive TTY; in print mode (`-p`), captures stdout via pipe and returns the output
|
||||||
- `vm.pi()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue`
|
- `vm.claude()` runs Claude with `--dangerously-skip-permissions`, `--model claude-opus-4-6`, and `--append-system-prompt` (system prompt describes the container environment)
|
||||||
- `vm.piPipe()` writes input to a temp file in `~/.sandlot/`, pipes it to `pi -p` inside the container, and returns the result — used for commit message generation and conflict resolution
|
- `vm.claude()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue`
|
||||||
- `vm.isPiActive()` reads activity marker files written by the in-container `sandlot-activity.ts` Pi extension
|
- `vm.claudePipe()` writes input to a temp file in `~/.sandlot/`, pipes it to `claude -p` inside the container, and returns the result — used for commit message generation and conflict resolution
|
||||||
|
- `vm.isClaudeActive()` reads activity marker files written by the in-container `sandlot-activity` hook script
|
||||||
- Branch creation in `createWorktree()` handles three cases: local branch, remote branch (tracks origin), new branch from HEAD
|
- Branch creation in `createWorktree()` handles three cases: local branch, remote branch (tracks origin), new branch from HEAD
|
||||||
- `sandlot new` accepts a prompt instead of a branch name — derives a 2-word branch name via the Anthropic API (Haiku), falling back to simple text munging
|
- `sandlot new` accepts a prompt instead of a branch name — derives a 2-word branch name via the Anthropic API (Haiku), falling back to simple text munging
|
||||||
- `sandlot open` always passes `continue: true` to `vm.pi()` to resume the previous conversation
|
- `sandlot open` always passes `continue: true` to `vm.claude()` to resume the previous conversation
|
||||||
- `sandlot save` uses `vm.piPipe()` to generate commit messages from the staged diff
|
- `sandlot save` uses `vm.claudePipe()` to generate commit messages from the staged diff
|
||||||
- `sandlot merge` and `sandlot rebase` use `vm.piPipe()` to resolve merge conflicts automatically
|
- `sandlot merge` and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically
|
||||||
- `sandlot squash` collapses all branch commits into a single commit in-place via `git reset --soft` to the merge base, then generates an AI commit message via `piPipe()`; falls back to `"squash <branch>"`. Rolls back to the original HEAD on failure.
|
- `sandlot squash` collapses all branch commits into a single commit in-place via `git reset --soft` to the merge base, then generates an AI commit message via `claudePipe()`; falls back to `"squash <branch>"`. Rolls back to the original HEAD on failure.
|
||||||
- `sandlot merge` delegates to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up
|
- `sandlot merge` delegates to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up
|
||||||
|
- `sandlot list` discovers missing session prompts by parsing Claude's `history.jsonl` from inside the container
|
||||||
- `sandlot list` shows five status icons: idle (dim `◯`), active (cyan `◎`), dirty/unsaved (yellow `◐`), saved (green `●`), review (magenta `⦿`)
|
- `sandlot list` shows five status icons: idle (dim `◯`), active (cyan `◎`), dirty/unsaved (yellow `◐`), saved (green `●`), review (magenta `⦿`)
|
||||||
- `sandlot review` sets `in_review` on the session during the review and clears it in a `finally` block on exit; `list` detects stale `in_review` flags (Pi not active) and clears them from state
|
- `sandlot review` sets `in_review` on the session during the review and clears it in a `finally` block on exit; `list` detects stale `in_review` flags (Claude not active) and clears them from state
|
||||||
- `sandlot new` and `sandlot open` auto-save changes when Pi exits (disable with `--no-save`)
|
- `sandlot new` and `sandlot open` auto-save changes when Claude exits (disable with `--no-save`)
|
||||||
- `sandlot close` has a hidden `rm` alias
|
- `sandlot close` has a hidden `rm` alias
|
||||||
- Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty)
|
- Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty)
|
||||||
- `.sandlot/` should be in the repo's `.gitignore`
|
- `.sandlot/` should be in the repo's `.gitignore`
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -59,17 +59,3 @@ Config is stored in `~/.config/sandlot/config.json`.
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|-----|---------|-------------|
|
|-----|---------|-------------|
|
||||||
| `memory` | `16G` | Container memory limit (e.g. 4G, 16G, 32G, 64G) |
|
| `memory` | `16G` | Container memory limit (e.g. 4G, 16G, 32G, 64G) |
|
||||||
| `hosts` | `[]` | Hostnames to resolve on the host and inject into the container |
|
|
||||||
|
|
||||||
#### Local network hosts
|
|
||||||
|
|
||||||
The container can't resolve `.local` (mDNS) hostnames natively. To make local network hosts reachable from inside the container, add them to the `hosts` config:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sandlot config hosts add claude.toes.local
|
|
||||||
sandlot config hosts add myserver.local
|
|
||||||
sandlot config hosts rm myserver.local
|
|
||||||
sandlot config hosts # list configured hosts
|
|
||||||
```
|
|
||||||
|
|
||||||
Hostnames are resolved on the Mac via mDNS and written to the container's `/etc/hosts` every time the VM starts.
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@because/sandlot",
|
"name": "@because/sandlot",
|
||||||
"version": "0.0.54",
|
"version": "0.0.50",
|
||||||
"description": "Sandboxed, branch-based development with Claude",
|
"description": "Sandboxed, branch-based development with Claude",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
1
rust-sandlot/.gitignore
vendored
Normal file
1
rust-sandlot/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
target/
|
||||||
1872
rust-sandlot/Cargo.lock
generated
Normal file
1872
rust-sandlot/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
rust-sandlot/Cargo.toml
Normal file
24
rust-sandlot/Cargo.toml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
[package]
|
||||||
|
name = "sandlot"
|
||||||
|
version = "0.0.50"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Sandboxed, branch-based development with Claude"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "sandlot"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive", "string"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
rand = "0.9"
|
||||||
|
dirs = "6"
|
||||||
|
regex = "1"
|
||||||
|
which = "7"
|
||||||
|
libc = "0.2"
|
||||||
|
anyhow = "1"
|
||||||
895
rust-sandlot/TESTING.md
Normal file
895
rust-sandlot/TESTING.md
Normal file
|
|
@ -0,0 +1,895 @@
|
||||||
|
# Sandlot Rust Rewrite: VM Integration Testing
|
||||||
|
|
||||||
|
This document describes how to test the Rust rewrite of sandlot against the TypeScript original. The goal is to verify **identical behavior** for every command that interacts with the VM/container, git worktrees, or session state.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- macOS on Apple Silicon
|
||||||
|
- Apple Container installed (`brew install container`)
|
||||||
|
- Rust toolchain (`rustup`)
|
||||||
|
- Bun installed (`brew install oven-sh/bun/bun`)
|
||||||
|
- An `ANTHROPIC_API_KEY` in `~/.env` (format: `ANTHROPIC_API_KEY=sk-ant-...`)
|
||||||
|
- A git repo to use as a test bed (create a throwaway one)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Build the Rust binary
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rust-sandlot
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is at `./rust-sandlot/target/release/sandlot`.
|
||||||
|
|
||||||
|
### 2. Set up aliases
|
||||||
|
|
||||||
|
Use two distinct aliases so you can run either implementation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alias sandlot-ts='bun run /path/to/rust-rewrite/src/cli.ts'
|
||||||
|
alias sandlot-rs='/path/to/rust-rewrite/rust-sandlot/target/release/sandlot'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Destroy any existing VM
|
||||||
|
|
||||||
|
Start from a clean slate. Both implementations share the same container name (`sandlot`), so only one can be tested at a time:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-ts vm destroy 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create a test repo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir /tmp/sandlot-test-repo && cd /tmp/sandlot-test-repo
|
||||||
|
git init
|
||||||
|
echo "hello" > README.md
|
||||||
|
git add . && git commit -m "initial commit"
|
||||||
|
```
|
||||||
|
|
||||||
|
All tests below assume you run commands from inside this repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing methodology
|
||||||
|
|
||||||
|
For each test:
|
||||||
|
|
||||||
|
1. Run the command with `sandlot-ts` first, observe the result
|
||||||
|
2. Clean up / reset state
|
||||||
|
3. Run the same command with `sandlot-rs`, observe the result
|
||||||
|
4. Compare: stdout content, stderr content, exit code, and side effects (files created, git state, container state)
|
||||||
|
|
||||||
|
Some commands produce animated spinner output on stderr. The final line of spinner output is what matters (the success/failure message). Intermediate spinner frames are cosmetic and may differ in timing.
|
||||||
|
|
||||||
|
When comparing output, strip ANSI codes for semantic comparison:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs list 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
|
||||||
|
sandlot-ts list 2>&1 | sed 's/\x1b\[[0-9;]*m//g'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: VM Lifecycle
|
||||||
|
|
||||||
|
These tests verify container management. Run them in order.
|
||||||
|
|
||||||
|
### Test 1.1: `vm create`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-ts vm destroy 2>/dev/null # clean slate
|
||||||
|
sandlot-rs vm create
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Spinner output on stderr progressing through: "Creating VM" -> "Pulling image & creating container" -> "Installing packages" -> "Installing Bun" -> "Installing Claude Code" -> "Installing neofetch" -> "Installing Neovim" -> "Configuring environment"
|
||||||
|
- Final line: `✔ VM created`
|
||||||
|
- Exit code: 0
|
||||||
|
|
||||||
|
**Verify side effects:**
|
||||||
|
```bash
|
||||||
|
container list --format json --all # should show "sandlot" container running
|
||||||
|
container exec sandlot which claude # should print /home/ubuntu/.local/bin/claude
|
||||||
|
container exec sandlot which bun # should print /home/ubuntu/.local/bin/bun
|
||||||
|
container exec sandlot which fish # should print /usr/bin/fish
|
||||||
|
container exec sandlot test -f /home/ubuntu/.claude/settings.json && echo ok
|
||||||
|
container exec sandlot test -f /home/ubuntu/.claude/api-key-helper.sh && echo ok
|
||||||
|
container exec sandlot cat /home/ubuntu/.claude.json # should have hasCompletedOnboarding: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Now destroy and repeat with TS:
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm destroy
|
||||||
|
sandlot-ts vm create
|
||||||
|
```
|
||||||
|
Verify the same side effects exist.
|
||||||
|
|
||||||
|
### Test 1.2: `vm status`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With VM running:
|
||||||
|
sandlot-rs vm status
|
||||||
|
sandlot-ts vm status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect (no sessions):**
|
||||||
|
```
|
||||||
|
VM: running (in green)
|
||||||
|
|
||||||
|
No active sessions. (in dim)
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JSON mode:
|
||||||
|
sandlot-rs vm status --json
|
||||||
|
sandlot-ts vm status --json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** JSON with `"vm": "running"` and `"sessions": []`.
|
||||||
|
|
||||||
|
### Test 1.3: `vm stop`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm stop
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Spinner, then `✔ VM stopped`. Exit code 0.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `VM: stopped` (in yellow).
|
||||||
|
|
||||||
|
### Test 1.4: `vm start`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✔ VM started` on stdout. Exit code 0.
|
||||||
|
|
||||||
|
### Test 1.5: `vm info`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm info
|
||||||
|
sandlot-ts vm info
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** neofetch output (system info). Both should show identical container specs.
|
||||||
|
|
||||||
|
### Test 1.6: `vm shell`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm shell
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Drops into an interactive fish shell inside the container. Type `exit` to leave. Verify the prompt works and `echo $PATH` includes the expected paths.
|
||||||
|
|
||||||
|
### Test 1.7: `vm destroy`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm destroy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Spinner, then `✔ VM destroyed`. Exit code 0.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `VM: missing` (in red).
|
||||||
|
|
||||||
|
### Test 1.8: `vm create` (duplicate)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm create
|
||||||
|
# Then try again:
|
||||||
|
sandlot-rs vm create
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect second call:** Error: `Container already exists. Use 'sandlot vm destroy' first to recreate it.` Exit code 1.
|
||||||
|
|
||||||
|
### Test 1.9: `vm uncache`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm uncache
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✔ Package cache cleared` if cache existed, or `No cache to clear`.
|
||||||
|
|
||||||
|
### Test 1.10: `vm start` when missing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm destroy
|
||||||
|
sandlot-rs vm start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Error: `Container does not exist. Use 'sandlot vm create' first.` Exit code 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Session Lifecycle
|
||||||
|
|
||||||
|
Ensure a VM is running before starting: `sandlot-rs vm create` (or `ensure` will auto-create).
|
||||||
|
|
||||||
|
### Test 2.1: `new` with explicit branch name
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs new test-branch-1
|
||||||
|
# Claude launches interactively. Press Ctrl+C or /exit to quit.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Spinner: "Creating worktree" -> "Starting container" -> `✔ [test-branch-1] Session ready`
|
||||||
|
- Claude Code launches in the container
|
||||||
|
- After exit, auto-save runs (spinner: "Staging changes" -> either "No changes to commit" or "Saved: ...")
|
||||||
|
|
||||||
|
**Verify side effects:**
|
||||||
|
```bash
|
||||||
|
ls -la ~/.sandlot/sandlot-test-repo/test-branch-1/ # worktree exists
|
||||||
|
ls -la .sandlot/test-branch-1 # symlink exists
|
||||||
|
cat .sandlot/state.json # session entry exists
|
||||||
|
git worktree list # shows the worktree
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2.2: `new` with no branch (random name)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs new
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** A random `adjective-noun` branch name is generated (e.g., `calm-fern`). The rest of the flow is identical to 2.1.
|
||||||
|
|
||||||
|
### Test 2.3: `new` with prompt (spaces in "branch")
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs new "fix the login bug on the settings page"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** The text is treated as a prompt. A branch name is derived via Claude Haiku API (e.g., `login-fix`). If the API call fails, falls back to first two words (`fix-the`). The prompt is stored in `state.json`.
|
||||||
|
|
||||||
|
### Test 2.4: `new` with `-p` (print mode)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs new -p "what is 2+2"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Branch name derived from the prompt
|
||||||
|
- Spinner: "Creating worktree" -> "Starting container" -> "Running prompt..."
|
||||||
|
- Claude's response printed to stdout (rendered as markdown)
|
||||||
|
- No interactive session
|
||||||
|
- Auto-save runs after
|
||||||
|
|
||||||
|
### Test 2.5: `new` duplicate session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs new test-branch-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ Session "test-branch-1" already exists. Use "sandlot open test-branch-1" to re-enter it.` Exit code 1.
|
||||||
|
|
||||||
|
### Test 2.6: `list` with sessions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
```
|
||||||
|
BRANCH PROMPT
|
||||||
|
◯ test-branch-1
|
||||||
|
◯ other-branch fix the login bug...
|
||||||
|
|
||||||
|
◯ idle · ◎ active · ◐ unsaved · ● saved · ⦿ review
|
||||||
|
```
|
||||||
|
|
||||||
|
Status icons use ANSI colors (dim for idle, cyan for active, yellow for dirty, green for saved, magenta for review).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** JSON array with each session having `branch`, `worktree`, `created_at`, `prompt`, `in_review`, `status`, `repoRoot` fields.
|
||||||
|
|
||||||
|
### Test 2.7: `open` existing session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs open test-branch-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Spinner: "Starting container" -> `✔ [test-branch-1] Session ready`
|
||||||
|
- Claude launches with `--continue` (resumes prior conversation)
|
||||||
|
- After exit, auto-save runs
|
||||||
|
|
||||||
|
### Test 2.8: `open` with `--no-save`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs open test-branch-1 --no-save
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Same as 2.7 but no auto-save after Claude exits.
|
||||||
|
|
||||||
|
### Test 2.9: `open` nonexistent session but existing branch
|
||||||
|
|
||||||
|
If you manually create a branch and remove the session from state.json, `open` should recreate the session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove from state but keep the branch
|
||||||
|
cat .sandlot/state.json # note the session
|
||||||
|
# Manually edit state.json to remove the session entry
|
||||||
|
sandlot-rs open test-branch-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Worktree is recreated, session is re-added to state, Claude launches.
|
||||||
|
|
||||||
|
### Test 2.10: `open` nonexistent branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs open nonexistent-branch-xyz
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ No session or branch found for "nonexistent-branch-xyz".` Exit code 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Branch Operations (read-only)
|
||||||
|
|
||||||
|
These commands read git state without modifying it. Create a session with some commits first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs new branch-ops-test
|
||||||
|
# Inside Claude, make some changes and commit, then exit
|
||||||
|
# Or manually:
|
||||||
|
cd ~/.sandlot/sandlot-test-repo/branch-ops-test
|
||||||
|
echo "new file" > test.txt
|
||||||
|
git add . && git commit -m "add test file"
|
||||||
|
cd /tmp/sandlot-test-repo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3.1: `diff`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs diff branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- If uncommitted changes in worktree: shows `git diff HEAD`
|
||||||
|
- If clean: shows `git diff main...branch-ops-test`
|
||||||
|
- Output piped through git's native diff display (with colors if terminal supports)
|
||||||
|
|
||||||
|
Compare with:
|
||||||
|
```bash
|
||||||
|
sandlot-ts diff branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3.2: `log`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs log branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- If the session has a prompt, prints `PROMPT: <text>` to stderr first
|
||||||
|
- Shows `git log main..HEAD` output with commit hashes highlighted in yellow
|
||||||
|
- Piped through pager if output exceeds terminal height
|
||||||
|
|
||||||
|
### Test 3.3: `show`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs show branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Prints prompt to stderr (if stored)
|
||||||
|
- Shows full `git diff main...branch` output on stdout
|
||||||
|
|
||||||
|
### Test 3.4: `web`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs web branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Generates `/tmp/sandlot-branch-ops-test.html`
|
||||||
|
- Opens it in the default browser
|
||||||
|
- HTML contains: branch name, prompt, commit log, diff stats, syntax-highlighted diff
|
||||||
|
|
||||||
|
**Verify:** Open the generated HTML file and compare it with the one generated by `sandlot-ts web branch-ops-test`.
|
||||||
|
|
||||||
|
### Test 3.5: `dir`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs dir branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Prints the absolute worktree path to stdout, e.g., `/Users/you/.sandlot/sandlot-test-repo/branch-ops-test`.
|
||||||
|
|
||||||
|
### Test 3.6: `dir` nonexistent session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs dir nonexistent
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ No session found for branch "nonexistent".` Exit code 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Save, Merge, Squash, Rebase
|
||||||
|
|
||||||
|
### Test 4.1: `save` with auto-generated message
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make changes in the worktree first:
|
||||||
|
echo "change" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt
|
||||||
|
sandlot-rs save branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Spinner: `[branch-ops-test] Staging changes` -> `Starting container` -> `Generating commit message` -> `Committing` -> `✔ [branch-ops-test] Saved: <commit message>`
|
||||||
|
- The commit message is AI-generated from the diff
|
||||||
|
|
||||||
|
### Test 4.2: `save` with explicit message
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "another change" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt
|
||||||
|
sandlot-rs save branch-ops-test "manual commit message"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Spinner: `Staging changes` -> `Committing` -> `✔ [branch-ops-test] Saved: manual commit message`
|
||||||
|
- No AI generation (no container startup needed for the message)
|
||||||
|
|
||||||
|
### Test 4.3: `save` with no changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs save branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ [branch-ops-test] No changes to commit`. Exit code 1.
|
||||||
|
|
||||||
|
### Test 4.4: `squash`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure branch has multiple commits beyond main
|
||||||
|
sandlot-rs squash branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Spinner: `[branch-ops-test] Squashing` -> `Starting container` -> `Generating commit message` -> `✔ [branch-ops-test] Squashed branch-ops-test into a single commit`
|
||||||
|
- `git log main..HEAD` in the worktree should show exactly 1 commit
|
||||||
|
|
||||||
|
### Test 4.5: `squash` with no commits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs new fresh-branch
|
||||||
|
# Exit Claude immediately without making changes
|
||||||
|
sandlot-rs squash fresh-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ Branch "fresh-branch" has no commits beyond main.` Exit code 1.
|
||||||
|
|
||||||
|
### Test 4.6: `squash` with dirty worktree
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "dirty" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt
|
||||||
|
sandlot-rs squash branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ Branch "branch-ops-test" has unsaved changes. Run "sandlot save branch-ops-test" first.` Exit code 1.
|
||||||
|
|
||||||
|
### Test 4.7: `rebase`
|
||||||
|
|
||||||
|
Set up a scenario where main has advanced:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In the main repo, add a commit to main
|
||||||
|
cd /tmp/sandlot-test-repo
|
||||||
|
echo "main change" > main-file.txt
|
||||||
|
git add . && git commit -m "advance main"
|
||||||
|
|
||||||
|
sandlot-rs rebase branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect (clean rebase):**
|
||||||
|
- Spinner: `[branch-ops-test] Fetching origin` -> `Rebasing onto origin/main` -> `✔ [branch-ops-test] Rebased branch-ops-test onto main`
|
||||||
|
|
||||||
|
**Expect (with conflicts):**
|
||||||
|
- `◆ Rebase conflicts in N file(s). Resolving with Claude...`
|
||||||
|
- Spinner: `[branch-ops-test] Starting container` -> `(1/N) Resolving <file> (round 1)` -> `✔ [branch-ops-test] Rebased branch-ops-test onto main (resolved N conflict round(s))`
|
||||||
|
|
||||||
|
### Test 4.8: `rebase` with dirty worktree
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "dirty" >> ~/.sandlot/sandlot-test-repo/branch-ops-test/test.txt
|
||||||
|
sandlot-rs rebase branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ Branch "branch-ops-test" has unsaved changes. Run "sandlot save branch-ops-test" first.` Exit code 1.
|
||||||
|
|
||||||
|
### Test 4.9: `merge`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/sandlot-test-repo
|
||||||
|
git checkout main
|
||||||
|
sandlot-rs merge branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect (clean merge):**
|
||||||
|
- Spinner: `Merging branch-ops-test` -> `✔ Merged branch-ops-test into main`
|
||||||
|
- Session is torn down (worktree removed, symlink removed, state cleared)
|
||||||
|
- Local branch is deleted
|
||||||
|
|
||||||
|
**Expect (with conflicts):**
|
||||||
|
- Spinner: `Resolving N conflict(s)` -> `Starting container` -> `(1/N) Resolving <file>` -> `✔ Resolved N conflict(s) and merged branch-ops-test`
|
||||||
|
- Same cleanup as clean merge
|
||||||
|
|
||||||
|
### Test 4.10: `merge` not on main
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b other-branch
|
||||||
|
sandlot-rs merge some-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ You must be on "main" to merge. Currently on "other-branch". Use --force to merge into "other-branch" anyway.` Exit code 1.
|
||||||
|
|
||||||
|
### Test 4.11: `merge --force` on non-main
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs merge some-branch --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Merge proceeds into `other-branch` instead of `main`.
|
||||||
|
|
||||||
|
### Test 4.12: `merge` with dirty session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "dirty" >> ~/.sandlot/sandlot-test-repo/some-branch/file.txt
|
||||||
|
sandlot-rs merge some-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ Branch "some-branch" has unsaved changes. Run "sandlot save some-branch" first.` Exit code 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Review
|
||||||
|
|
||||||
|
### Test 5.1: `review` interactive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs review branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Spinner: `[branch-ops-test] Starting container` -> `✔ [branch-ops-test] Session ready`
|
||||||
|
- Claude launches with the review prompt (4-agent grumpy senior engineer review)
|
||||||
|
- `state.json` shows `in_review: true` during the review
|
||||||
|
- After exit: `in_review` is cleared, auto-save runs
|
||||||
|
|
||||||
|
### Test 5.2: `review --print`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs review branch-ops-test --print
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- Spinner: `[branch-ops-test] Starting container` -> `Running review...`
|
||||||
|
- Review output printed to stdout (not interactive)
|
||||||
|
- No auto-save after
|
||||||
|
|
||||||
|
### Test 5.3: `review` with extra prompt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs review branch-ops-test "also check for SQL injection"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** The extra text is appended to the review prompt. Claude receives both the standard review instructions and the additional context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Shell and Edit
|
||||||
|
|
||||||
|
### Test 6.1: `shell` with branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs shell branch-ops-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Interactive fish shell opens in the worktree directory inside the container. `pwd` should show the container-translated worktree path.
|
||||||
|
|
||||||
|
### Test 6.2: `shell` without branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs shell
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Interactive fish shell opens at a default location (no `--workdir` flag).
|
||||||
|
|
||||||
|
### Test 6.3: `edit`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export EDITOR=vim
|
||||||
|
sandlot-rs edit branch-ops-test test.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** vim opens the file at the worktree path. After closing, exits cleanly.
|
||||||
|
|
||||||
|
### Test 6.4: `edit` with missing EDITOR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unset EDITOR
|
||||||
|
sandlot-rs edit branch-ops-test test.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ $EDITOR is not set.` Exit code 1.
|
||||||
|
|
||||||
|
### Test 6.5: `edit` with missing file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export EDITOR=vim
|
||||||
|
sandlot-rs edit branch-ops-test nonexistent.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ File not found: nonexistent.txt` Exit code 1.
|
||||||
|
|
||||||
|
### Test 6.6: `edit` path escape attempt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs edit branch-ops-test ../../etc/passwd
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Error (path escapes the worktree). The exact message may vary but should prevent access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Close and Checkout
|
||||||
|
|
||||||
|
### Test 7.1: `close` clean session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs close test-branch-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- `✔ Closed session test-branch-1` on stdout
|
||||||
|
- Worktree removed from `~/.sandlot/...`
|
||||||
|
- Symlink removed from `.sandlot/test-branch-1`
|
||||||
|
- Session removed from `state.json`
|
||||||
|
- Local branch deleted
|
||||||
|
- Exit code 0
|
||||||
|
|
||||||
|
### Test 7.2: `close` dirty session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set up a dirty session first
|
||||||
|
sandlot-rs new dirty-test
|
||||||
|
echo "uncommitted" > ~/.sandlot/sandlot-test-repo/dirty-test/uncommitted.txt
|
||||||
|
sandlot-rs close dirty-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ Branch "dirty-test" has unsaved changes. Run "sandlot save dirty-test" first, or use -f to force.` Exit code 1.
|
||||||
|
|
||||||
|
### Test 7.3: `close --force` dirty session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs close dirty-test --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✔ Closed session dirty-test`. Session is torn down despite uncommitted changes.
|
||||||
|
|
||||||
|
### Test 7.4: `rm` alias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs rm some-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Identical to `close`. The `rm` command is a hidden alias.
|
||||||
|
|
||||||
|
### Test 7.5: `close` nonexistent session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs close nonexistent-xyz
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ No session found for branch "nonexistent-xyz".` Exit code 1.
|
||||||
|
|
||||||
|
### Test 7.6: `checkout`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs new checkout-test
|
||||||
|
# Make a commit
|
||||||
|
echo "data" > ~/.sandlot/sandlot-test-repo/checkout-test/data.txt
|
||||||
|
cd ~/.sandlot/sandlot-test-repo/checkout-test && git add . && git commit -m "data"
|
||||||
|
cd /tmp/sandlot-test-repo
|
||||||
|
sandlot-rs checkout checkout-test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:**
|
||||||
|
- `✔ Checked out checkout-test`
|
||||||
|
- Session torn down (worktree, symlink, state removed)
|
||||||
|
- `git branch` in main repo shows you're now on `checkout-test`
|
||||||
|
- Branch is NOT deleted (unlike `close` and `merge`)
|
||||||
|
|
||||||
|
### Test 7.7: `checkout` with dirty main worktree
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "dirty" > /tmp/sandlot-test-repo/dirty.txt
|
||||||
|
sandlot-rs checkout some-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✖ Working tree has uncommitted changes that may conflict with checkout. Commit or stash them first, or use -f to force.` Exit code 1.
|
||||||
|
|
||||||
|
### Test 7.8: `checkout --force` with dirty main worktree
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs checkout some-branch --force
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Proceeds despite dirty working tree.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Cleanup and Upgrade
|
||||||
|
|
||||||
|
### Test 8.1: `cleanup` with stale sessions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a session, then manually delete the worktree
|
||||||
|
sandlot-rs new stale-test
|
||||||
|
rm -rf ~/.sandlot/sandlot-test-repo/stale-test
|
||||||
|
sandlot-rs cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `✔ Removed stale session: stale-test`. Session removed from state.json.
|
||||||
|
|
||||||
|
### Test 8.2: `cleanup` with no stale sessions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs cleanup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `No stale sessions found.` (or `No sessions to clean up.` if no sessions at all).
|
||||||
|
|
||||||
|
### Test 8.3: `upgrade`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Attempts to upgrade sandlot. Compare behavior with `sandlot-ts upgrade`. Both should attempt the same upgrade mechanism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: List Status Resolution
|
||||||
|
|
||||||
|
This tests that `list` correctly resolves session status.
|
||||||
|
|
||||||
|
### Test 9.1: Idle session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs new idle-test
|
||||||
|
# Exit Claude immediately, no changes
|
||||||
|
sandlot-rs list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `idle-test` shows `◯` (dim circle) = idle.
|
||||||
|
|
||||||
|
### Test 9.2: Dirty session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "dirty" > ~/.sandlot/sandlot-test-repo/idle-test/dirty.txt
|
||||||
|
sandlot-rs list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `idle-test` shows `◐` (yellow half-circle) = unsaved.
|
||||||
|
|
||||||
|
### Test 9.3: Saved session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/.sandlot/sandlot-test-repo/idle-test
|
||||||
|
git add . && git commit -m "save"
|
||||||
|
cd /tmp/sandlot-test-repo
|
||||||
|
sandlot-rs list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `idle-test` shows `●` (green circle) = saved.
|
||||||
|
|
||||||
|
### Test 9.4: `list --all`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs list --all
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Sessions grouped by repo name with headers:
|
||||||
|
```
|
||||||
|
── repo-name ──
|
||||||
|
BRANCH PROMPT
|
||||||
|
◯ branch prompt text
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 9.5: `list` with no sessions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Close all sessions first
|
||||||
|
sandlot-rs list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** `◆ No active sessions.`
|
||||||
|
|
||||||
|
### Test 9.6: `list` with VM down
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sandlot-rs vm stop
|
||||||
|
sandlot-rs list
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect:** Normal session list (all show as idle since VM can't check status), plus:
|
||||||
|
```
|
||||||
|
VM is not running. (in red)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: End-to-End Comparison
|
||||||
|
|
||||||
|
For each command tested above, run the same scenario with both `sandlot-ts` and `sandlot-rs` and compare:
|
||||||
|
|
||||||
|
1. **Exit codes** must be identical
|
||||||
|
2. **Stdout content** must be semantically identical (exact match after stripping ANSI if formatting differs)
|
||||||
|
3. **Stderr content** must match (error messages, spinner final lines)
|
||||||
|
4. **Side effects** must match:
|
||||||
|
- Same files created/deleted
|
||||||
|
- Same git state (branches, worktrees, commits)
|
||||||
|
- Same state.json content (modulo timestamps)
|
||||||
|
- Same container state
|
||||||
|
|
||||||
|
### Comparison script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Compare a command between TS and Rust
|
||||||
|
CMD="$@"
|
||||||
|
echo "=== TypeScript ==="
|
||||||
|
sandlot-ts $CMD 2>/tmp/ts-stderr; TS_EXIT=$?
|
||||||
|
echo "EXIT: $TS_EXIT"
|
||||||
|
cat /tmp/ts-stderr
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Rust ==="
|
||||||
|
sandlot-rs $CMD 2>/tmp/rs-stderr; RS_EXIT=$?
|
||||||
|
echo "EXIT: $RS_EXIT"
|
||||||
|
cat /tmp/rs-stderr
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ "$TS_EXIT" = "$RS_EXIT" ]; then
|
||||||
|
echo "EXIT CODES: MATCH ($TS_EXIT)"
|
||||||
|
else
|
||||||
|
echo "EXIT CODES: MISMATCH (ts=$TS_EXIT rs=$RS_EXIT)"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Differences to Accept
|
||||||
|
|
||||||
|
- **Timestamps** in `state.json` will differ between runs (different `created_at` values). Compare structure and non-timestamp fields only.
|
||||||
|
- **Spinner frame timing** may differ slightly. Only compare the final spinner message.
|
||||||
|
- **AI-generated content** (branch names from prompts, commit messages, conflict resolutions, reviews) will differ between runs since they involve LLM calls. Verify the format is correct, not the exact text.
|
||||||
|
- **Random branch names** from `sandlot new` (no args) will differ. Verify the format is `adjective-noun` from the same word lists.
|
||||||
|
- **Order of JSON object keys** may differ between serde_json (Rust) and JSON.stringify (TS). Compare semantically.
|
||||||
|
|
||||||
|
## What Must Be Identical
|
||||||
|
|
||||||
|
- All error messages (exact wording, Unicode markers)
|
||||||
|
- Exit codes for all error and success paths
|
||||||
|
- File paths (worktree locations, symlink targets, state file location)
|
||||||
|
- Git operations (same branches created/deleted, same merge behavior)
|
||||||
|
- Container commands (same `container exec` invocations, same environment variables)
|
||||||
|
- Flag parsing (`-f`, `--force`, `-p`, `--print`, `-n`, `--no-save`, `--json`, `-a`, `--all`)
|
||||||
|
- Default behavior (no args = `list`)
|
||||||
|
- Shell init output (`init fish`, `init bash`, `init zsh`) -- these were already verified byte-for-byte identical
|
||||||
|
- Fish/bash/zsh completions -- already verified byte-for-byte identical
|
||||||
7
rust-sandlot/src/commands/cd.rs
Normal file
7
rust-sandlot/src/commands/cd.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn action(_branch: &str) -> Result<()> {
|
||||||
|
crate::fmt::die(
|
||||||
|
"\"sandlot cd\" requires shell integration.\n\nAdd one of these to your shell config:\n\n Fish (~/.config/fish/config.fish):\n sandlot init fish | source\n\n Bash (~/.bashrc):\n eval \"$(sandlot init bash)\"\n\n Zsh (~/.zshrc):\n eval \"$(sandlot init zsh)\""
|
||||||
|
)
|
||||||
|
}
|
||||||
28
rust-sandlot/src/commands/checkout.rs
Normal file
28
rust-sandlot/src/commands/checkout.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
|
||||||
|
use super::helpers::{require_session, teardown_session};
|
||||||
|
|
||||||
|
pub async fn action(branch: &str, force: bool) -> Result<()> {
|
||||||
|
let (root, session) = require_session(branch).await;
|
||||||
|
|
||||||
|
if git::is_dirty(&session.worktree).await {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !force && git::is_dirty(&root).await {
|
||||||
|
crate::fmt::die(
|
||||||
|
"Working tree has uncommitted changes that may conflict with checkout. Commit or stash them first, or use -f to force.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown_session(&root, branch, &session.worktree).await;
|
||||||
|
|
||||||
|
git::checkout(branch, &root).await?;
|
||||||
|
|
||||||
|
println!("\u{2714} Checked out {branch}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
38
rust-sandlot/src/commands/cleanup.rs
Normal file
38
rust-sandlot/src/commands/cleanup.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::state;
|
||||||
|
|
||||||
|
use super::helpers::unlink_session_symlink;
|
||||||
|
|
||||||
|
pub async fn action() -> Result<()> {
|
||||||
|
let root = git::repo_root(None).await.unwrap_or_else(|e| {
|
||||||
|
crate::fmt::die(&e.to_string());
|
||||||
|
});
|
||||||
|
let st = state::load(&root).await;
|
||||||
|
let sessions: Vec<_> = st.sessions.values().collect();
|
||||||
|
|
||||||
|
if sessions.is_empty() {
|
||||||
|
println!("No sessions to clean up.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let stale: Vec<_> = sessions
|
||||||
|
.iter()
|
||||||
|
.filter(|s| !Path::new(&s.worktree).exists())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if stale.is_empty() {
|
||||||
|
println!("No stale sessions found.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in &stale {
|
||||||
|
state::remove_session(&root, &s.branch).await.ok();
|
||||||
|
unlink_session_symlink(&root, &s.branch).await;
|
||||||
|
println!("\u{2714} Removed stale session: {}", s.branch);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
22
rust-sandlot/src/commands/close.rs
Normal file
22
rust-sandlot/src/commands/close.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
|
||||||
|
use super::helpers::{require_session, teardown_session};
|
||||||
|
|
||||||
|
pub async fn action(branch: &str, force: bool) -> Result<()> {
|
||||||
|
let (root, session) = require_session(branch).await;
|
||||||
|
|
||||||
|
if !force && git::is_dirty(&session.worktree).await {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first, or use -f to force."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
teardown_session(&root, branch, &session.worktree).await;
|
||||||
|
|
||||||
|
git::delete_local_branch(branch, &root).await;
|
||||||
|
|
||||||
|
println!("\u{2714} Closed session {branch}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
217
rust-sandlot/src/commands/completions.rs
Normal file
217
rust-sandlot/src/commands/completions.rs
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::config::VALID_KEYS;
|
||||||
|
|
||||||
|
/// Commands that accept a branch argument
|
||||||
|
pub const BRANCH_COMMANDS: &[&str] = &[
|
||||||
|
"new", "open", "close", "rm", "checkout", "diff", "log", "show", "web",
|
||||||
|
"save", "merge", "squash", "rebase", "review", "shell", "edit", "dir", "cd",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// All visible subcommands
|
||||||
|
pub const SUBCOMMANDS: &[(&str, &str)] = &[
|
||||||
|
("list", "Show all active sessions"),
|
||||||
|
("new", "Create a new session and launch Claude"),
|
||||||
|
("open", "Open an existing Claude session"),
|
||||||
|
("close", "Remove a worktree and clean up the session"),
|
||||||
|
("checkout", "Close the session and check out the branch locally"),
|
||||||
|
("diff", "Show uncommitted changes, or full branch diff vs main"),
|
||||||
|
("log", "Show commits on a branch that are not on main"),
|
||||||
|
("show", "Show the prompt and full diff for a branch"),
|
||||||
|
("web", "Open the branch diff in a web browser"),
|
||||||
|
("save", "Stage all changes and commit"),
|
||||||
|
("merge", "Merge a branch into main and close the session"),
|
||||||
|
("squash", "Squash all commits on a branch into a single commit"),
|
||||||
|
("rebase", "Rebase a branch onto the latest main"),
|
||||||
|
("review", "Launch an interactive grumpy code review for a branch"),
|
||||||
|
("shell", "Open a shell in the VM"),
|
||||||
|
("edit", "Open a file from a session in $EDITOR"),
|
||||||
|
("dir", "Print the worktree path for a session"),
|
||||||
|
("cd", "Change to a branch's worktree directory"),
|
||||||
|
("config", "Get or set configuration (e.g. sandlot config memory 16G)"),
|
||||||
|
("cleanup", "Remove stale sessions whose worktrees no longer exist"),
|
||||||
|
("vm", "Manage the sandlot VM"),
|
||||||
|
("upgrade", "Upgrade sandlot to the latest version"),
|
||||||
|
("version", "Print the version number"),
|
||||||
|
("completions", "Output fish shell completions"),
|
||||||
|
("init", "Print shell init script (eval in your shell config)"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const VM_SUBCOMMANDS: &[(&str, &str)] = &[
|
||||||
|
("create", "Create and provision the VM"),
|
||||||
|
("start", "Start the VM"),
|
||||||
|
("shell", "Open a shell in the VM"),
|
||||||
|
("status", "Show VM status and all sessions across repos"),
|
||||||
|
("info", "Show VM system info (via neofetch)"),
|
||||||
|
("stop", "Stop the VM"),
|
||||||
|
("destroy", "Stop and delete the VM"),
|
||||||
|
("uncache", "Clear the package cache (next create will re-download)"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn esc(s: &str) -> String {
|
||||||
|
format!("\"{}\"", s.replace('"', "\\\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_fish_completions() -> Vec<String> {
|
||||||
|
let mut lines = vec![
|
||||||
|
"# Fish completions for sandlot (auto-generated)".to_string(),
|
||||||
|
String::new(),
|
||||||
|
"complete -c sandlot -f".to_string(),
|
||||||
|
String::new(),
|
||||||
|
"function __sandlot_sessions".to_string(),
|
||||||
|
" command sandlot list --json 2>/dev/null | string match -r '\"branch\":\\s*\"[^\"]+\"' | string replace -r '.*\"branch\":\\s*\"([^\"]+)\".*' '$1'".to_string(),
|
||||||
|
"end".to_string(),
|
||||||
|
String::new(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Commands with their options interleaved (matching TS traversal order)
|
||||||
|
// Each entry: (name, desc, options)
|
||||||
|
// Options: (short, long, desc, required)
|
||||||
|
let commands_with_opts: Vec<(&str, &str, Vec<(Option<&str>, Option<&str>, &str, bool)>)> = vec![
|
||||||
|
("list", "Show all active sessions", vec![
|
||||||
|
(None, Some("json"), "Output as JSON", false),
|
||||||
|
(Some("a"), Some("all"), "Show sessions across all projects", false),
|
||||||
|
]),
|
||||||
|
("new", "Create a new session and launch Claude", vec![
|
||||||
|
(Some("p"), Some("print"), "run Claude in non-interactive mode with -p", true),
|
||||||
|
(Some("n"), Some("no-save"), "skip auto-save after Claude exits", false),
|
||||||
|
]),
|
||||||
|
("open", "Open an existing Claude session", vec![
|
||||||
|
(Some("p"), Some("print"), "run Claude in non-interactive mode with -p", true),
|
||||||
|
(Some("n"), Some("no-save"), "skip auto-save after Claude exits", false),
|
||||||
|
]),
|
||||||
|
("close", "Remove a worktree and clean up the session", vec![
|
||||||
|
(Some("f"), Some("force"), "close even if there are unsaved changes", false),
|
||||||
|
]),
|
||||||
|
("rm", "Remove a session (alias for close)", vec![
|
||||||
|
(Some("f"), Some("force"), "close even if there are unsaved changes", false),
|
||||||
|
]),
|
||||||
|
("checkout", "Close the session and check out the branch locally", vec![
|
||||||
|
(Some("f"), Some("force"), "checkout even if there are unsaved changes", false),
|
||||||
|
]),
|
||||||
|
("diff", "Show uncommitted changes, or full branch diff vs main", vec![]),
|
||||||
|
("log", "Show commits on a branch that are not on main", vec![]),
|
||||||
|
("show", "Show the prompt and full diff for a branch", vec![]),
|
||||||
|
("web", "Open the branch diff in a web browser", vec![]),
|
||||||
|
("save", "Stage all changes and commit", vec![]),
|
||||||
|
("merge", "Merge a branch into main and close the session", vec![
|
||||||
|
(Some("f"), Some("force"), "allow merging into a non-main branch", false),
|
||||||
|
]),
|
||||||
|
("squash", "Squash all commits on a branch into a single commit", vec![]),
|
||||||
|
("rebase", "Rebase a branch onto the latest main", vec![]),
|
||||||
|
("review", "Launch an interactive grumpy code review for a branch", vec![
|
||||||
|
(Some("p"), Some("print"), "print the review to stdout instead of launching interactive mode", false),
|
||||||
|
]),
|
||||||
|
("shell", "Open a shell in the VM", vec![]),
|
||||||
|
("edit", "Open a file from a session in $EDITOR", vec![]),
|
||||||
|
("dir", "Print the worktree path for a session", vec![]),
|
||||||
|
("cd", "Change to a branch's worktree directory", vec![]),
|
||||||
|
("config", "Get or set configuration (e.g. sandlot config memory 16G)", vec![]),
|
||||||
|
("cleanup", "Remove stale sessions whose worktrees no longer exist", vec![]),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, desc, opts) in &commands_with_opts {
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n __fish_use_subcommand -a {name} -d {}",
|
||||||
|
esc(desc)
|
||||||
|
));
|
||||||
|
for (short, long, opt_desc, required) in opts {
|
||||||
|
let mut parts = vec![format!("complete -c sandlot -n \"__fish_seen_subcommand_from {name}\"")];
|
||||||
|
if let Some(s) = short {
|
||||||
|
parts.push(format!("-s {s}"));
|
||||||
|
}
|
||||||
|
if let Some(l) = long {
|
||||||
|
parts.push(format!("-l {l}"));
|
||||||
|
}
|
||||||
|
parts.push(format!("-d {}", esc(opt_desc)));
|
||||||
|
if *required {
|
||||||
|
parts.push("-r".to_string());
|
||||||
|
}
|
||||||
|
lines.push(parts.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VM parent command with subcommands
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n __fish_use_subcommand -a vm -d {}",
|
||||||
|
esc("Manage the sandlot VM")
|
||||||
|
));
|
||||||
|
let sub_names: Vec<&str> = VM_SUBCOMMANDS.iter().map(|(n, _)| *n).collect();
|
||||||
|
let guard = format!(
|
||||||
|
"\"__fish_seen_subcommand_from vm; and not __fish_seen_subcommand_from {}\"",
|
||||||
|
sub_names.join(" ")
|
||||||
|
);
|
||||||
|
for (sub, sub_desc) in VM_SUBCOMMANDS {
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n {guard} -a {sub} -d {}",
|
||||||
|
esc(sub_desc)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// VM subcommand options
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n \"__fish_seen_subcommand_from vm status\" -l json -d {}",
|
||||||
|
esc("Output as JSON")
|
||||||
|
));
|
||||||
|
|
||||||
|
// Remaining top-level commands without options
|
||||||
|
for (name, desc) in &[
|
||||||
|
("upgrade", "Upgrade sandlot to the latest version"),
|
||||||
|
("version", "Print the version number"),
|
||||||
|
] {
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n __fish_use_subcommand -a {name} -d {}",
|
||||||
|
esc(desc)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n __fish_use_subcommand -a completions -d {}",
|
||||||
|
esc("Output fish shell completions")
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n \"__fish_seen_subcommand_from completions\" -l install -d {}",
|
||||||
|
esc("Output a shell script that installs the completions file")
|
||||||
|
));
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n __fish_use_subcommand -a init -d {}",
|
||||||
|
esc("Print shell init script (eval in your shell config)")
|
||||||
|
));
|
||||||
|
|
||||||
|
// Session completions for branch-taking commands
|
||||||
|
lines.push(String::new());
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n \"__fish_seen_subcommand_from {}\" -xa \"(__sandlot_sessions)\"",
|
||||||
|
BRANCH_COMMANDS.join(" ")
|
||||||
|
));
|
||||||
|
|
||||||
|
// Config key completions
|
||||||
|
lines.push(String::new());
|
||||||
|
lines.push(format!(
|
||||||
|
"complete -c sandlot -n \"__fish_seen_subcommand_from config\" -xa \"{}\"",
|
||||||
|
VALID_KEYS.join(" ")
|
||||||
|
));
|
||||||
|
|
||||||
|
lines.push(String::new());
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn action(install: bool) -> Result<()> {
|
||||||
|
if install {
|
||||||
|
let dest = "~/.config/fish/completions/sandlot.fish";
|
||||||
|
println!("#!/bin/sh");
|
||||||
|
println!("mkdir -p ~/.config/fish/completions");
|
||||||
|
println!("sandlot completions > {dest}");
|
||||||
|
println!("echo \"Installed fish completions to {dest}\"");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = generate_fish_completions();
|
||||||
|
lines.insert(
|
||||||
|
1,
|
||||||
|
"# Install: sandlot completions > ~/.config/fish/completions/sandlot.fish".to_string(),
|
||||||
|
);
|
||||||
|
for line in &lines {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
60
rust-sandlot/src/commands/config.rs
Normal file
60
rust-sandlot/src/commands/config.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::config::{self, DEFAULTS_MEMORY, VALID_KEYS};
|
||||||
|
|
||||||
|
pub async fn action(args: &[String]) -> Result<()> {
|
||||||
|
if args.is_empty() {
|
||||||
|
let cfg = config::load().await;
|
||||||
|
for key in VALID_KEYS {
|
||||||
|
let display = match *key {
|
||||||
|
"memory" => match &cfg.memory {
|
||||||
|
Some(v) => v.to_string(),
|
||||||
|
None => format!("{DEFAULTS_MEMORY} (default)"),
|
||||||
|
},
|
||||||
|
_ => "(unknown)".to_string(),
|
||||||
|
};
|
||||||
|
println!("{key} = {display}");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = &args[0];
|
||||||
|
if !VALID_KEYS.contains(&key.as_str()) {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Unknown config key: {key}\nAvailable keys: {}",
|
||||||
|
VALID_KEYS.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.len() == 1 {
|
||||||
|
let val = match key.as_str() {
|
||||||
|
"memory" => config::get_memory().await,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let default = match key.as_str() {
|
||||||
|
"memory" => DEFAULTS_MEMORY,
|
||||||
|
_ => "",
|
||||||
|
};
|
||||||
|
match val {
|
||||||
|
Some(v) => println!("{v}"),
|
||||||
|
None => println!("{default} (default)"),
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.len() > 2 {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Too many arguments. Usage: sandlot config {key} <value>"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = &args[1];
|
||||||
|
let normalized = match config::validate_memory(value) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => crate::fmt::die("Must be a number followed by G or M, minimum 512M (e.g. 16G)"),
|
||||||
|
};
|
||||||
|
config::set_memory(normalized.clone()).await?;
|
||||||
|
println!("{key} = {normalized}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
116
rust-sandlot/src/commands/diff.html
Normal file
116
rust-sandlot/src/commands/diff.html
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{BRANCH}} — sandlot diff</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github-dark.min.css" media="(prefers-color-scheme: dark)">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github.min.css" media="(prefers-color-scheme: light)">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117; --fg: #e6edf3; --fg-secondary: #8b949e; --fg-code: #c9d1d9;
|
||||||
|
--code-bg: #1f2937; --border: #30363d;
|
||||||
|
--add: #3fb950; --remove: #f85149;
|
||||||
|
--color-scheme: dark;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff; --fg: #1f2328; --fg-secondary: #656d76; --fg-code: #1f2328;
|
||||||
|
--code-bg: #f6f8fa; --border: #d0d7de;
|
||||||
|
--add: #1a7f37; --remove: #cf222e;
|
||||||
|
--color-scheme: light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; margin: 0; padding: 0; background: var(--bg); color: var(--fg); color-scheme: light dark; }
|
||||||
|
.header { padding: 24px 32px; border-bottom: 1px solid var(--border); }
|
||||||
|
.header h1 { margin: 0 0 8px; font-size: 24px; font-weight: 600; }
|
||||||
|
.header h1 code { background: var(--code-bg); padding: 2px 8px; border-radius: 6px; font-size: 22px; }
|
||||||
|
.prompt { color: var(--fg-secondary); margin: 0 0 16px; font-style: italic; }
|
||||||
|
.meta { display: flex; gap: 32px; }
|
||||||
|
.meta-section h3 { margin: 0 0 6px; font-size: 13px; text-transform: uppercase; color: var(--fg-secondary); letter-spacing: 0.05em; }
|
||||||
|
.meta-section pre { margin: 0; font-size: 13px; line-height: 1.5; color: var(--fg-code); white-space: pre-wrap; }
|
||||||
|
.diff-container { padding: 16px 32px; }
|
||||||
|
.d2h-file-wrapper { margin-bottom: 16px; border-radius: 6px; overflow: hidden; }
|
||||||
|
.d2h-code-line-ctn { background: transparent; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1><code>{{BRANCH}}</code></h1>
|
||||||
|
{{PROMPT_SECTION}}
|
||||||
|
<div style="margin: 12px 0;">
|
||||||
|
<label style="cursor:pointer; user-select:none; font-size:14px; color:var(--fg-secondary);">
|
||||||
|
<input type="checkbox" id="unified-toggle" style="cursor:pointer; vertical-align:middle; margin-right:6px;">
|
||||||
|
Unified
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
{{LOG_SECTION}}
|
||||||
|
{{STAT_SECTION}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="diff-container" id="diff"></div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/diff2html/bundles/js/diff2html-ui.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const diffString = {{DIFF_JSON}};
|
||||||
|
const targetElement = document.getElementById("diff");
|
||||||
|
const toggle = document.getElementById("unified-toggle");
|
||||||
|
const schemeMq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
|
||||||
|
function getColorScheme() {
|
||||||
|
return schemeMq.matches ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split raw diff into per-file chunks and classify each
|
||||||
|
function splitDiff(raw) {
|
||||||
|
const files = [];
|
||||||
|
const parts = raw.split(/^(?=diff --git )/m);
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!part.trim()) continue;
|
||||||
|
const isNew = /^new file mode/m.test(part);
|
||||||
|
const isDeleted = /^deleted file mode/m.test(part);
|
||||||
|
files.push({ raw: part, isNew, isDeleted });
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = splitDiff(diffString);
|
||||||
|
|
||||||
|
function renderAll(modifiedFormat) {
|
||||||
|
targetElement.innerHTML = "";
|
||||||
|
for (const file of files) {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
targetElement.appendChild(div);
|
||||||
|
const format = (file.isNew || file.isDeleted) ? "line-by-line" : modifiedFormat;
|
||||||
|
const ui = new Diff2HtmlUI(div, file.raw, {
|
||||||
|
drawFileList: false,
|
||||||
|
matching: "lines",
|
||||||
|
outputFormat: format,
|
||||||
|
highlight: true,
|
||||||
|
colorScheme: getColorScheme(),
|
||||||
|
}, hljs);
|
||||||
|
ui.draw();
|
||||||
|
ui.highlightCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentFormat() {
|
||||||
|
return toggle.checked ? "line-by-line" : "side-by-side";
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = localStorage.getItem("sandlot-unified") === "1";
|
||||||
|
toggle.checked = saved;
|
||||||
|
renderAll(currentFormat());
|
||||||
|
|
||||||
|
toggle.addEventListener("change", function () {
|
||||||
|
localStorage.setItem("sandlot-unified", this.checked ? "1" : "0");
|
||||||
|
renderAll(currentFormat());
|
||||||
|
});
|
||||||
|
|
||||||
|
schemeMq.addEventListener("change", function () {
|
||||||
|
renderAll(currentFormat());
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
63
rust-sandlot/src/commands/diff.rs
Normal file
63
rust-sandlot/src/commands/diff.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
|
||||||
|
use super::helpers::require_session;
|
||||||
|
|
||||||
|
pub async fn action(branch: &str) -> Result<()> {
|
||||||
|
let (_, session) = require_session(branch).await;
|
||||||
|
|
||||||
|
// Check for uncommitted changes (staged + unstaged)
|
||||||
|
let status = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", &session.worktree, "status", "--porcelain"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let status = match status {
|
||||||
|
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||||
|
_ => {
|
||||||
|
eprintln!("\u{2716} git status failed");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let args: Vec<String> = if !status.trim().is_empty() {
|
||||||
|
// Show uncommitted changes
|
||||||
|
let has_head = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", &session.worktree, "rev-parse", "--verify", "HEAD"])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
if has_head.is_ok_and(|s| s.success()) {
|
||||||
|
vec!["diff".into(), "HEAD".into()]
|
||||||
|
} else {
|
||||||
|
vec!["diff".into()]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No uncommitted changes — show full branch diff vs main
|
||||||
|
let main = git::main_branch(Some(&session.worktree)).await?;
|
||||||
|
vec!["diff".into(), format!("{main}...{branch}")]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run git diff with inherited stdio
|
||||||
|
let status = std::process::Command::new("git")
|
||||||
|
.args(
|
||||||
|
std::iter::once("-C".to_string())
|
||||||
|
.chain(std::iter::once(session.worktree.clone()))
|
||||||
|
.chain(args),
|
||||||
|
)
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.spawn()?
|
||||||
|
.wait()?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
std::process::exit(status.code().unwrap_or(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
9
rust-sandlot/src/commands/dir.rs
Normal file
9
rust-sandlot/src/commands/dir.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::helpers::require_session;
|
||||||
|
|
||||||
|
pub async fn action(branch: &str) -> Result<()> {
|
||||||
|
let (_, session) = require_session(branch).await;
|
||||||
|
println!("{}", session.worktree);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
50
rust-sandlot/src/commands/edit.rs
Normal file
50
rust-sandlot/src/commands/edit.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use super::helpers::require_session;
|
||||||
|
|
||||||
|
pub async fn action(branch: &str, file: &str) -> Result<()> {
|
||||||
|
let editor = match std::env::var("EDITOR") {
|
||||||
|
Ok(e) if !e.is_empty() => e,
|
||||||
|
_ => crate::fmt::die("$EDITOR is not set."),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_, session) = require_session(branch).await;
|
||||||
|
let worktree = std::fs::canonicalize(&session.worktree)
|
||||||
|
.unwrap_or_else(|_| Path::new(&session.worktree).to_path_buf());
|
||||||
|
let worktree_str = worktree.to_string_lossy().to_string();
|
||||||
|
let path = std::fs::canonicalize(worktree.join(file))
|
||||||
|
.unwrap_or_else(|_| worktree.join(file));
|
||||||
|
let path_str = path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
if !path_str.starts_with(&format!("{worktree_str}/")) && path_str != worktree_str {
|
||||||
|
crate::fmt::die("File path escapes the worktree.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if !path.exists() {
|
||||||
|
crate::fmt::die(&format!("File not found: {file}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = editor.split_whitespace().collect();
|
||||||
|
let (cmd, args) = parts.split_first().unwrap();
|
||||||
|
|
||||||
|
let mut command_args: Vec<&str> = args.to_vec();
|
||||||
|
command_args.push(&path_str);
|
||||||
|
|
||||||
|
let status = std::process::Command::new(cmd)
|
||||||
|
.args(&command_args)
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.spawn()?
|
||||||
|
.wait()?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Editor exited with code {}.",
|
||||||
|
status.code().unwrap_or(1)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
414
rust-sandlot/src/commands/helpers.rs
Normal file
414
rust-sandlot/src/commands/helpers.rs
Normal file
|
|
@ -0,0 +1,414 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::spinner::Spinner;
|
||||||
|
use crate::state::{self, Session};
|
||||||
|
use crate::vm;
|
||||||
|
|
||||||
|
/// Generated files to skip AI resolution — accept theirs and move on.
|
||||||
|
fn skip_resolve_set() -> HashSet<&'static str> {
|
||||||
|
[
|
||||||
|
"bun.lock",
|
||||||
|
"bun.lockb",
|
||||||
|
"Cargo.lock",
|
||||||
|
"composer.lock",
|
||||||
|
"Gemfile.lock",
|
||||||
|
"go.sum",
|
||||||
|
"mix.lock",
|
||||||
|
"package-lock.json",
|
||||||
|
"Pipfile.lock",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"Podfile.lock",
|
||||||
|
"poetry.lock",
|
||||||
|
"pubspec.lock",
|
||||||
|
"flake.lock",
|
||||||
|
"gradle.lockfile",
|
||||||
|
"npm-shrinkwrap.json",
|
||||||
|
"Package.resolved",
|
||||||
|
"uv.lock",
|
||||||
|
"yarn.lock",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a .sandlot/<branch> symlink and prune empty parent dirs up to .sandlot/.
|
||||||
|
pub async fn unlink_session_symlink(root: &str, branch: &str) {
|
||||||
|
let sandlot_dir = Path::new(root).join(".sandlot");
|
||||||
|
let symlink_path = sandlot_dir.join(branch);
|
||||||
|
tokio::fs::remove_file(&symlink_path).await.ok();
|
||||||
|
|
||||||
|
// Walk up from the symlink's parent, removing empty dirs, stopping at .sandlot/ itself
|
||||||
|
let mut dir = symlink_path.parent().map(|p| p.to_path_buf());
|
||||||
|
while let Some(d) = &dir {
|
||||||
|
if d == &sandlot_dir {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if tokio::fs::remove_dir(d).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
dir = d.parent().map(|p| p.to_path_buf());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a session by branch, dying if it doesn't exist.
|
||||||
|
pub async fn require_session(branch: &str) -> (String, Session) {
|
||||||
|
let root = git::repo_root(None).await.unwrap_or_else(|e| {
|
||||||
|
crate::fmt::die(&e.to_string());
|
||||||
|
});
|
||||||
|
let session = state::get_session(&root, branch).await;
|
||||||
|
match session {
|
||||||
|
Some(s) => (root, s),
|
||||||
|
None => crate::fmt::die(&format!("No session found for branch \"{branch}\".")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a session by branch, recreating worktree/session if the branch exists but the session doesn't.
|
||||||
|
pub async fn ensure_session(branch: &str) -> (String, Session) {
|
||||||
|
let root = git::repo_root(None).await.unwrap_or_else(|e| {
|
||||||
|
crate::fmt::die(&e.to_string());
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(existing) = state::get_session(&root, branch).await {
|
||||||
|
return (root, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No session — check if the branch exists
|
||||||
|
let exists = git::branch_exists(branch, Some(&root), false).await;
|
||||||
|
if exists.is_none() {
|
||||||
|
crate::fmt::die(&format!("No session or branch found for \"{branch}\"."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recreate worktree and session
|
||||||
|
let home = dirs::home_dir().expect("cannot find home directory");
|
||||||
|
let repo_name = Path::new(&root)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let worktree_abs = home
|
||||||
|
.join(".sandlot")
|
||||||
|
.join(&repo_name)
|
||||||
|
.join(branch)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
match git::create_worktree(branch, &worktree_abs, &root).await {
|
||||||
|
Ok(_) => {
|
||||||
|
let symlink_path = Path::new(&root).join(".sandlot").join(branch);
|
||||||
|
if let Some(parent) = symlink_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await.ok();
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
tokio::fs::symlink(&worktree_abs, &symlink_path).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
git::remove_worktree(&worktree_abs, &root).await.ok();
|
||||||
|
unlink_session_symlink(&root, branch).await;
|
||||||
|
crate::fmt::die(&format!("Failed to recreate session: {err}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = Session {
|
||||||
|
branch: branch.to_string(),
|
||||||
|
worktree: worktree_abs,
|
||||||
|
created_at: chrono_now(),
|
||||||
|
prompt: None,
|
||||||
|
in_review: None,
|
||||||
|
};
|
||||||
|
state::set_session(&root, session.clone()).await.ok();
|
||||||
|
(root, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tear down a session: clear activity, remove worktree, unlink symlink, remove state.
|
||||||
|
pub async fn teardown_session(root: &str, branch: &str, worktree: &str) {
|
||||||
|
vm::clear_activity(worktree, branch).await;
|
||||||
|
|
||||||
|
if let Err(e) = git::remove_worktree(worktree, root).await {
|
||||||
|
eprintln!("\u{26A0} Failed to remove worktree: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
unlink_session_symlink(root, branch).await;
|
||||||
|
|
||||||
|
state::remove_session(root, branch).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve conflict markers in files using Claude, then stage them.
|
||||||
|
pub async fn resolve_conflicts(
|
||||||
|
files: &[String],
|
||||||
|
cwd: &str,
|
||||||
|
on_file: &dyn Fn(&str, usize, usize),
|
||||||
|
) -> Result<()> {
|
||||||
|
let skip = skip_resolve_set();
|
||||||
|
|
||||||
|
for (i, file) in files.iter().enumerate() {
|
||||||
|
on_file(file, i + 1, files.len());
|
||||||
|
|
||||||
|
let basename = Path::new(file)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if skip.contains(basename.as_str()) {
|
||||||
|
git::checkout_theirs(file, cwd).await?;
|
||||||
|
git::stage_file(file, cwd).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = Path::new(cwd).join(file);
|
||||||
|
let content = tokio::fs::read_to_string(&file_path).await.map_err(|_| {
|
||||||
|
anyhow::anyhow!("Failed to read conflicted file: {file}")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (exit_code, stdout, stderr) = vm::claude_pipe(
|
||||||
|
&content,
|
||||||
|
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if exit_code != 0 || stdout.trim().is_empty() {
|
||||||
|
let detail = if stderr.trim().is_empty() {
|
||||||
|
"(no output)"
|
||||||
|
} else {
|
||||||
|
stderr.trim()
|
||||||
|
};
|
||||||
|
anyhow::bail!("Claude failed to resolve {file}: {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = format!("{}\n", stdout.trim_end());
|
||||||
|
tokio::fs::write(&file_path, &resolved).await?;
|
||||||
|
git::stage_file(file, cwd).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge a branch into main, resolve conflicts if needed, and close the session.
|
||||||
|
pub async fn merge_and_close(branch: &str, force: bool) -> Result<()> {
|
||||||
|
let root = git::repo_root(None).await?;
|
||||||
|
let main = git::main_branch(Some(&root)).await?;
|
||||||
|
let current = git::current_branch(Some(&root)).await?;
|
||||||
|
|
||||||
|
if current != main && !force {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"You must be on \"{main}\" to merge. Currently on \"{current}\". Use --force to merge into \"{current}\" anyway."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = state::get_session(&root, branch).await;
|
||||||
|
|
||||||
|
if let Some(ref s) = session {
|
||||||
|
if git::is_dirty(&s.worktree).await {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let spin = Spinner::new("Merging", Some(branch));
|
||||||
|
let conflicts = git::merge(branch, &root).await?;
|
||||||
|
|
||||||
|
if conflicts.is_empty() {
|
||||||
|
spin.succeed(&format!("Merged {branch} into {current}"));
|
||||||
|
if let Some(ref s) = session {
|
||||||
|
teardown_session(&root, branch, &s.worktree).await;
|
||||||
|
}
|
||||||
|
git::delete_local_branch(branch, &root).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve conflicts with Claude
|
||||||
|
spin.set_text(&format!("Resolving {} conflict(s)", conflicts.len()));
|
||||||
|
|
||||||
|
let result: Result<()> = async {
|
||||||
|
vm::ensure(&|msg| spin.set_text(msg)).await?;
|
||||||
|
if let Some(ref s) = session {
|
||||||
|
vm::set_activity(&s.worktree, branch).await;
|
||||||
|
}
|
||||||
|
resolve_conflicts(&conflicts, &root, &|file, i, total| {
|
||||||
|
if total > 1 {
|
||||||
|
spin.set_text(&format!("({i}/{total}) Resolving {file}"));
|
||||||
|
} else {
|
||||||
|
spin.set_text(&format!("Resolving {file}"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
git::commit_merge(&root).await?;
|
||||||
|
spin.succeed(&format!(
|
||||||
|
"Resolved {} conflict(s) and merged {branch}",
|
||||||
|
conflicts.len()
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
spin.fail(&e.to_string());
|
||||||
|
if let Some(ref s) = session {
|
||||||
|
vm::clear_activity(&s.worktree, branch).await;
|
||||||
|
}
|
||||||
|
git::abort_merge(&root).await;
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref s) = session {
|
||||||
|
vm::clear_activity(&s.worktree, branch).await;
|
||||||
|
teardown_session(&root, branch, &s.worktree).await;
|
||||||
|
}
|
||||||
|
git::delete_local_branch(branch, &root).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage all changes, generate a commit message, and commit. Returns true on success.
|
||||||
|
pub async fn save_changes(worktree: &str, branch: &str, message: Option<&str>) -> bool {
|
||||||
|
let spin = Spinner::new("Staging changes", Some(branch));
|
||||||
|
|
||||||
|
// git add .
|
||||||
|
let _ = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", worktree, "add", "."])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Check for staged changes
|
||||||
|
let check = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", worktree, "diff", "--staged", "--quiet"])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await;
|
||||||
|
if check.is_ok_and(|s| s.success()) {
|
||||||
|
spin.fail("No changes to commit");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = if let Some(m) = message {
|
||||||
|
m.to_string()
|
||||||
|
} else {
|
||||||
|
spin.set_text("Starting container");
|
||||||
|
if let Err(e) = vm::ensure(&|m| spin.set_text(m)).await {
|
||||||
|
spin.fail(&format!("Failed to start container: {e}"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
spin.set_text("Generating commit message");
|
||||||
|
let diff_output = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", worktree, "diff", "--staged"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
let diff = match diff_output {
|
||||||
|
Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||||
|
Err(_) => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (exit_code, stdout, stderr) = vm::claude_pipe(
|
||||||
|
&diff,
|
||||||
|
"Write a commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if exit_code != 0 {
|
||||||
|
spin.fail("Failed to generate commit message");
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
eprintln!("{stderr}");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
stdout
|
||||||
|
};
|
||||||
|
|
||||||
|
spin.set_text("Committing");
|
||||||
|
let commit = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", worktree, "commit", "-m", &msg])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match commit {
|
||||||
|
Ok(o) if o.status.success() => {
|
||||||
|
let first_line = msg.lines().next().unwrap_or(&msg);
|
||||||
|
spin.succeed(&format!("Saved: {first_line}"));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Ok(o) => {
|
||||||
|
spin.fail("Commit failed");
|
||||||
|
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||||
|
if !stderr.trim().is_empty() {
|
||||||
|
eprintln!("{}", stderr.trim());
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
spin.fail("Commit failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chrono_now() -> String {
|
||||||
|
// Simple ISO 8601 timestamp without chrono dependency
|
||||||
|
use std::time::SystemTime;
|
||||||
|
let duration = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let secs = duration.as_secs();
|
||||||
|
// Basic UTC timestamp
|
||||||
|
let days = secs / 86400;
|
||||||
|
let time_secs = secs % 86400;
|
||||||
|
let hours = time_secs / 3600;
|
||||||
|
let minutes = (time_secs % 3600) / 60;
|
||||||
|
let seconds = time_secs % 60;
|
||||||
|
|
||||||
|
// Calculate date from days since epoch (1970-01-01)
|
||||||
|
let mut y = 1970i64;
|
||||||
|
let mut remaining_days = days as i64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let days_in_year = if is_leap_year(y) { 366 } else { 365 };
|
||||||
|
if remaining_days < days_in_year {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
remaining_days -= days_in_year;
|
||||||
|
y += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let month_days = if is_leap_year(y) {
|
||||||
|
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
} else {
|
||||||
|
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut m = 0;
|
||||||
|
for (i, &md) in month_days.iter().enumerate() {
|
||||||
|
if remaining_days < md as i64 {
|
||||||
|
m = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
remaining_days -= md as i64;
|
||||||
|
}
|
||||||
|
|
||||||
|
format!(
|
||||||
|
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
|
||||||
|
y,
|
||||||
|
m + 1,
|
||||||
|
remaining_days + 1,
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
seconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_leap_year(y: i64) -> bool {
|
||||||
|
(y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)
|
||||||
|
}
|
||||||
97
rust-sandlot/src/commands/init.rs
Normal file
97
rust-sandlot/src/commands/init.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::completions::{generate_fish_completions, BRANCH_COMMANDS, SUBCOMMANDS};
|
||||||
|
|
||||||
|
pub fn action(shell: &str) -> Result<()> {
|
||||||
|
match shell {
|
||||||
|
"fish" => emit_fish(),
|
||||||
|
"bash" => emit_bash(),
|
||||||
|
"zsh" => emit_zsh(),
|
||||||
|
_ => crate::fmt::die(&format!(
|
||||||
|
"Unsupported shell: {shell}. Supported shells: fish, bash, zsh"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_fish() -> Result<()> {
|
||||||
|
let lines = vec![
|
||||||
|
"function sandlot --wraps sandlot --description 'Sandlot CLI wrapper'",
|
||||||
|
" if test (count $argv) -ge 1; and test \"$argv[1]\" = cd",
|
||||||
|
" set -l dir (command sandlot dir $argv[2..])",
|
||||||
|
" and cd $dir",
|
||||||
|
" else",
|
||||||
|
" command sandlot $argv",
|
||||||
|
" end",
|
||||||
|
"end",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
for line in &lines {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
for line in &generate_fish_completions() {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hidden commands excluded from bash/zsh completions (fish includes them via the full generator).
|
||||||
|
const HIDDEN_COMMANDS: &[&str] = &["rm"];
|
||||||
|
|
||||||
|
fn emit_bash() -> Result<()> {
|
||||||
|
let subcommands: Vec<&str> = SUBCOMMANDS
|
||||||
|
.iter()
|
||||||
|
.map(|(name, _)| *name)
|
||||||
|
.filter(|name| !HIDDEN_COMMANDS.contains(name))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let lines = vec![
|
||||||
|
"sandlot() {",
|
||||||
|
" if [ \"$#\" -ge 1 ] && [ \"$1\" = \"cd\" ]; then",
|
||||||
|
" local dir",
|
||||||
|
" dir=\"$(command sandlot dir \"${@:2}\")\" && cd \"$dir\"",
|
||||||
|
" else",
|
||||||
|
" command sandlot \"$@\"",
|
||||||
|
" fi",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
"_sandlot_completions() {",
|
||||||
|
" local cur prev",
|
||||||
|
" cur=\"${COMP_WORDS[COMP_CWORD]}\"",
|
||||||
|
" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"",
|
||||||
|
"",
|
||||||
|
" if [ \"$COMP_CWORD\" -eq 1 ]; then",
|
||||||
|
];
|
||||||
|
|
||||||
|
for line in &lines {
|
||||||
|
println!("{line}");
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" COMPREPLY=( $(compgen -W \"{}\" -- \"$cur\") )",
|
||||||
|
subcommands.join(" ")
|
||||||
|
);
|
||||||
|
println!(" return");
|
||||||
|
println!(" fi");
|
||||||
|
println!();
|
||||||
|
let bash_branch_cmds: Vec<&str> = BRANCH_COMMANDS
|
||||||
|
.iter()
|
||||||
|
.filter(|name| !HIDDEN_COMMANDS.contains(name))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
println!(" case \"$prev\" in");
|
||||||
|
println!(" {})", bash_branch_cmds.join("|"));
|
||||||
|
println!(" local branches");
|
||||||
|
println!(" branches=\"$(command sandlot list --json 2>/dev/null | grep -o '\"branch\": *\"[^\"]*\"' | sed 's/.*\"\\([^\"]*\\)\"$/\\1/')\"");
|
||||||
|
println!(" COMPREPLY=( $(compgen -W \"$branches\" -- \"$cur\") )");
|
||||||
|
println!(" return");
|
||||||
|
println!(" ;;");
|
||||||
|
println!(" esac");
|
||||||
|
println!("}}");
|
||||||
|
println!("complete -F _sandlot_completions sandlot");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_zsh() -> Result<()> {
|
||||||
|
println!("autoload -Uz bashcompinit && bashcompinit");
|
||||||
|
emit_bash()
|
||||||
|
}
|
||||||
313
rust-sandlot/src/commands/list.rs
Normal file
313
rust-sandlot/src/commands/list.rs
Normal file
|
|
@ -0,0 +1,313 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::fmt::{self, CYAN, DIM, GREEN, MAGENTA, RED, RESET, YELLOW};
|
||||||
|
use crate::git;
|
||||||
|
use crate::state::{self, GlobalSession};
|
||||||
|
use crate::vm;
|
||||||
|
|
||||||
|
struct StyleDef {
|
||||||
|
icon: String,
|
||||||
|
color: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn styles() -> HashMap<&'static str, StyleDef> {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert(
|
||||||
|
"idle",
|
||||||
|
StyleDef {
|
||||||
|
icon: format!("{DIM}\u{25EF}{RESET}"),
|
||||||
|
color: DIM,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"active",
|
||||||
|
StyleDef {
|
||||||
|
icon: format!("{CYAN}\u{25CE}{RESET}"),
|
||||||
|
color: CYAN,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"dirty",
|
||||||
|
StyleDef {
|
||||||
|
icon: format!("{YELLOW}\u{25D0}{RESET}"),
|
||||||
|
color: YELLOW,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"saved",
|
||||||
|
StyleDef {
|
||||||
|
icon: format!("{GREEN}\u{25CF}{RESET}"),
|
||||||
|
color: GREEN,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"review",
|
||||||
|
StyleDef {
|
||||||
|
icon: format!("{MAGENTA}\u{29BF}{RESET}"),
|
||||||
|
color: MAGENTA,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_sessions(
|
||||||
|
sessions: &[&GlobalSession],
|
||||||
|
status_map: &HashMap<usize, String>,
|
||||||
|
indices: &[usize],
|
||||||
|
) {
|
||||||
|
let styles = styles();
|
||||||
|
let branch_width = sessions
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.session.branch.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(6)
|
||||||
|
.max(6);
|
||||||
|
let cols = fmt::terminal_width();
|
||||||
|
let prefix_width = branch_width + 4;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {DIM}{:branch_width$} PROMPT{RESET}",
|
||||||
|
"BRANCH"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, gs) in sessions.iter().enumerate() {
|
||||||
|
let idx = indices[i];
|
||||||
|
let prompt = gs
|
||||||
|
.session
|
||||||
|
.prompt
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
let status = status_map
|
||||||
|
.get(&idx)
|
||||||
|
.map(|s| s.as_str())
|
||||||
|
.unwrap_or("idle");
|
||||||
|
let style = styles.get(status).unwrap_or(styles.get("idle").unwrap());
|
||||||
|
let max_prompt = if cols > prefix_width {
|
||||||
|
cols - prefix_width
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let truncated = if max_prompt <= 3 {
|
||||||
|
String::new()
|
||||||
|
} else if prompt.len() <= max_prompt {
|
||||||
|
prompt.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}...", &prompt[..max_prompt - 3])
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{} {}{:branch_width$}{RESET} {DIM}{truncated}{RESET}",
|
||||||
|
style.icon, style.color, gs.session.branch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_status(session: &state::Session, vm_running: bool) -> String {
|
||||||
|
if !std::path::Path::new(&session.worktree).exists() {
|
||||||
|
return "idle".to_string();
|
||||||
|
}
|
||||||
|
if vm_running {
|
||||||
|
let active = vm::is_claude_active(&session.worktree, &session.branch).await;
|
||||||
|
if active && session.in_review == Some(true) {
|
||||||
|
return "review".to_string();
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
return "active".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if git::is_dirty(&session.worktree).await {
|
||||||
|
return "dirty".to_string();
|
||||||
|
}
|
||||||
|
if git::has_new_commits(&session.worktree).await {
|
||||||
|
return "saved".to_string();
|
||||||
|
}
|
||||||
|
"idle".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_stale_reviews(sessions: &[GlobalSession], status_map: &HashMap<usize, String>) {
|
||||||
|
let mut stale_by_repo: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
|
for (i, s) in sessions.iter().enumerate() {
|
||||||
|
if s.session.in_review == Some(true) {
|
||||||
|
let status = status_map.get(&i).map(|s| s.as_str()).unwrap_or("idle");
|
||||||
|
if status != "review" {
|
||||||
|
stale_by_repo
|
||||||
|
.entry(s.repo_root.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(s.session.branch.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (repo_root, branches) in &stale_by_repo {
|
||||||
|
let mut fresh = state::load(repo_root).await;
|
||||||
|
for branch in branches {
|
||||||
|
if let Some(s) = fresh.sessions.get_mut(branch) {
|
||||||
|
s.in_review = Some(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state::save(repo_root, &fresh).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn backfill_prompts(sessions: &mut [GlobalSession], vm_running: bool) {
|
||||||
|
if !vm_running {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let needs_prompt: Vec<usize> = sessions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, s)| s.session.prompt.is_none())
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
if needs_prompt.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let home = dirs::home_dir().unwrap_or_default();
|
||||||
|
let sandlot_dir = home.join(".sandlot").to_string_lossy().to_string();
|
||||||
|
let (code, stdout, _) =
|
||||||
|
vm::exec(&sandlot_dir, "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null").await;
|
||||||
|
if code != 0 || stdout.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut by_project: HashMap<String, String> = HashMap::new();
|
||||||
|
for line in stdout.lines() {
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(e) = serde_json::from_str::<serde_json::Value>(line) {
|
||||||
|
if let (Some(project), Some(display)) =
|
||||||
|
(e.get("project").and_then(|p| p.as_str()), e.get("display").and_then(|d| d.as_str()))
|
||||||
|
{
|
||||||
|
by_project.insert(project.to_string(), display.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in needs_prompt {
|
||||||
|
let container_wt = vm::container_path(&sessions[i].session.worktree);
|
||||||
|
if let Some(display) = by_project.get(&container_wt) {
|
||||||
|
sessions[i].session.prompt = Some(display.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action(json: bool, all: bool) -> Result<()> {
|
||||||
|
let mut sessions: Vec<GlobalSession> = if all {
|
||||||
|
state::load_all().await
|
||||||
|
} else {
|
||||||
|
let root = git::repo_root(None).await.unwrap_or_else(|e| {
|
||||||
|
crate::fmt::die(&e.to_string());
|
||||||
|
});
|
||||||
|
let st = state::load(&root).await;
|
||||||
|
st.sessions
|
||||||
|
.into_values()
|
||||||
|
.map(|s| GlobalSession {
|
||||||
|
session: s,
|
||||||
|
repo_root: root.clone(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let vm_running = vm::status().await == "running";
|
||||||
|
|
||||||
|
if sessions.is_empty() && !json {
|
||||||
|
if all {
|
||||||
|
println!("\u{25C6} No active sessions across any project.");
|
||||||
|
} else {
|
||||||
|
println!("\u{25C6} No active sessions.");
|
||||||
|
}
|
||||||
|
if !all && !vm_running {
|
||||||
|
println!("\n{RED}VM is not running.{RESET}");
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
backfill_prompts(&mut sessions, vm_running).await;
|
||||||
|
|
||||||
|
// Resolve statuses
|
||||||
|
let mut status_map: HashMap<usize, String> = HashMap::new();
|
||||||
|
for (i, gs) in sessions.iter().enumerate() {
|
||||||
|
let status = resolve_status(&gs.session, vm_running).await;
|
||||||
|
status_map.insert(i, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_stale_reviews(&sessions, &status_map).await;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let with_status: Vec<serde_json::Value> = sessions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, gs)| {
|
||||||
|
let mut val = serde_json::to_value(&gs.session).unwrap_or_default();
|
||||||
|
if let Some(obj) = val.as_object_mut() {
|
||||||
|
obj.insert(
|
||||||
|
"status".to_string(),
|
||||||
|
serde_json::Value::String(
|
||||||
|
status_map
|
||||||
|
.get(&i)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or("idle".to_string()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
obj.insert(
|
||||||
|
"repoRoot".to_string(),
|
||||||
|
serde_json::Value::String(gs.repo_root.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
val
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
println!("{}", serde_json::to_string_pretty(&with_status)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if all {
|
||||||
|
// Group by repo
|
||||||
|
let mut by_repo: HashMap<String, Vec<usize>> = HashMap::new();
|
||||||
|
for (i, gs) in sessions.iter().enumerate() {
|
||||||
|
by_repo
|
||||||
|
.entry(gs.repo_root.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(i);
|
||||||
|
}
|
||||||
|
let mut repos: Vec<_> = by_repo.keys().cloned().collect();
|
||||||
|
repos.sort_by(|a, b| {
|
||||||
|
let a_name = std::path::Path::new(a)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy();
|
||||||
|
let b_name = std::path::Path::new(b)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy();
|
||||||
|
a_name.cmp(&b_name)
|
||||||
|
});
|
||||||
|
|
||||||
|
for repo_root in &repos {
|
||||||
|
let repo_name = std::path::Path::new(repo_root)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy();
|
||||||
|
println!("\n{DIM}\u{2500}\u{2500} {RESET}{repo_name}{DIM} \u{2500}\u{2500}{RESET}");
|
||||||
|
let indices = by_repo.get(repo_root).unwrap();
|
||||||
|
let repo_sessions: Vec<&GlobalSession> =
|
||||||
|
indices.iter().map(|&i| &sessions[i]).collect();
|
||||||
|
render_sessions(&repo_sessions, &status_map, indices);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let indices: Vec<usize> = (0..sessions.len()).collect();
|
||||||
|
let refs: Vec<&GlobalSession> = sessions.iter().collect();
|
||||||
|
render_sessions(&refs, &status_map, &indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{DIM}\u{25EF} idle{RESET} \u{00B7} {CYAN}\u{25CE} active{RESET} \u{00B7} {YELLOW}\u{25D0} unsaved{RESET} \u{00B7} {GREEN}\u{25CF} saved{RESET} \u{00B7} {MAGENTA}\u{29BF} review{RESET}");
|
||||||
|
if !vm_running {
|
||||||
|
println!("\n{RED}VM is not running.{RESET}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
32
rust-sandlot/src/commands/log.rs
Normal file
32
rust-sandlot/src/commands/log.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::fmt::{self, RESET, YELLOW};
|
||||||
|
|
||||||
|
use super::helpers::require_session;
|
||||||
|
|
||||||
|
pub async fn action(branch: &str) -> Result<()> {
|
||||||
|
let (_, session) = require_session(branch).await;
|
||||||
|
|
||||||
|
let output = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", &session.worktree, "log", "--no-color", "main..HEAD"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let output = match output {
|
||||||
|
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||||
|
_ => {
|
||||||
|
crate::fmt::die("git log failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Highlight commit hashes in yellow
|
||||||
|
let re = Regex::new(r"(?m)^(commit [0-9a-f]+)").unwrap();
|
||||||
|
let colored = re.replace_all(&output, format!("{YELLOW}$1{RESET}"));
|
||||||
|
|
||||||
|
fmt::pager(&colored).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
7
rust-sandlot/src/commands/merge.rs
Normal file
7
rust-sandlot/src/commands/merge.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::helpers::merge_and_close;
|
||||||
|
|
||||||
|
pub async fn action(branch: &str, force: bool) -> Result<()> {
|
||||||
|
merge_and_close(branch, force).await
|
||||||
|
}
|
||||||
25
rust-sandlot/src/commands/mod.rs
Normal file
25
rust-sandlot/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
pub mod cd;
|
||||||
|
pub mod checkout;
|
||||||
|
pub mod cleanup;
|
||||||
|
pub mod close;
|
||||||
|
pub mod completions;
|
||||||
|
pub mod config;
|
||||||
|
pub mod diff;
|
||||||
|
pub mod dir;
|
||||||
|
pub mod edit;
|
||||||
|
pub mod helpers;
|
||||||
|
pub mod init;
|
||||||
|
pub mod list;
|
||||||
|
pub mod log;
|
||||||
|
pub mod merge;
|
||||||
|
pub mod new;
|
||||||
|
pub mod open;
|
||||||
|
pub mod rebase;
|
||||||
|
pub mod review;
|
||||||
|
pub mod save;
|
||||||
|
pub mod shell;
|
||||||
|
pub mod show;
|
||||||
|
pub mod squash;
|
||||||
|
pub mod upgrade;
|
||||||
|
pub mod vm_cmd;
|
||||||
|
pub mod web;
|
||||||
230
rust-sandlot/src/commands/new.rs
Normal file
230
rust-sandlot/src/commands/new.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use rand::Rng;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::markdown::render_markdown;
|
||||||
|
use crate::spinner::Spinner;
|
||||||
|
use crate::state::{self, Session};
|
||||||
|
use crate::vm;
|
||||||
|
|
||||||
|
use super::helpers::{save_changes, unlink_session_symlink};
|
||||||
|
|
||||||
|
const ADJECTIVES: &[&str] = &[
|
||||||
|
"calm", "bold", "warm", "cool", "keen", "soft", "fast", "wild", "fair", "rare",
|
||||||
|
"deep", "dark", "pale", "wide", "slim", "tall", "glad", "pure", "safe", "free",
|
||||||
|
"hazy", "lazy", "cozy", "tiny", "vast", "busy", "easy", "gray", "gold", "blue",
|
||||||
|
"rosy", "wavy", "mild", "loud", "firm", "flat", "crisp", "dry", "raw", "odd",
|
||||||
|
];
|
||||||
|
|
||||||
|
const NOUNS: &[&str] = &[
|
||||||
|
"fern", "dune", "cove", "pine", "reef", "hawk", "pond", "mesa", "vale", "glen",
|
||||||
|
"haze", "moss", "peak", "tide", "dawn", "lynx", "wren", "sage", "crag", "flint",
|
||||||
|
"leaf", "reed", "cave", "star", "gust", "surf", "opal", "lark", "vale", "plum",
|
||||||
|
"birch", "clay", "jade", "ivy", "fox", "elk", "bay", "ash", "dew", "oak",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn random_branch_name() -> String {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let adj = ADJECTIVES[rng.random_range(0..ADJECTIVES.len())];
|
||||||
|
let noun = NOUNS[rng.random_range(0..NOUNS.len())];
|
||||||
|
format!("{adj}-{noun}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fallback_branch_name(text: &str) -> String {
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
let cleaned: String = lower
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == ' ' || c == '-' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
' '
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trimmed = cleaned.trim();
|
||||||
|
trimmed
|
||||||
|
.split_whitespace()
|
||||||
|
.take(2)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn branch_from_prompt(text: &str) -> String {
|
||||||
|
let api_key = match crate::env::get_api_key().await {
|
||||||
|
Some(k) => k,
|
||||||
|
None => return fallback_branch_name(text),
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"model": "claude-haiku-4-5-20251001",
|
||||||
|
"max_tokens": 15,
|
||||||
|
"temperature": 0,
|
||||||
|
"messages": [{"role": "user", "content": format!("Generate a 2-word git branch name (lowercase, hyphen-separated) for this task:\n\n{text}\n\nOutput ONLY the branch name, nothing else.")}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = match reqwest::Client::new()
|
||||||
|
.post("https://api.anthropic.com/v1/messages")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("x-api-key", &api_key)
|
||||||
|
.header("anthropic-version", "2023-06-01")
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) if res.status().is_success() => res,
|
||||||
|
_ => return fallback_branch_name(text),
|
||||||
|
};
|
||||||
|
|
||||||
|
let json: serde_json::Value = match client.json().await {
|
||||||
|
Ok(j) => j,
|
||||||
|
Err(_) => return fallback_branch_name(text),
|
||||||
|
};
|
||||||
|
|
||||||
|
let raw = json
|
||||||
|
.get("content")
|
||||||
|
.and_then(|c| c.as_array())
|
||||||
|
.and_then(|a| a.first())
|
||||||
|
.and_then(|t| t.get("text"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
let name: String = raw
|
||||||
|
.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' })
|
||||||
|
.collect();
|
||||||
|
// Collapse multiple hyphens, strip leading/trailing
|
||||||
|
let re = regex::Regex::new(r"-+").unwrap();
|
||||||
|
let name = re.replace_all(&name, "-").to_string();
|
||||||
|
let name = name.trim_matches('-');
|
||||||
|
|
||||||
|
if !name.is_empty() && name.len() <= 50 {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
fallback_branch_name(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action(
|
||||||
|
branch: Option<String>,
|
||||||
|
prompt: Option<String>,
|
||||||
|
print: Option<String>,
|
||||||
|
save: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut branch = branch;
|
||||||
|
let mut prompt = prompt;
|
||||||
|
|
||||||
|
// No branch given — derive from -p prompt
|
||||||
|
if branch.is_none() && print.is_some() {
|
||||||
|
branch = Some(branch_from_prompt(print.as_ref().unwrap()).await);
|
||||||
|
} else if branch.is_none() {
|
||||||
|
branch = Some(random_branch_name());
|
||||||
|
} else if let Some(ref b) = branch {
|
||||||
|
if b.contains(' ') {
|
||||||
|
// If the "branch" contains spaces, it's actually a prompt
|
||||||
|
prompt = Some(b.clone());
|
||||||
|
branch = Some(branch_from_prompt(b).await);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let branch = branch.unwrap();
|
||||||
|
let root = git::repo_root(None).await.unwrap_or_else(|e| {
|
||||||
|
crate::fmt::die(&e.to_string());
|
||||||
|
});
|
||||||
|
|
||||||
|
let home = dirs::home_dir().expect("cannot find home directory");
|
||||||
|
let repo_name = Path::new(&root)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let worktree_abs = home
|
||||||
|
.join(".sandlot")
|
||||||
|
.join(&repo_name)
|
||||||
|
.join(&branch)
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let existing = state::get_session(&root, &branch).await;
|
||||||
|
if existing.is_some() {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Session \"{branch}\" already exists. Use \"sandlot open {branch}\" to re-enter it."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let spin = Spinner::new("Creating worktree", Some(&branch));
|
||||||
|
let mut branch_created = false;
|
||||||
|
|
||||||
|
match git::create_worktree(&branch, &worktree_abs, &root).await {
|
||||||
|
Ok(created) => {
|
||||||
|
branch_created = created;
|
||||||
|
let symlink_path = Path::new(&root).join(".sandlot").join(&branch);
|
||||||
|
if let Some(parent) = symlink_path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent).await.ok();
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
tokio::fs::symlink(&worktree_abs, &symlink_path).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
spin.set_text("Starting container");
|
||||||
|
if let Err(e) = vm::ensure(&|msg| spin.set_text(msg)).await {
|
||||||
|
spin.fail(&e.to_string());
|
||||||
|
git::remove_worktree(&worktree_abs, &root).await.ok();
|
||||||
|
if branch_created {
|
||||||
|
git::delete_local_branch(&branch, &root).await;
|
||||||
|
}
|
||||||
|
unlink_session_symlink(&root, &branch).await;
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
if print.is_none() {
|
||||||
|
spin.succeed("Session ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
spin.fail(&e.to_string());
|
||||||
|
git::remove_worktree(&worktree_abs, &root).await.ok();
|
||||||
|
if branch_created {
|
||||||
|
git::delete_local_branch(&branch, &root).await;
|
||||||
|
}
|
||||||
|
unlink_session_symlink(&root, &branch).await;
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let effective_prompt = print.as_ref().or(prompt.as_ref()).cloned();
|
||||||
|
let session = Session {
|
||||||
|
branch: branch.clone(),
|
||||||
|
worktree: worktree_abs.clone(),
|
||||||
|
created_at: super::helpers::chrono_now(),
|
||||||
|
prompt: effective_prompt,
|
||||||
|
in_review: None,
|
||||||
|
};
|
||||||
|
state::set_session(&root, session).await.ok();
|
||||||
|
|
||||||
|
if let Some(ref p) = print {
|
||||||
|
spin.set_text("Running prompt\u{2026}");
|
||||||
|
let (_, output) =
|
||||||
|
vm::claude(&worktree_abs, prompt.as_deref(), Some(p), false).await?;
|
||||||
|
if let Some(ref out) = output {
|
||||||
|
spin.stop();
|
||||||
|
print!("{}\n", render_markdown(out));
|
||||||
|
} else {
|
||||||
|
spin.succeed("Done");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vm::claude(&worktree_abs, prompt.as_deref(), None, false).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
vm::clear_activity(&worktree_abs, &branch).await;
|
||||||
|
|
||||||
|
if save {
|
||||||
|
save_changes(&worktree_abs, &branch, None).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
53
rust-sandlot/src/commands/open.rs
Normal file
53
rust-sandlot/src/commands/open.rs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::markdown::render_markdown;
|
||||||
|
use crate::spinner::Spinner;
|
||||||
|
use crate::state;
|
||||||
|
use crate::vm;
|
||||||
|
|
||||||
|
use super::helpers::{ensure_session, save_changes};
|
||||||
|
|
||||||
|
pub async fn action(
|
||||||
|
branch: String,
|
||||||
|
prompt: Option<String>,
|
||||||
|
print: Option<String>,
|
||||||
|
save: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (root, session) = ensure_session(&branch).await;
|
||||||
|
|
||||||
|
let effective_prompt = print.as_ref().or(prompt.as_ref()).cloned();
|
||||||
|
if let Some(ref p) = effective_prompt {
|
||||||
|
let mut updated = session.clone();
|
||||||
|
updated.prompt = Some(p.clone());
|
||||||
|
state::set_session(&root, updated).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let spin = Spinner::new("Starting container", Some(&branch));
|
||||||
|
if let Err(e) = vm::ensure(&|msg| spin.set_text(msg)).await {
|
||||||
|
spin.fail(&e.to_string());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref p) = print {
|
||||||
|
spin.set_text("Running prompt\u{2026}");
|
||||||
|
let (_, output) =
|
||||||
|
vm::claude(&session.worktree, prompt.as_deref(), Some(p), true).await?;
|
||||||
|
if let Some(ref out) = output {
|
||||||
|
spin.stop();
|
||||||
|
print!("{}\n", render_markdown(out));
|
||||||
|
} else {
|
||||||
|
spin.succeed("Done");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spin.succeed("Session ready");
|
||||||
|
vm::claude(&session.worktree, prompt.as_deref(), None, true).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
vm::clear_activity(&session.worktree, &branch).await;
|
||||||
|
|
||||||
|
if save {
|
||||||
|
save_changes(&session.worktree, &branch, None).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
98
rust-sandlot/src/commands/rebase.rs
Normal file
98
rust-sandlot/src/commands/rebase.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::spinner::Spinner;
|
||||||
|
use crate::vm;
|
||||||
|
|
||||||
|
use super::helpers::{require_session, resolve_conflicts};
|
||||||
|
|
||||||
|
const MAX_REBASE_ROUNDS: usize = 10;
|
||||||
|
|
||||||
|
pub async fn action(branch: &str) -> Result<()> {
|
||||||
|
let (root, session) = require_session(branch).await;
|
||||||
|
let worktree = &session.worktree;
|
||||||
|
|
||||||
|
if git::is_dirty(worktree).await {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let main = git::main_branch(Some(&root)).await?;
|
||||||
|
let fetch_spin = Spinner::new("Fetching origin", Some(branch));
|
||||||
|
|
||||||
|
// Fetch origin main
|
||||||
|
let _ = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", &root, "fetch", "origin", &main])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
fetch_spin.set_text(&format!("Rebasing onto origin/{main}"));
|
||||||
|
|
||||||
|
let onto = format!("origin/{main}");
|
||||||
|
let mut conflicts = match git::rebase(&onto, worktree).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
fetch_spin.fail(&e.to_string());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if conflicts.is_empty() {
|
||||||
|
fetch_spin.succeed(&format!("Rebased {branch} onto {main}"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_spin.stop();
|
||||||
|
println!(
|
||||||
|
"\u{25C6} Rebase conflicts in {} file(s). Resolving with Claude...",
|
||||||
|
conflicts.len()
|
||||||
|
);
|
||||||
|
let resolve_spin = Spinner::new("Starting container", Some(branch));
|
||||||
|
|
||||||
|
let result: Result<()> = async {
|
||||||
|
vm::ensure(&|msg| resolve_spin.set_text(msg)).await?;
|
||||||
|
vm::set_activity(worktree, branch).await;
|
||||||
|
|
||||||
|
let mut round = 1usize;
|
||||||
|
while !conflicts.is_empty() {
|
||||||
|
if round > MAX_REBASE_ROUNDS {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Exceeded {MAX_REBASE_ROUNDS} conflict resolution rounds \u{2014} aborting rebase"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_conflicts(&conflicts, worktree, &|file, i, total| {
|
||||||
|
if total > 1 {
|
||||||
|
resolve_spin
|
||||||
|
.set_text(&format!("({i}/{total}) Resolving {file} (round {round})"));
|
||||||
|
} else {
|
||||||
|
resolve_spin.set_text(&format!("Resolving {file} (round {round})"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
conflicts = git::rebase_continue(worktree).await?;
|
||||||
|
round += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_spin.succeed(&format!(
|
||||||
|
"Rebased {branch} onto {main} (resolved {} conflict round(s))",
|
||||||
|
round - 1
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
resolve_spin.fail(&e.to_string());
|
||||||
|
git::rebase_abort(worktree).await;
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
vm::clear_activity(worktree, branch).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
119
rust-sandlot/src/commands/review.rs
Normal file
119
rust-sandlot/src/commands/review.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::spinner::Spinner;
|
||||||
|
use crate::state;
|
||||||
|
use crate::vm;
|
||||||
|
|
||||||
|
use super::helpers::{require_session, save_changes};
|
||||||
|
|
||||||
|
pub async fn action(branch: &str, extra: Option<&str>, print: bool) -> Result<()> {
|
||||||
|
let (root, session) = require_session(branch).await;
|
||||||
|
|
||||||
|
let spin = Spinner::new("Starting container", Some(branch));
|
||||||
|
if let Err(e) = vm::ensure(&|msg| spin.set_text(msg)).await {
|
||||||
|
spin.fail(&e.to_string());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prompt = r#"
|
||||||
|
You're a grumpy old senior software engineer. You need to review some code my co-worker wrote.
|
||||||
|
|
||||||
|
Launch four agents to review the diff between this branch and main with the following specializations:
|
||||||
|
|
||||||
|
1. Checks CLAUDE.md compliance
|
||||||
|
2. Looks specifically for bugs
|
||||||
|
3. Also looks specifically for bugs
|
||||||
|
4. Looks for opportunities to simplify code
|
||||||
|
|
||||||
|
Have them focus only on the diff! + lines are added in this branch, - lines are overwritten or deleted. They must focus mostly on the + lines.
|
||||||
|
|
||||||
|
Each agent should deliver you a report in this format (the <tags> are just for you, not part of their output):
|
||||||
|
|
||||||
|
<agentOutput>
|
||||||
|
# Problem Identified
|
||||||
|
|
||||||
|
Description of problem.
|
||||||
|
</agentOutput>
|
||||||
|
|
||||||
|
Tell each agent: Run `git diff main...HEAD` and focus on the "+" lines, not the "-" lines.
|
||||||
|
|
||||||
|
Once the agents are done, look at all their suggestions and let me know what you think.
|
||||||
|
|
||||||
|
Give me your opinion in this format, with the :
|
||||||
|
|
||||||
|
<grumpySeniorDevResponse>
|
||||||
|
# {branch name} Review
|
||||||
|
|
||||||
|
**OK TO SHIP: yes or no**
|
||||||
|
|
||||||
|
# Showstoppers
|
||||||
|
|
||||||
|
1. BUG: Bug that both bug hunters found.
|
||||||
|
2. BUG: Bug that one of the hunters found.
|
||||||
|
3. COMPLIANCE: Describe CLAUDE.md compliance issue.
|
||||||
|
|
||||||
|
# Recommendations
|
||||||
|
|
||||||
|
4. BUG: Bug that both bug hunters found.
|
||||||
|
5. BUG: Bug that one of the hunters found.
|
||||||
|
6. COMPLIANCE: Describe CLAUDE.md compliance issue.
|
||||||
|
7. SIMPLIFY: Opportunities to simplify code.
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
|
||||||
|
8. BUG: Bug that both bug hunters found.
|
||||||
|
9. BUG: Bug that one of the hunters found.
|
||||||
|
10. COMPLIANCE: Describe CLAUDE.md compliance issue.
|
||||||
|
11. SIMPLIFY: Opportunities to simplify code.
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
|
||||||
|
Your thoughts, in brief.
|
||||||
|
|
||||||
|
</grumpySeniorDevResponse>
|
||||||
|
"#
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if let Some(extra_text) = extra {
|
||||||
|
prompt.push_str("\n\n");
|
||||||
|
prompt.push_str(extra_text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set in_review flag
|
||||||
|
let mut updated = session.clone();
|
||||||
|
updated.in_review = Some(true);
|
||||||
|
state::set_session(&root, updated).await.ok();
|
||||||
|
|
||||||
|
let result = if print {
|
||||||
|
spin.set_text("Running review\u{2026}");
|
||||||
|
let r = vm::claude(&session.worktree, None, Some(&prompt), false).await;
|
||||||
|
match r {
|
||||||
|
Ok((_, Some(ref output))) => {
|
||||||
|
print!("{output}\n");
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(ref e) => {
|
||||||
|
spin.fail(&e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.map(|_| ())
|
||||||
|
} else {
|
||||||
|
spin.succeed("Session ready");
|
||||||
|
vm::claude(&session.worktree, Some(&prompt), None, false)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up: clear in_review flag
|
||||||
|
spin.stop();
|
||||||
|
if let Some(fresh) = state::get_session(&root, &session.branch).await {
|
||||||
|
let mut fresh = fresh;
|
||||||
|
fresh.in_review = Some(false);
|
||||||
|
state::set_session(&root, fresh).await.ok();
|
||||||
|
}
|
||||||
|
if !print {
|
||||||
|
save_changes(&session.worktree, &session.branch, None).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
13
rust-sandlot/src/commands/save.rs
Normal file
13
rust-sandlot/src/commands/save.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::helpers::{require_session, save_changes};
|
||||||
|
|
||||||
|
pub async fn action(branch: &str, message: Option<&str>) -> Result<()> {
|
||||||
|
let (_, session) = require_session(branch).await;
|
||||||
|
|
||||||
|
let ok = save_changes(&session.worktree, branch, message).await;
|
||||||
|
if !ok {
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
16
rust-sandlot/src/commands/shell.rs
Normal file
16
rust-sandlot/src/commands/shell.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::vm;
|
||||||
|
|
||||||
|
use super::helpers::require_session;
|
||||||
|
|
||||||
|
pub async fn action(branch: Option<&str>) -> Result<()> {
|
||||||
|
if let Some(branch) = branch {
|
||||||
|
let (_, session) = require_session(branch).await;
|
||||||
|
vm::ensure(&|_| {}).await?;
|
||||||
|
vm::shell(Some(&session.worktree)).await
|
||||||
|
} else {
|
||||||
|
vm::ensure(&|_| {}).await?;
|
||||||
|
vm::shell(None).await
|
||||||
|
}
|
||||||
|
}
|
||||||
30
rust-sandlot/src/commands/show.rs
Normal file
30
rust-sandlot/src/commands/show.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
|
||||||
|
use super::helpers::require_session;
|
||||||
|
|
||||||
|
pub async fn action(branch: &str) -> Result<()> {
|
||||||
|
let (_, session) = require_session(branch).await;
|
||||||
|
|
||||||
|
if let Some(ref prompt) = session.prompt {
|
||||||
|
eprint!("PROMPT: {prompt}\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
let main = git::main_branch(Some(&session.worktree)).await?;
|
||||||
|
|
||||||
|
// Run git diff with inherited stdio
|
||||||
|
let status = std::process::Command::new("git")
|
||||||
|
.args(["-C", &session.worktree, "diff", &format!("{main}...{branch}")])
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.spawn()?
|
||||||
|
.wait()?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
std::process::exit(status.code().unwrap_or(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
83
rust-sandlot/src/commands/squash.rs
Normal file
83
rust-sandlot/src/commands/squash.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
use crate::spinner::Spinner;
|
||||||
|
use crate::vm;
|
||||||
|
|
||||||
|
use super::helpers::require_session;
|
||||||
|
|
||||||
|
pub async fn action(branch: &str) -> Result<()> {
|
||||||
|
let (_, session) = require_session(branch).await;
|
||||||
|
let worktree = &session.worktree;
|
||||||
|
|
||||||
|
if git::is_dirty(worktree).await {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Branch \"{branch}\" has unsaved changes. Run \"sandlot save {branch}\" first."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let main = git::main_branch(Some(worktree)).await?;
|
||||||
|
|
||||||
|
if !git::has_new_commits(worktree).await {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"Branch \"{branch}\" has no commits beyond {main}."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = git::merge_base(&main, "HEAD", worktree).await?;
|
||||||
|
let original_head = git::head_ref(worktree).await?;
|
||||||
|
|
||||||
|
let spin = Spinner::new("Squashing", Some(branch));
|
||||||
|
let mut did_reset = false;
|
||||||
|
|
||||||
|
let result: Result<()> = async {
|
||||||
|
git::reset_soft(&base, worktree).await?;
|
||||||
|
did_reset = true;
|
||||||
|
|
||||||
|
spin.set_text("Starting container");
|
||||||
|
vm::ensure(&|msg| spin.set_text(msg)).await?;
|
||||||
|
|
||||||
|
spin.set_text("Generating commit message");
|
||||||
|
let diff = git::diff_staged(worktree).await;
|
||||||
|
|
||||||
|
if diff.trim().is_empty() {
|
||||||
|
git::reset_soft(&original_head, worktree).await.ok();
|
||||||
|
spin.fail("No changes after squash");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (exit_code, stdout, _) = vm::claude_pipe(
|
||||||
|
&diff,
|
||||||
|
"Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let msg = if exit_code == 0 && !stdout.trim().is_empty() {
|
||||||
|
stdout
|
||||||
|
} else {
|
||||||
|
spin.set_text("AI commit message failed, using fallback");
|
||||||
|
format!("squash {branch}")
|
||||||
|
};
|
||||||
|
|
||||||
|
git::commit(&msg, worktree).await?;
|
||||||
|
spin.succeed(&format!("Squashed {branch} into a single commit"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
if !did_reset {
|
||||||
|
spin.fail(&format!("Squash failed: {e}"));
|
||||||
|
} else {
|
||||||
|
match git::reset_soft(&original_head, worktree).await {
|
||||||
|
Ok(_) => spin.fail(&format!("Squash failed, changes restored: {e}")),
|
||||||
|
Err(_) => spin.fail(&format!(
|
||||||
|
"Squash failed and rollback failed \u{2014} check \"git reflog\" in the worktree: {e}"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
12
rust-sandlot/src/commands/upgrade.rs
Normal file
12
rust-sandlot/src/commands/upgrade.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub async fn action() -> Result<()> {
|
||||||
|
let status = std::process::Command::new("bun")
|
||||||
|
.args(["install", "-g", "@because/sandlot@latest"])
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.spawn()?
|
||||||
|
.wait()?;
|
||||||
|
std::process::exit(status.code().unwrap_or(1));
|
||||||
|
}
|
||||||
222
rust-sandlot/src/commands/vm_cmd.rs
Normal file
222
rust-sandlot/src/commands/vm_cmd.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::fmt::{CYAN, DIM, GREEN, RED, RESET, YELLOW};
|
||||||
|
use crate::git;
|
||||||
|
use crate::spinner::Spinner;
|
||||||
|
use crate::state;
|
||||||
|
use crate::vm;
|
||||||
|
|
||||||
|
pub async fn create() -> Result<()> {
|
||||||
|
let spin = Spinner::new("Creating VM", None);
|
||||||
|
match vm::create(&|msg| spin.set_text(msg)).await {
|
||||||
|
Ok(()) => {
|
||||||
|
spin.succeed("VM created");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
spin.fail(&e.to_string());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start() -> Result<()> {
|
||||||
|
match vm::start().await {
|
||||||
|
Ok(()) => {
|
||||||
|
println!("\u{2714} VM started");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("\u{2716} {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn shell() -> Result<()> {
|
||||||
|
vm::ensure(&|_| {}).await?;
|
||||||
|
vm::shell(None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn status(json: bool) -> Result<()> {
|
||||||
|
let s = vm::status().await;
|
||||||
|
let sessions = state::load_all().await;
|
||||||
|
|
||||||
|
if json {
|
||||||
|
let json_val = serde_json::json!({
|
||||||
|
"vm": s,
|
||||||
|
"sessions": sessions.iter().map(|gs| {
|
||||||
|
let mut v = serde_json::to_value(&gs.session).unwrap_or_default();
|
||||||
|
if let Some(obj) = v.as_object_mut() {
|
||||||
|
obj.insert("repoRoot".to_string(), serde_json::Value::String(gs.repo_root.clone()));
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}).collect::<Vec<_>>(),
|
||||||
|
});
|
||||||
|
println!("{}", serde_json::to_string_pretty(&json_val)?);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let status_colors: HashMap<&str, &str> =
|
||||||
|
[("running", GREEN), ("stopped", YELLOW), ("missing", RED)]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let color = status_colors.get(s).copied().unwrap_or("");
|
||||||
|
println!("{DIM}VM:{RESET} {color}{s}{RESET}");
|
||||||
|
|
||||||
|
if sessions.is_empty() {
|
||||||
|
println!("\n{DIM}No active sessions.{RESET}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine statuses
|
||||||
|
let mut statuses: HashMap<String, &str> = HashMap::new();
|
||||||
|
for sess in &sessions {
|
||||||
|
let repo_name = Path::new(&sess.repo_root)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let key = format!("{repo_name}/{}", sess.session.branch);
|
||||||
|
let status = if vm::is_claude_active(&sess.session.worktree, &sess.session.branch).await {
|
||||||
|
"active"
|
||||||
|
} else if git::is_dirty(&sess.session.worktree).await {
|
||||||
|
"dirty"
|
||||||
|
} else if git::has_new_commits(&sess.session.worktree).await {
|
||||||
|
"saved"
|
||||||
|
} else {
|
||||||
|
"idle"
|
||||||
|
};
|
||||||
|
statuses.insert(key, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
let icons: HashMap<&str, String> = [
|
||||||
|
("idle", format!("{DIM}\u{25EF}{RESET}")),
|
||||||
|
("active", format!("{CYAN}\u{25CE}{RESET}")),
|
||||||
|
("dirty", format!("{YELLOW}\u{25D0}{RESET}")),
|
||||||
|
("saved", format!("{GREEN}\u{25CF}{RESET}")),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let branch_colors: HashMap<&str, &str> = [
|
||||||
|
("idle", DIM),
|
||||||
|
("active", CYAN),
|
||||||
|
("dirty", YELLOW),
|
||||||
|
("saved", GREEN),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let repo_width = sessions
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
Path::new(&s.repo_root)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.len()
|
||||||
|
})
|
||||||
|
.max()
|
||||||
|
.unwrap_or(4)
|
||||||
|
.max(4);
|
||||||
|
let branch_width = sessions
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.session.branch.len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(6)
|
||||||
|
.max(6);
|
||||||
|
let cols = crate::fmt::terminal_width();
|
||||||
|
let prefix_width = repo_width + branch_width + 6;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n {DIM}{:repo_width$} {:branch_width$} PROMPT{RESET}",
|
||||||
|
"REPO", "BRANCH"
|
||||||
|
);
|
||||||
|
|
||||||
|
for sess in &sessions {
|
||||||
|
let repo_name = Path::new(&sess.repo_root)
|
||||||
|
.file_name()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
let prompt = sess
|
||||||
|
.session
|
||||||
|
.prompt
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
let key = format!("{repo_name}/{}", sess.session.branch);
|
||||||
|
let status = statuses.get(key.as_str()).copied().unwrap_or("idle");
|
||||||
|
let icon = icons.get(status).cloned().unwrap_or_default();
|
||||||
|
let bc = branch_colors.get(status).copied().unwrap_or(DIM);
|
||||||
|
let max_prompt = if cols > prefix_width {
|
||||||
|
cols - prefix_width
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let truncated = if max_prompt > 3 && prompt.len() > max_prompt {
|
||||||
|
format!("{}...", &prompt[..max_prompt - 3])
|
||||||
|
} else {
|
||||||
|
prompt.to_string()
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{icon} {DIM}{:repo_width$}{RESET} {bc}{:branch_width$}{RESET} {DIM}{truncated}{RESET}",
|
||||||
|
repo_name, sess.session.branch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"\n{DIM}\u{25EF} idle{RESET} \u{00B7} {CYAN}\u{25CE} active{RESET} \u{00B7} {YELLOW}\u{25D0} unsaved{RESET} \u{00B7} {GREEN}\u{25CF} saved{RESET}"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn info() -> Result<()> {
|
||||||
|
vm::ensure(&|_| {}).await?;
|
||||||
|
vm::neofetch().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn stop() -> Result<()> {
|
||||||
|
let spin = Spinner::new("Stopping VM", None);
|
||||||
|
match vm::stop().await {
|
||||||
|
Ok(()) => {
|
||||||
|
spin.succeed("VM stopped");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
spin.fail(&e.to_string());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn destroy() -> Result<()> {
|
||||||
|
let spin = Spinner::new("Destroying VM", None);
|
||||||
|
match vm::destroy().await {
|
||||||
|
Ok(()) => {
|
||||||
|
spin.succeed("VM destroyed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
spin.fail(&e.to_string());
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn uncache() -> Result<()> {
|
||||||
|
let had = vm::clear_cache().await;
|
||||||
|
if had {
|
||||||
|
println!("\u{2714} Package cache cleared");
|
||||||
|
} else {
|
||||||
|
println!("No cache to clear");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
111
rust-sandlot/src/commands/web.rs
Normal file
111
rust-sandlot/src/commands/web.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::git;
|
||||||
|
|
||||||
|
use super::helpers::require_session;
|
||||||
|
|
||||||
|
const TEMPLATE: &str = include_str!("diff.html");
|
||||||
|
|
||||||
|
fn escape_html(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_stat(raw: &str) -> String {
|
||||||
|
let trimmed: String = raw
|
||||||
|
.lines()
|
||||||
|
.map(|l| {
|
||||||
|
if let Some(stripped) = l.strip_prefix(' ') {
|
||||||
|
stripped
|
||||||
|
} else {
|
||||||
|
l
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let escaped = escape_html(&trimmed);
|
||||||
|
escaped
|
||||||
|
.lines()
|
||||||
|
.map(|line| {
|
||||||
|
if let Some(pipe_pos) = line.find('|') {
|
||||||
|
let before = &line[..pipe_pos + 1];
|
||||||
|
let after = &line[pipe_pos + 1..];
|
||||||
|
let colored: String = after
|
||||||
|
.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'+' => "<span style=\"color:var(--add)\">+</span>".to_string(),
|
||||||
|
'-' => "<span style=\"color:var(--remove)\">-</span>".to_string(),
|
||||||
|
_ => c.to_string(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("{before}{colored}")
|
||||||
|
} else {
|
||||||
|
line.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn action(branch: &str) -> Result<()> {
|
||||||
|
let (_, session) = require_session(branch).await;
|
||||||
|
let worktree = &session.worktree;
|
||||||
|
let main = git::main_branch(Some(worktree)).await?;
|
||||||
|
|
||||||
|
let log_range = format!("{main}..{branch}");
|
||||||
|
let stat_range = format!("{main}...{branch}");
|
||||||
|
let (diff, log, stat) = tokio::join!(
|
||||||
|
git::branch_diff(branch, &main, worktree),
|
||||||
|
git::commit_log(&log_range, worktree),
|
||||||
|
git::diff_stat(&stat_range, worktree),
|
||||||
|
);
|
||||||
|
|
||||||
|
if diff.trim().is_empty() {
|
||||||
|
crate::fmt::die(&format!(
|
||||||
|
"No changes on branch \"{branch}\" compared to {main}."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let diff_json = serde_json::to_string(&diff)?.replace('<', "\\u003c");
|
||||||
|
|
||||||
|
let prompt_section = match &session.prompt {
|
||||||
|
Some(p) => format!("<p class=\"prompt\">{}</p>", escape_html(p)),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
let log_section = if !log.is_empty() {
|
||||||
|
format!(
|
||||||
|
"<div class=\"meta-section\"><h3>Commits</h3><pre>{}</pre></div>",
|
||||||
|
escape_html(&log)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
let stat_section = if !stat.is_empty() {
|
||||||
|
format!(
|
||||||
|
"<div class=\"meta-section\"><h3>Stats</h3><pre>{}</pre></div>",
|
||||||
|
format_stat(&stat)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = TEMPLATE
|
||||||
|
.replace("{{BRANCH}}", &escape_html(branch))
|
||||||
|
.replace("{{PROMPT_SECTION}}", &prompt_section)
|
||||||
|
.replace("{{LOG_SECTION}}", &log_section)
|
||||||
|
.replace("{{STAT_SECTION}}", &stat_section)
|
||||||
|
.replace("{{DIFF_JSON}}", &diff_json);
|
||||||
|
|
||||||
|
let tmp_path = format!("/tmp/sandlot-{branch}.html");
|
||||||
|
tokio::fs::write(&tmp_path, &html).await?;
|
||||||
|
let _ = tokio::process::Command::new("open")
|
||||||
|
.arg(&tmp_path)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
65
rust-sandlot/src/config.rs
Normal file
65
rust-sandlot/src/config.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
const MIN_MEMORY_MB: u64 = 512;
|
||||||
|
|
||||||
|
pub const DEFAULTS_MEMORY: &str = "16G";
|
||||||
|
pub const VALID_KEYS: &[&str] = &["memory"];
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub memory: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_dir() -> std::path::PathBuf {
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("cannot find home directory")
|
||||||
|
.join(".config")
|
||||||
|
.join("sandlot")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_path() -> std::path::PathBuf {
|
||||||
|
config_dir().join("config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_memory(v: &str) -> Result<String> {
|
||||||
|
let re = Regex::new(r"^[1-9]\d*[GMgm]$").unwrap();
|
||||||
|
if !re.is_match(v) {
|
||||||
|
bail!("Invalid memory value: {v} (must be a number followed by G or M, e.g. 16G)");
|
||||||
|
}
|
||||||
|
let num: u64 = v[..v.len() - 1].parse().unwrap();
|
||||||
|
let unit = v.chars().last().unwrap().to_ascii_uppercase();
|
||||||
|
let mb = if unit == 'G' { num * 1024 } else { num };
|
||||||
|
if mb < MIN_MEMORY_MB {
|
||||||
|
bail!("Memory too low: {v} (minimum {MIN_MEMORY_MB}M)");
|
||||||
|
}
|
||||||
|
Ok(v.to_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load() -> Config {
|
||||||
|
let path = config_path();
|
||||||
|
match tokio::fs::read_to_string(&path).await {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
|
Err(_) => Config::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(config: &Config) -> Result<()> {
|
||||||
|
let dir = config_dir();
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
let json = serde_json::to_string_pretty(config)? + "\n";
|
||||||
|
tokio::fs::write(config_path(), json).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_memory() -> Option<String> {
|
||||||
|
load().await.memory
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_memory(value: String) -> Result<()> {
|
||||||
|
let mut config = load().await;
|
||||||
|
config.memory = Some(value);
|
||||||
|
save(&config).await
|
||||||
|
}
|
||||||
20
rust-sandlot/src/env.rs
Normal file
20
rust-sandlot/src/env.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
/// Read the ANTHROPIC_API_KEY from ~/.env. Returns None if not found.
|
||||||
|
pub async fn get_api_key() -> Option<String> {
|
||||||
|
let home = dirs::home_dir()?;
|
||||||
|
let env_file = home.join(".env");
|
||||||
|
let content = tokio::fs::read_to_string(&env_file).await.ok()?;
|
||||||
|
let re = Regex::new(r#"(?m)^(?:export\s+)?ANTHROPIC_API_KEY=["']?([^"'\s]+)["']?"#).ok()?;
|
||||||
|
re.captures(&content)
|
||||||
|
.and_then(|c| c.get(1))
|
||||||
|
.map(|m| m.as_str().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the ANTHROPIC_API_KEY from ~/.env, dying if not found.
|
||||||
|
pub async fn require_api_key() -> String {
|
||||||
|
match get_api_key().await {
|
||||||
|
Some(key) => key,
|
||||||
|
None => crate::fmt::die("ANTHROPIC_API_KEY not found in ~/.env"),
|
||||||
|
}
|
||||||
|
}
|
||||||
104
rust-sandlot/src/fmt.rs
Normal file
104
rust-sandlot/src/fmt.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
// ── ANSI escape codes ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const RESET: &str = "\x1b[0m";
|
||||||
|
pub const DIM: &str = "\x1b[2m";
|
||||||
|
pub const GREEN: &str = "\x1b[32m";
|
||||||
|
pub const YELLOW: &str = "\x1b[33m";
|
||||||
|
pub const RED: &str = "\x1b[31m";
|
||||||
|
pub const MAGENTA: &str = "\x1b[35m";
|
||||||
|
pub const CYAN: &str = "\x1b[36m";
|
||||||
|
|
||||||
|
// ── Formatted output ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn die(message: &str) -> ! {
|
||||||
|
eprint!("\u{2716} {message}\n");
|
||||||
|
std::process::exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn success(message: &str) {
|
||||||
|
eprint!("\u{2714} {message}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info(message: &str) {
|
||||||
|
eprint!("\u{25C6} {message}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pager ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn pager(content: &str) {
|
||||||
|
let lines = content.split('\n').count();
|
||||||
|
let term_height = terminal_height();
|
||||||
|
if lines > term_height {
|
||||||
|
let mut child = match tokio::process::Command::new("less")
|
||||||
|
.arg("-R")
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
print!("{content}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
let _ = stdin.write_all(content.as_bytes()).await;
|
||||||
|
drop(stdin);
|
||||||
|
}
|
||||||
|
let _ = child.wait().await;
|
||||||
|
} else {
|
||||||
|
print!("{content}");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminal_height() -> usize {
|
||||||
|
// Try to get terminal size
|
||||||
|
if let Ok(s) = std::env::var("LINES") {
|
||||||
|
if let Ok(n) = s.parse::<usize>() {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use ioctl
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::mem::MaybeUninit;
|
||||||
|
unsafe {
|
||||||
|
let mut ws = MaybeUninit::<libc::winsize>::uninit();
|
||||||
|
if libc::ioctl(1, libc::TIOCGWINSZ, ws.as_mut_ptr()) == 0 {
|
||||||
|
let ws = ws.assume_init();
|
||||||
|
if ws.ws_row > 0 {
|
||||||
|
return ws.ws_row as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
24
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn terminal_width() -> usize {
|
||||||
|
if let Ok(s) = std::env::var("COLUMNS") {
|
||||||
|
if let Ok(n) = s.parse::<usize>() {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::mem::MaybeUninit;
|
||||||
|
unsafe {
|
||||||
|
let mut ws = MaybeUninit::<libc::winsize>::uninit();
|
||||||
|
if libc::ioctl(1, libc::TIOCGWINSZ, ws.as_mut_ptr()) == 0 {
|
||||||
|
let ws = ws.assume_init();
|
||||||
|
if ws.ws_col > 0 {
|
||||||
|
return ws.ws_col as usize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
80
|
||||||
|
}
|
||||||
435
rust-sandlot/src/git.rs
Normal file
435
rust-sandlot/src/git.rs
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
/// Format a git error with a fallback for empty stderr.
|
||||||
|
fn git_error(action: &str, stderr: &str) -> anyhow::Error {
|
||||||
|
let msg = stderr.trim();
|
||||||
|
if msg.is_empty() {
|
||||||
|
anyhow::anyhow!("{action}: (no output)")
|
||||||
|
} else {
|
||||||
|
anyhow::anyhow!("{action}: {msg}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_git_nothrow(cwd: &str, args: &[&str]) -> (i32, String, String) {
|
||||||
|
match Command::new("git")
|
||||||
|
.current_dir(cwd)
|
||||||
|
.args(args)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(output) => (
|
||||||
|
output.status.code().unwrap_or(1),
|
||||||
|
String::from_utf8_lossy(&output.stdout).to_string(),
|
||||||
|
String::from_utf8_lossy(&output.stderr).to_string(),
|
||||||
|
),
|
||||||
|
Err(_) => (1, String::new(), String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the repo root from a working directory.
|
||||||
|
pub async fn repo_root(cwd: Option<&str>) -> Result<String> {
|
||||||
|
let dir = cwd.unwrap_or(".");
|
||||||
|
let (code, stdout, _) = run_git_nothrow(dir, &["rev-parse", "--show-toplevel"]).await;
|
||||||
|
if code != 0 {
|
||||||
|
bail!("Not a git repository. Run this command from inside a git repo.");
|
||||||
|
}
|
||||||
|
Ok(stdout.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the current branch name.
|
||||||
|
pub async fn current_branch(cwd: Option<&str>) -> Result<String> {
|
||||||
|
let dir = cwd.unwrap_or(".");
|
||||||
|
let (code, stdout, _) = run_git_nothrow(dir, &["rev-parse", "--abbrev-ref", "HEAD"]).await;
|
||||||
|
if code != 0 {
|
||||||
|
bail!("Could not determine current branch.");
|
||||||
|
}
|
||||||
|
Ok(stdout.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a branch exists locally or remotely. Returns "local", "remote", or None.
|
||||||
|
pub async fn branch_exists(branch: &str, cwd: Option<&str>, fetch: bool) -> Option<&'static str> {
|
||||||
|
let dir = cwd.unwrap_or(".");
|
||||||
|
let local_ref = format!("refs/heads/{branch}");
|
||||||
|
let (code, _, _) = run_git_nothrow(dir, &["show-ref", "--verify", "--quiet", &local_ref]).await;
|
||||||
|
if code == 0 {
|
||||||
|
return Some("local");
|
||||||
|
}
|
||||||
|
|
||||||
|
if fetch {
|
||||||
|
let _ = run_git_nothrow(dir, &["fetch", "origin"]).await;
|
||||||
|
}
|
||||||
|
let remote_ref = format!("refs/remotes/origin/{branch}");
|
||||||
|
let (code, _, _) =
|
||||||
|
run_git_nothrow(dir, &["show-ref", "--verify", "--quiet", &remote_ref]).await;
|
||||||
|
if code == 0 {
|
||||||
|
return Some("remote");
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a worktree for the given branch.
|
||||||
|
pub async fn create_worktree(
|
||||||
|
branch: &str,
|
||||||
|
worktree_path: &str,
|
||||||
|
cwd: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
// Clean up stale worktree path if it exists
|
||||||
|
if Path::new(worktree_path).exists() {
|
||||||
|
let _ = run_git_nothrow(cwd, &["worktree", "remove", worktree_path, "--force"]).await;
|
||||||
|
if Path::new(worktree_path).exists() {
|
||||||
|
tokio::fs::remove_dir_all(worktree_path).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = run_git_nothrow(cwd, &["worktree", "prune"]).await;
|
||||||
|
|
||||||
|
let exists = branch_exists(branch, Some(cwd), true).await;
|
||||||
|
|
||||||
|
let mut switched_from_branch = false;
|
||||||
|
let (code, _, stderr) = match exists {
|
||||||
|
Some("local") => {
|
||||||
|
let main = main_branch(Some(cwd)).await?;
|
||||||
|
if branch == main {
|
||||||
|
bail!("Cannot create a worktree for the main branch \"{main}\".");
|
||||||
|
}
|
||||||
|
// If the branch is checked out in the main worktree, switch it to main first
|
||||||
|
if current_branch(Some(cwd)).await? == branch {
|
||||||
|
if is_dirty(cwd).await {
|
||||||
|
bail!("Cannot move branch \"{branch}\" to a worktree: the main worktree has uncommitted changes. Commit or stash them first.");
|
||||||
|
}
|
||||||
|
checkout(&main, cwd).await?;
|
||||||
|
switched_from_branch = true;
|
||||||
|
}
|
||||||
|
run_git_nothrow(cwd, &["worktree", "add", worktree_path, branch]).await
|
||||||
|
}
|
||||||
|
Some("remote") => {
|
||||||
|
let tracking = format!("origin/{branch}");
|
||||||
|
run_git_nothrow(
|
||||||
|
cwd,
|
||||||
|
&["worktree", "add", worktree_path, "-b", branch, &tracking],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// New branch from current HEAD
|
||||||
|
run_git_nothrow(cwd, &["worktree", "add", "-b", branch, worktree_path]).await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if code != 0 {
|
||||||
|
if switched_from_branch {
|
||||||
|
let _ = checkout(branch, cwd).await;
|
||||||
|
}
|
||||||
|
return Err(git_error(
|
||||||
|
&format!("Failed to create worktree for \"{branch}\""),
|
||||||
|
&stderr,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(exists != Some("local"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a worktree. Silently succeeds if the worktree is already gone.
|
||||||
|
pub async fn remove_worktree(worktree_path: &str, cwd: &str) -> Result<()> {
|
||||||
|
let (code, _, _) =
|
||||||
|
run_git_nothrow(cwd, &["worktree", "remove", worktree_path, "--force"]).await;
|
||||||
|
if code != 0 {
|
||||||
|
let _ = run_git_nothrow(cwd, &["worktree", "prune"]).await;
|
||||||
|
if Path::new(worktree_path).exists() {
|
||||||
|
tokio::fs::remove_dir_all(worktree_path).await.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a local branch.
|
||||||
|
pub async fn delete_local_branch(branch: &str, cwd: &str) {
|
||||||
|
let _ = run_git_nothrow(cwd, &["branch", "-D", branch]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checkout a branch.
|
||||||
|
pub async fn checkout(branch: &str, cwd: &str) -> Result<()> {
|
||||||
|
let (code, _, stderr) = run_git_nothrow(cwd, &["checkout", branch]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return Err(git_error(
|
||||||
|
&format!("Failed to checkout branch \"{branch}\""),
|
||||||
|
&stderr,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge a branch into the current branch. Returns conflicted file paths, or empty vec if clean.
|
||||||
|
pub async fn merge(branch: &str, cwd: &str) -> Result<Vec<String>> {
|
||||||
|
let (code, _, stderr) = run_git_nothrow(cwd, &["merge", branch]).await;
|
||||||
|
if code == 0 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, unmerged, _) =
|
||||||
|
run_git_nothrow(cwd, &["diff", "--name-only", "--diff-filter=U"]).await;
|
||||||
|
let files: Vec<String> = unmerged
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
if !files.is_empty() {
|
||||||
|
return Ok(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(git_error(
|
||||||
|
&format!("Failed to merge branch \"{branch}\""),
|
||||||
|
&stderr,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the staged diff as text.
|
||||||
|
pub async fn diff_staged(cwd: &str) -> String {
|
||||||
|
let (_, stdout, _) = run_git_nothrow(cwd, &["diff", "--staged"]).await;
|
||||||
|
stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commit staged changes with a message.
|
||||||
|
pub async fn commit(message: &str, cwd: &str) -> Result<()> {
|
||||||
|
let (code, _, stderr) = run_git_nothrow(cwd, &["commit", "-m", message]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return Err(git_error("Failed to commit", &stderr));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept "theirs" version of a conflicted file.
|
||||||
|
pub async fn checkout_theirs(file: &str, cwd: &str) -> Result<()> {
|
||||||
|
let (code, _, stderr) = run_git_nothrow(cwd, &["checkout", "--theirs", "--", file]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return Err(git_error(
|
||||||
|
&format!("Failed to checkout theirs for {file}"),
|
||||||
|
&stderr,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage a file.
|
||||||
|
pub async fn stage_file(file: &str, cwd: &str) -> Result<()> {
|
||||||
|
let (code, _, stderr) = run_git_nothrow(cwd, &["add", file]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return Err(git_error(&format!("Failed to stage {file}"), &stderr));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalize a merge commit after resolving conflicts.
|
||||||
|
pub async fn commit_merge(cwd: &str) -> Result<()> {
|
||||||
|
let (code, _, stderr) = run_git_nothrow(cwd, &["commit", "--no-edit"]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return Err(git_error("Failed to commit merge", &stderr));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abort an in-progress merge.
|
||||||
|
pub async fn abort_merge(cwd: &str) {
|
||||||
|
let _ = run_git_nothrow(cwd, &["merge", "--abort"]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-reset to a given ref (keeps changes staged).
|
||||||
|
pub async fn reset_soft(reference: &str, cwd: &str) -> Result<()> {
|
||||||
|
let (code, _, stderr) = run_git_nothrow(cwd, &["reset", "--soft", reference]).await;
|
||||||
|
if code != 0 {
|
||||||
|
bail!(
|
||||||
|
"Failed to reset to \"{reference}\": {}",
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the full SHA of HEAD.
|
||||||
|
pub async fn head_ref(cwd: &str) -> Result<String> {
|
||||||
|
let (code, stdout, _) = run_git_nothrow(cwd, &["rev-parse", "HEAD"]).await;
|
||||||
|
if code != 0 {
|
||||||
|
bail!("Could not resolve HEAD.");
|
||||||
|
}
|
||||||
|
Ok(stdout.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebase the current branch onto another. Returns conflicted file paths, or empty vec if clean.
|
||||||
|
pub async fn rebase(onto: &str, cwd: &str) -> Result<Vec<String>> {
|
||||||
|
// Check for existing rebase state
|
||||||
|
let (_, rebase_merge, _) =
|
||||||
|
run_git_nothrow(cwd, &["rev-parse", "--git-path", "rebase-merge"]).await;
|
||||||
|
let (_, rebase_apply, _) =
|
||||||
|
run_git_nothrow(cwd, &["rev-parse", "--git-path", "rebase-apply"]).await;
|
||||||
|
if Path::new(rebase_merge.trim()).exists() || Path::new(rebase_apply.trim()).exists() {
|
||||||
|
bail!(
|
||||||
|
"A rebase is already in progress. Run \"git -C {cwd} rebase --abort\" to cancel it first."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (code, _, stderr) = run_git_nothrow(cwd, &["rebase", onto]).await;
|
||||||
|
if code == 0 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, unmerged, _) =
|
||||||
|
run_git_nothrow(cwd, &["diff", "--name-only", "--diff-filter=U"]).await;
|
||||||
|
let files: Vec<String> = unmerged
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
if !files.is_empty() {
|
||||||
|
return Ok(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = stderr.trim();
|
||||||
|
bail!(
|
||||||
|
"Rebase onto \"{onto}\" failed: {}",
|
||||||
|
if msg.is_empty() {
|
||||||
|
"(no output from git)"
|
||||||
|
} else {
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Continue a rebase after resolving conflicts. Returns conflicted files for the next commit, or empty if done.
|
||||||
|
pub async fn rebase_continue(cwd: &str) -> Result<Vec<String>> {
|
||||||
|
let (code, _, stderr) =
|
||||||
|
run_git_nothrow(cwd, &["-c", "core.editor=true", "rebase", "--continue"]).await;
|
||||||
|
if code == 0 {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (_, unmerged, _) =
|
||||||
|
run_git_nothrow(cwd, &["diff", "--name-only", "--diff-filter=U"]).await;
|
||||||
|
let files: Vec<String> = unmerged
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
if !files.is_empty() {
|
||||||
|
return Ok(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(git_error("Rebase --continue failed", &stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abort an in-progress rebase.
|
||||||
|
pub async fn rebase_abort(cwd: &str) {
|
||||||
|
let _ = run_git_nothrow(cwd, &["rebase", "--abort"]).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a worktree has uncommitted changes.
|
||||||
|
pub async fn is_dirty(worktree_path: &str) -> bool {
|
||||||
|
let (code, stdout, _) = run_git_nothrow(
|
||||||
|
".",
|
||||||
|
&["-C", worktree_path, "status", "--porcelain"],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if code != 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!stdout.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the merge base (common ancestor) between two refs.
|
||||||
|
pub async fn merge_base(ref1: &str, ref2: &str, cwd: &str) -> Result<String> {
|
||||||
|
let (code, stdout, _) = run_git_nothrow(cwd, &["merge-base", ref1, ref2]).await;
|
||||||
|
if code != 0 {
|
||||||
|
bail!("Could not find merge base between \"{ref1}\" and \"{ref2}\"");
|
||||||
|
}
|
||||||
|
Ok(stdout.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a one-line-per-commit log for a revision range.
|
||||||
|
pub async fn commit_log(range: &str, cwd: &str) -> String {
|
||||||
|
let (code, stdout, _) = run_git_nothrow(cwd, &["log", "--oneline", range]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
stdout.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a diff stat summary for a revision range.
|
||||||
|
pub async fn diff_stat(range: &str, cwd: &str) -> String {
|
||||||
|
let (code, stdout, _) =
|
||||||
|
run_git_nothrow(cwd, &["diff", "--stat", "--stat-width=68", range]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
stdout.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the diff for a specific file between two refs.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn file_diff(ref1: &str, ref2: &str, file: &str, cwd: &str) -> String {
|
||||||
|
let (code, stdout, _) =
|
||||||
|
run_git_nothrow(cwd, &["diff", ref1, ref2, "--", file]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
stdout.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a branch has commits beyond main.
|
||||||
|
pub async fn has_new_commits(worktree_path: &str) -> bool {
|
||||||
|
let main = match main_branch(Some(worktree_path)).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let range = format!("{main}..HEAD");
|
||||||
|
let (code, stdout, _) =
|
||||||
|
run_git_nothrow(".", &["-C", worktree_path, "rev-list", &range, "--count"]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
stdout.trim().parse::<u64>().unwrap_or(0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the full unified diff of a branch vs main as a string.
|
||||||
|
pub async fn branch_diff(branch: &str, main: &str, cwd: &str) -> String {
|
||||||
|
let range = format!("{main}...{branch}");
|
||||||
|
let (code, stdout, _) = run_git_nothrow(cwd, &["diff", "--no-ext-diff", &range]).await;
|
||||||
|
if code != 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the main branch name (main or master).
|
||||||
|
pub async fn main_branch(cwd: Option<&str>) -> Result<String> {
|
||||||
|
let dir = cwd.unwrap_or(".");
|
||||||
|
let (code, _, _) = run_git_nothrow(
|
||||||
|
".",
|
||||||
|
&["-C", dir, "rev-parse", "--verify", "--quiet", "refs/heads/main"],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if code == 0 {
|
||||||
|
return Ok("main".to_string());
|
||||||
|
}
|
||||||
|
let (code, _, _) = run_git_nothrow(
|
||||||
|
".",
|
||||||
|
&[
|
||||||
|
"-C",
|
||||||
|
dir,
|
||||||
|
"rev-parse",
|
||||||
|
"--verify",
|
||||||
|
"--quiet",
|
||||||
|
"refs/heads/master",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if code == 0 {
|
||||||
|
return Ok("master".to_string());
|
||||||
|
}
|
||||||
|
bail!("Could not detect main branch: neither \"main\" nor \"master\" exists.")
|
||||||
|
}
|
||||||
341
rust-sandlot/src/main.rs
Normal file
341
rust-sandlot/src/main.rs
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
mod commands;
|
||||||
|
mod config;
|
||||||
|
mod env;
|
||||||
|
mod fmt;
|
||||||
|
mod git;
|
||||||
|
mod markdown;
|
||||||
|
mod spinner;
|
||||||
|
mod state;
|
||||||
|
mod vm;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "sandlot",
|
||||||
|
about = "Sandboxed development with Claude.",
|
||||||
|
disable_version_flag = true
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(short = 'V', long = "version")]
|
||||||
|
version: bool,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Commands>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Show all active sessions
|
||||||
|
List {
|
||||||
|
/// Output as JSON
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
/// Show sessions across all projects
|
||||||
|
#[arg(short, long)]
|
||||||
|
all: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Create a new session and launch Claude
|
||||||
|
New {
|
||||||
|
/// branch name or prompt (if it contains spaces)
|
||||||
|
branch: Option<String>,
|
||||||
|
/// initial prompt for Claude
|
||||||
|
prompt: Option<String>,
|
||||||
|
/// run Claude in non-interactive mode with -p
|
||||||
|
#[arg(short, long)]
|
||||||
|
print: Option<String>,
|
||||||
|
/// skip auto-save after Claude exits
|
||||||
|
#[arg(short = 'n', long = "no-save")]
|
||||||
|
no_save: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Open an existing Claude session
|
||||||
|
Open {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
/// initial prompt for Claude
|
||||||
|
prompt: Option<String>,
|
||||||
|
/// run Claude in non-interactive mode with -p
|
||||||
|
#[arg(short, long)]
|
||||||
|
print: Option<String>,
|
||||||
|
/// skip auto-save after Claude exits
|
||||||
|
#[arg(short = 'n', long = "no-save")]
|
||||||
|
no_save: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Remove a worktree and clean up the session
|
||||||
|
Close {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
/// close even if there are unsaved changes
|
||||||
|
#[arg(short, long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Remove a session (alias for close)
|
||||||
|
#[command(hide = true)]
|
||||||
|
Rm {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
/// close even if there are unsaved changes
|
||||||
|
#[arg(short, long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Close the session and check out the branch locally
|
||||||
|
#[command(alias = "co")]
|
||||||
|
Checkout {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
/// checkout even if there are unsaved changes
|
||||||
|
#[arg(short, long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Branch Commands ──
|
||||||
|
|
||||||
|
/// Show uncommitted changes, or full branch diff vs main
|
||||||
|
Diff {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show commits on a branch that are not on main
|
||||||
|
Log {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Show the prompt and full diff for a branch
|
||||||
|
Show {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Open the branch diff in a web browser
|
||||||
|
Web {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Stage all changes and commit
|
||||||
|
Save {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
/// commit message (AI-generated if omitted)
|
||||||
|
message: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Merge a branch into main and close the session
|
||||||
|
Merge {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
/// allow merging into a non-main branch
|
||||||
|
#[arg(short, long)]
|
||||||
|
force: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Squash all commits on a branch into a single commit
|
||||||
|
Squash {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Rebase a branch onto the latest main
|
||||||
|
Rebase {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Launch an interactive grumpy code review for a branch
|
||||||
|
Review {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
/// additional instructions to append to the review prompt
|
||||||
|
prompt: Option<String>,
|
||||||
|
/// print the review to stdout instead of launching interactive mode
|
||||||
|
#[arg(short, long)]
|
||||||
|
print: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Open a shell in the VM
|
||||||
|
Shell {
|
||||||
|
/// branch name (omit for a plain VM shell)
|
||||||
|
branch: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Open a file from a session in $EDITOR
|
||||||
|
Edit {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
/// file path relative to worktree root
|
||||||
|
file: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Print the worktree path for a session
|
||||||
|
Dir {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Change to a branch's worktree directory
|
||||||
|
Cd {
|
||||||
|
/// branch name
|
||||||
|
branch: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Admin Commands ──
|
||||||
|
|
||||||
|
/// Get or set configuration (e.g. sandlot config memory 16G)
|
||||||
|
Config {
|
||||||
|
/// key [value]
|
||||||
|
args: Vec<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Remove stale sessions whose worktrees no longer exist
|
||||||
|
Cleanup,
|
||||||
|
|
||||||
|
/// Manage the sandlot VM
|
||||||
|
Vm {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: VmCommands,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Upgrade sandlot to the latest version
|
||||||
|
Upgrade,
|
||||||
|
|
||||||
|
/// Print the version number
|
||||||
|
Version,
|
||||||
|
|
||||||
|
/// Output fish shell completions
|
||||||
|
Completions {
|
||||||
|
/// Output a shell script that installs the completions file
|
||||||
|
#[arg(long)]
|
||||||
|
install: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Print shell init script (eval in your shell config)
|
||||||
|
Init {
|
||||||
|
/// shell type (fish, bash, zsh)
|
||||||
|
shell: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum VmCommands {
|
||||||
|
/// Create and provision the VM
|
||||||
|
Create,
|
||||||
|
/// Start the VM
|
||||||
|
Start,
|
||||||
|
/// Open a shell in the VM
|
||||||
|
Shell,
|
||||||
|
/// Show VM status and all sessions across repos
|
||||||
|
Status {
|
||||||
|
/// Output as JSON
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// Show VM system info (via neofetch)
|
||||||
|
Info,
|
||||||
|
/// Stop the VM
|
||||||
|
Stop,
|
||||||
|
/// Stop and delete the VM
|
||||||
|
Destroy,
|
||||||
|
/// Clear the package cache (next create will re-download)
|
||||||
|
Uncache,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Default: `sandlot` → `sandlot list`
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let effective_args = if args.len() <= 1 {
|
||||||
|
vec![args[0].clone(), "list".to_string()]
|
||||||
|
} else {
|
||||||
|
args
|
||||||
|
};
|
||||||
|
|
||||||
|
let cli = match Cli::try_parse_from(&effective_args) {
|
||||||
|
Ok(cli) => cli,
|
||||||
|
Err(e) => {
|
||||||
|
// clap handles --help and error display
|
||||||
|
e.exit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if cli.version {
|
||||||
|
let parts: Vec<&str> = VERSION.split('.').collect();
|
||||||
|
println!("v{}", parts.last().unwrap_or(&VERSION));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = match cli.command.unwrap_or(Commands::List {
|
||||||
|
json: false,
|
||||||
|
all: false,
|
||||||
|
}) {
|
||||||
|
Commands::List { json, all } => commands::list::action(json, all).await,
|
||||||
|
Commands::New {
|
||||||
|
branch,
|
||||||
|
prompt,
|
||||||
|
print,
|
||||||
|
no_save,
|
||||||
|
} => commands::new::action(branch, prompt, print, !no_save).await,
|
||||||
|
Commands::Open {
|
||||||
|
branch,
|
||||||
|
prompt,
|
||||||
|
print,
|
||||||
|
no_save,
|
||||||
|
} => commands::open::action(branch, prompt, print, !no_save).await,
|
||||||
|
Commands::Close { branch, force } | Commands::Rm { branch, force } => {
|
||||||
|
commands::close::action(&branch, force).await
|
||||||
|
}
|
||||||
|
Commands::Checkout { branch, force } => commands::checkout::action(&branch, force).await,
|
||||||
|
Commands::Diff { branch } => commands::diff::action(&branch).await,
|
||||||
|
Commands::Log { branch } => commands::log::action(&branch).await,
|
||||||
|
Commands::Show { branch } => commands::show::action(&branch).await,
|
||||||
|
Commands::Web { branch } => commands::web::action(&branch).await,
|
||||||
|
Commands::Save { branch, message } => {
|
||||||
|
commands::save::action(&branch, message.as_deref()).await
|
||||||
|
}
|
||||||
|
Commands::Merge { branch, force } => commands::merge::action(&branch, force).await,
|
||||||
|
Commands::Squash { branch } => commands::squash::action(&branch).await,
|
||||||
|
Commands::Rebase { branch } => commands::rebase::action(&branch).await,
|
||||||
|
Commands::Review {
|
||||||
|
branch,
|
||||||
|
prompt,
|
||||||
|
print,
|
||||||
|
} => commands::review::action(&branch, prompt.as_deref(), print).await,
|
||||||
|
Commands::Shell { branch } => commands::shell::action(branch.as_deref()).await,
|
||||||
|
Commands::Edit { branch, file } => commands::edit::action(&branch, &file).await,
|
||||||
|
Commands::Dir { branch } => commands::dir::action(&branch).await,
|
||||||
|
Commands::Cd { branch } => commands::cd::action(&branch),
|
||||||
|
Commands::Config { args } => commands::config::action(&args).await,
|
||||||
|
Commands::Cleanup => commands::cleanup::action().await,
|
||||||
|
Commands::Vm { command } => match command {
|
||||||
|
VmCommands::Create => commands::vm_cmd::create().await,
|
||||||
|
VmCommands::Start => commands::vm_cmd::start().await,
|
||||||
|
VmCommands::Shell => commands::vm_cmd::shell().await,
|
||||||
|
VmCommands::Status { json } => commands::vm_cmd::status(json).await,
|
||||||
|
VmCommands::Info => commands::vm_cmd::info().await,
|
||||||
|
VmCommands::Stop => commands::vm_cmd::stop().await,
|
||||||
|
VmCommands::Destroy => commands::vm_cmd::destroy().await,
|
||||||
|
VmCommands::Uncache => commands::vm_cmd::uncache().await,
|
||||||
|
},
|
||||||
|
Commands::Upgrade => commands::upgrade::action().await,
|
||||||
|
Commands::Version => {
|
||||||
|
let parts: Vec<&str> = VERSION.split('.').collect();
|
||||||
|
println!("v{}", parts.last().unwrap_or(&VERSION));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Commands::Completions { install } => commands::completions::action(install),
|
||||||
|
Commands::Init { shell } => commands::init::action(&shell),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("\u{2716} {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
254
rust-sandlot/src/markdown.rs
Normal file
254
rust-sandlot/src/markdown.rs
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
fn strip_ansi(s: &str) -> String {
|
||||||
|
let re1 = Regex::new(r"\x1b\]8;;[^\x07]*\x07").unwrap();
|
||||||
|
let re2 = Regex::new(r"\x1b\[[0-9;]*m").unwrap();
|
||||||
|
let s = re1.replace_all(s, "");
|
||||||
|
re2.replace_all(&s, "").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_table(block: &str) -> String {
|
||||||
|
let lines: Vec<&str> = block.trim().split('\n').collect();
|
||||||
|
if lines.len() < 2 {
|
||||||
|
return block.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let parse_row = |line: &str| -> Vec<String> {
|
||||||
|
let l = line.strip_prefix('|').unwrap_or(line);
|
||||||
|
let l = l.strip_suffix('|').unwrap_or(l);
|
||||||
|
l.split('|').map(|c| c.trim().to_string()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = parse_row(lines[0]);
|
||||||
|
let sep_cells = parse_row(lines[1]);
|
||||||
|
|
||||||
|
let sep_re = Regex::new(r"^:?-+:?$").unwrap();
|
||||||
|
if !sep_cells.iter().all(|s| sep_re.is_match(s.trim())) {
|
||||||
|
return block.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let cols = header.len();
|
||||||
|
let align: Vec<&str> = sep_cells
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let t = s.trim();
|
||||||
|
if t.starts_with(':') && t.ends_with(':') {
|
||||||
|
"center"
|
||||||
|
} else if t.ends_with(':') {
|
||||||
|
"right"
|
||||||
|
} else {
|
||||||
|
"left"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let rows: Vec<Vec<String>> = lines[2..].iter().map(|l| parse_row(l)).collect();
|
||||||
|
|
||||||
|
let mut widths = vec![0usize; cols];
|
||||||
|
for c in 0..cols {
|
||||||
|
widths[c] = widths[c].max(strip_ansi(header.get(c).map(|s| s.as_str()).unwrap_or("")).len());
|
||||||
|
for row in &rows {
|
||||||
|
widths[c] = widths[c].max(strip_ansi(row.get(c).map(|s| s.as_str()).unwrap_or("")).len());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pad = |text: &str, width: usize, a: &str| -> String {
|
||||||
|
let visible = strip_ansi(text).len();
|
||||||
|
if visible >= width {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
let needed = width - visible;
|
||||||
|
match a {
|
||||||
|
"right" => format!("{}{}", " ".repeat(needed), text),
|
||||||
|
"center" => {
|
||||||
|
let l = needed / 2;
|
||||||
|
format!("{}{}{}", " ".repeat(l), text, " ".repeat(needed - l))
|
||||||
|
}
|
||||||
|
_ => format!("{}{}", text, " ".repeat(needed)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let d = "\x1b[2m";
|
||||||
|
let r = "\x1b[22m";
|
||||||
|
|
||||||
|
let render_row = |cells: &[String], bold: bool| -> String {
|
||||||
|
let parts: Vec<String> = cells
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, c)| pad(c, *widths.get(i).unwrap_or(&0), align.get(i).copied().unwrap_or("left")))
|
||||||
|
.collect();
|
||||||
|
if bold {
|
||||||
|
format!(
|
||||||
|
"{d}\u{2502}{r} {} {d}\u{2502}{r}",
|
||||||
|
parts
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!("\x1b[1m{p}\x1b[22m"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(&format!(" {d}\u{2502}{r} "))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"{d}\u{2502}{r} {} {d}\u{2502}{r}",
|
||||||
|
parts.join(&format!(" {d}\u{2502}{r} "))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let hline = |l: &str, m: &str, r_ch: &str| -> String {
|
||||||
|
let segs: Vec<String> = widths.iter().map(|w| "\u{2500}".repeat(w + 2)).collect();
|
||||||
|
format!("{d}{l}{}{r_ch}{r}", segs.join(m))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.push(hline("\u{250C}", "\u{252C}", "\u{2510}"));
|
||||||
|
out.push(render_row(&header, true));
|
||||||
|
out.push(hline("\u{251C}", "\u{253C}", "\u{2524}"));
|
||||||
|
for row in &rows {
|
||||||
|
out.push(render_row(row, false));
|
||||||
|
}
|
||||||
|
out.push(hline("\u{2514}", "\u{2534}", "\u{2518}"));
|
||||||
|
out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_markdown(text: &str) -> String {
|
||||||
|
// Extract fenced code blocks before anything else
|
||||||
|
let mut code_blocks: Vec<String> = Vec::new();
|
||||||
|
let code_block_re = Regex::new(r"(?m)^```\w*\n([\s\S]*?)^```\s*$").unwrap();
|
||||||
|
let mut result = code_block_re
|
||||||
|
.replace_all(text, |caps: ®ex::Captures| {
|
||||||
|
code_blocks.push(caps[1].to_string());
|
||||||
|
format!("\x00CODEBLOCK{}\x00", code_blocks.len() - 1)
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Extract backslash escapes
|
||||||
|
let mut escapes: Vec<String> = Vec::new();
|
||||||
|
let esc_re = Regex::new(r"\\([\\`*_~\[\]()#>!\-])").unwrap();
|
||||||
|
result = esc_re
|
||||||
|
.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
escapes.push(caps[1].to_string());
|
||||||
|
format!("\x00ESC{}\x00", escapes.len() - 1)
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Extract code spans
|
||||||
|
let mut code_spans: Vec<String> = Vec::new();
|
||||||
|
let span_re = Regex::new(r"`([^`]+)`").unwrap();
|
||||||
|
result = span_re
|
||||||
|
.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
code_spans.push(caps[1].to_string());
|
||||||
|
format!("\x00CODE{}\x00", code_spans.len() - 1)
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Links: [text](url) -> OSC 8 terminal hyperlink
|
||||||
|
let link_re = Regex::new(r#"(?<!!)\[([^\]]+)\]\(([^)]+)\)"#).unwrap();
|
||||||
|
result = link_re
|
||||||
|
.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
let text = &caps[1];
|
||||||
|
let href = &caps[2];
|
||||||
|
let url_re = Regex::new(r#"\s+"[^"]*"$"#).unwrap();
|
||||||
|
let url = url_re.replace(href, "");
|
||||||
|
format!("\x1b]8;;{url}\x07\x1b[4;38;5;75m{text}\x1b[24;39m\x1b]8;;\x07")
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// H1: # Header -> bold+italic+underline
|
||||||
|
let h1_re = Regex::new(r"(?m)^# (.+)$").unwrap();
|
||||||
|
result = h1_re
|
||||||
|
.replace_all(&result, "\x1b[1;3;4m$1\x1b[22;23;24m")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// H2/H3: ## Header -> bold
|
||||||
|
let h2_re = Regex::new(r"(?m)^#{2,6} (.+)$").unwrap();
|
||||||
|
result = h2_re
|
||||||
|
.replace_all(&result, "\x1b[1m$1\x1b[22m")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Blockquotes: > text -> dim+italic
|
||||||
|
let bq_re = Regex::new(r"(?m)^> (.+)$").unwrap();
|
||||||
|
result = bq_re
|
||||||
|
.replace_all(&result, "\x1b[2;3m$1\x1b[22;23m")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Bare blockquote lines
|
||||||
|
let bq_bare_re = Regex::new(r"(?m)^>\s*$").unwrap();
|
||||||
|
result = bq_bare_re.replace_all(&result, "").to_string();
|
||||||
|
|
||||||
|
// Task lists: - [x] -> green check, - [ ] -> dim box
|
||||||
|
let task_x_re = Regex::new(r"(?m)^(\s*)[-*] \[x\] (.+)$").unwrap();
|
||||||
|
result = task_x_re
|
||||||
|
.replace_all(&result, "$1\x1b[32m\u{2713}\x1b[39m $2")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let task_o_re = Regex::new(r"(?m)^(\s*)[-*] \[ \] (.+)$").unwrap();
|
||||||
|
result = task_o_re
|
||||||
|
.replace_all(&result, "$1\x1b[2m\u{2610}\x1b[22m $2")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Bold: **text**
|
||||||
|
let bold_re = Regex::new(r"\*\*(.+?)\*\*").unwrap();
|
||||||
|
result = bold_re
|
||||||
|
.replace_all(&result, "\x1b[1m$1\x1b[22m")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Italic: *text*
|
||||||
|
let italic_re = Regex::new(r"\*(.+?)\*").unwrap();
|
||||||
|
result = italic_re
|
||||||
|
.replace_all(&result, "\x1b[3m$1\x1b[23m")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Restore code spans as light blue
|
||||||
|
let code_restore_re = Regex::new(r"\x00CODE(\d+)\x00").unwrap();
|
||||||
|
result = code_restore_re
|
||||||
|
.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
let i: usize = caps[1].parse().unwrap();
|
||||||
|
format!(
|
||||||
|
"\x1b[38;5;147m{}\x1b[39m",
|
||||||
|
code_spans.get(i).map(|s| s.as_str()).unwrap_or("")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Restore backslash escapes
|
||||||
|
let esc_restore_re = Regex::new(r"\x00ESC(\d+)\x00").unwrap();
|
||||||
|
result = esc_restore_re
|
||||||
|
.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
let i: usize = caps[1].parse().unwrap();
|
||||||
|
escapes.get(i).map(|s| s.as_str()).unwrap_or("").to_string()
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Tables: pipe tables with box-drawing
|
||||||
|
let table_re = Regex::new(r"(?m)^(\|[^\n]+\|\n)(\|[\s:|\-]+\|\n)((?:\|[^\n]+\|\n?)*)").unwrap();
|
||||||
|
result = table_re
|
||||||
|
.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
render_table(&caps[0])
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Restore code blocks
|
||||||
|
let cb_restore_re = Regex::new(r"\x00CODEBLOCK(\d+)\x00").unwrap();
|
||||||
|
result = cb_restore_re
|
||||||
|
.replace_all(&result, |caps: ®ex::Captures| {
|
||||||
|
let i: usize = caps[1].parse().unwrap();
|
||||||
|
code_blocks.get(i).map(|s| s.as_str()).unwrap_or("").to_string()
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Breathe: add blank line before list starts
|
||||||
|
let mut lines: Vec<String> = result.split('\n').map(|s| s.to_string()).collect();
|
||||||
|
let list_re = Regex::new(r"^[\s]*[-*] ").unwrap();
|
||||||
|
let mut i = lines.len();
|
||||||
|
while i > 1 {
|
||||||
|
i -= 1;
|
||||||
|
if list_re.is_match(&lines[i])
|
||||||
|
&& !lines[i - 1].trim().is_empty()
|
||||||
|
&& !list_re.is_match(&lines[i - 1])
|
||||||
|
{
|
||||||
|
lines.insert(i, String::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
99
rust-sandlot/src/spinner.rs
Normal file
99
rust-sandlot/src/spinner.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
const FRAMES: &[&str] = &[
|
||||||
|
"\u{280B}", "\u{2819}", "\u{2839}", "\u{2838}", "\u{283C}", "\u{2834}", "\u{2826}",
|
||||||
|
"\u{2827}", "\u{2807}", "\u{280F}",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct Spinner {
|
||||||
|
text: Arc<Mutex<String>>,
|
||||||
|
prefix_tag: String,
|
||||||
|
running: Arc<AtomicBool>,
|
||||||
|
handle: Option<std::thread::JoinHandle<()>>,
|
||||||
|
debug: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Spinner {
|
||||||
|
pub fn new(text: &str, prefix: Option<&str>) -> Self {
|
||||||
|
let debug = std::env::var("DEBUG").is_ok_and(|v| !v.is_empty());
|
||||||
|
let prefix_tag = match prefix {
|
||||||
|
Some(p) => format!("\x1b[2m[{p}]\x1b[22m "),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if debug {
|
||||||
|
eprint!("\u{25B8} {}{text}\n", prefix_tag);
|
||||||
|
return Self {
|
||||||
|
text: Arc::new(Mutex::new(text.to_string())),
|
||||||
|
prefix_tag,
|
||||||
|
running: Arc::new(AtomicBool::new(false)),
|
||||||
|
handle: None,
|
||||||
|
debug: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = Arc::new(Mutex::new(text.to_string()));
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
|
||||||
|
let t_text = text.clone();
|
||||||
|
let t_running = running.clone();
|
||||||
|
let t_tag = prefix_tag.clone();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let mut i = 0usize;
|
||||||
|
while t_running.load(Ordering::Relaxed) {
|
||||||
|
let txt = t_text.lock().unwrap().clone();
|
||||||
|
eprint!("\r\x1b[2K{} {t_tag}{txt}", FRAMES[i % FRAMES.len()]);
|
||||||
|
let _ = std::io::stderr().flush();
|
||||||
|
i += 1;
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(80));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
text,
|
||||||
|
prefix_tag,
|
||||||
|
running,
|
||||||
|
handle: Some(handle),
|
||||||
|
debug,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_text(&self, t: &str) {
|
||||||
|
if self.debug {
|
||||||
|
eprint!("\u{25B8} {}{t}\n", self.prefix_tag);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
*self.text.lock().unwrap() = t.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn succeed(&self, msg: &str) {
|
||||||
|
self.stop_thread();
|
||||||
|
eprint!("\r\x1b[2K\u{2714} {}{msg}\n", self.prefix_tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fail(&self, msg: &str) {
|
||||||
|
self.stop_thread();
|
||||||
|
eprint!("\r\x1b[2K\u{2716} {}{msg}\n", self.prefix_tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.stop_thread();
|
||||||
|
eprint!("\r\x1b[2K");
|
||||||
|
let _ = std::io::stderr().flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_thread(&self) {
|
||||||
|
self.running.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Spinner {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.running.store(false, Ordering::Relaxed);
|
||||||
|
if let Some(h) = self.handle.take() {
|
||||||
|
let _ = h.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
160
rust-sandlot/src/state.rs
Normal file
160
rust-sandlot/src/state.rs
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Session {
|
||||||
|
pub branch: String,
|
||||||
|
pub worktree: String,
|
||||||
|
pub created_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub prompt: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub in_review: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct State {
|
||||||
|
pub sessions: HashMap<String, Session>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct GlobalSession {
|
||||||
|
pub session: Session,
|
||||||
|
pub repo_root: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path(repo_root: &str) -> PathBuf {
|
||||||
|
Path::new(repo_root).join(".sandlot").join("state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load(repo_root: &str) -> State {
|
||||||
|
let path = state_path(repo_root);
|
||||||
|
match tokio::fs::read_to_string(&path).await {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
|
Err(_) => State::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save(repo_root: &str, state: &State) -> Result<()> {
|
||||||
|
let path = state_path(repo_root);
|
||||||
|
let dir = Path::new(repo_root).join(".sandlot");
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
// Ensure dir exists via .gitkeep
|
||||||
|
let gitkeep = dir.join(".gitkeep");
|
||||||
|
if !gitkeep.exists() {
|
||||||
|
tokio::fs::write(&gitkeep, "").await.ok();
|
||||||
|
}
|
||||||
|
let tmp_path = format!("{}.tmp", path.display());
|
||||||
|
let json = serde_json::to_string_pretty(state)? + "\n";
|
||||||
|
tokio::fs::write(&tmp_path, &json).await?;
|
||||||
|
tokio::fs::rename(&tmp_path, &path).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_session(repo_root: &str, branch: &str) -> Option<Session> {
|
||||||
|
let state = load(repo_root).await;
|
||||||
|
state.sessions.get(branch).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_session(repo_root: &str, session: Session) -> Result<()> {
|
||||||
|
let mut state = load(repo_root).await;
|
||||||
|
state.sessions.insert(session.branch.clone(), session);
|
||||||
|
save(repo_root, &state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_session(repo_root: &str, branch: &str) -> Result<()> {
|
||||||
|
let mut state = load(repo_root).await;
|
||||||
|
state.sessions.remove(branch);
|
||||||
|
save(repo_root, &state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover all sessions across all repos by scanning ~/.sandlot/
|
||||||
|
pub async fn load_all() -> Vec<GlobalSession> {
|
||||||
|
let home = match dirs::home_dir() {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return vec![],
|
||||||
|
};
|
||||||
|
let sandlot_dir = home.join(".sandlot");
|
||||||
|
let mut all = Vec::new();
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
let mut repo_dirs = match tokio::fs::read_dir(&sandlot_dir).await {
|
||||||
|
Ok(rd) => rd,
|
||||||
|
Err(_) => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(entry)) = repo_dirs.next_entry().await {
|
||||||
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ft = match entry.file_type().await {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !ft.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let repo_dir = sandlot_dir.join(&name);
|
||||||
|
let mut repo_root: Option<String> = None;
|
||||||
|
|
||||||
|
// Find the main repo root from a worktree's .git pointer
|
||||||
|
let mut branch_entries = match tokio::fs::read_dir(&repo_dir).await {
|
||||||
|
Ok(be) => be,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(be)) = branch_entries.next_entry().await {
|
||||||
|
let be_name = be.file_name().to_string_lossy().to_string();
|
||||||
|
if be_name.starts_with('.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let be_ft = match be.file_type().await {
|
||||||
|
Ok(ft) => ft,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !be_ft.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dot_git = repo_dir.join(&be_name).join(".git");
|
||||||
|
if let Ok(content) = tokio::fs::read_to_string(&dot_git).await {
|
||||||
|
if let Some(m) = regex::Regex::new(r"(?m)^gitdir:\s*(.+)")
|
||||||
|
.ok()
|
||||||
|
.and_then(|re| re.captures(&content))
|
||||||
|
.and_then(|c| c.get(1))
|
||||||
|
{
|
||||||
|
let gitdir = m.as_str().trim();
|
||||||
|
// gitdir: /path/to/repo/.git/worktrees/<name>
|
||||||
|
let main_git = regex::Regex::new(r"/worktrees/[^/]+$")
|
||||||
|
.unwrap()
|
||||||
|
.replace(gitdir, "");
|
||||||
|
let main_git_path = Path::new(main_git.as_ref());
|
||||||
|
if let Some(parent) = main_git_path.parent() {
|
||||||
|
repo_root = Some(parent.to_string_lossy().to_string());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref root) = repo_root {
|
||||||
|
if seen.contains(root) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.insert(root.clone());
|
||||||
|
let st = load(root).await;
|
||||||
|
for session in st.sessions.into_values() {
|
||||||
|
all.push(GlobalSession {
|
||||||
|
session,
|
||||||
|
repo_root: root.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all
|
||||||
|
}
|
||||||
908
rust-sandlot/src/vm.rs
Normal file
908
rust-sandlot/src/vm.rs
Normal file
|
|
@ -0,0 +1,908 @@
|
||||||
|
use anyhow::{Result, bail};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const CONTAINER_NAME: &str = "sandlot";
|
||||||
|
const USER: &str = "ubuntu";
|
||||||
|
const CLAUDE_BIN: &str = "/home/ubuntu/.local/bin/claude";
|
||||||
|
const CONTAINER_PATH: &str = "/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||||
|
|
||||||
|
const CONTAINER_ENV: &[(&str, &str)] = &[
|
||||||
|
("RUSTUP_HOME", "/sandlot/.rustup"),
|
||||||
|
("CARGO_HOME", "/sandlot/.cargo"),
|
||||||
|
("GOROOT", "/sandlot/.go"),
|
||||||
|
("GOPATH", "/sandlot/.gopath"),
|
||||||
|
("RUSTC_WRAPPER", "/sandlot/.cargo/bin/sccache"),
|
||||||
|
("SCCACHE_DIR", "/sandlot/.sccache"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn debug_mode() -> bool {
|
||||||
|
std::env::var("DEBUG").is_ok_and(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn home_dir() -> String {
|
||||||
|
dirs::home_dir()
|
||||||
|
.expect("cannot find home directory")
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_dir() -> String {
|
||||||
|
format!("{}/.sandlot/.cache", home_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a host path to its corresponding container path.
|
||||||
|
pub fn container_path(host_path: &str) -> String {
|
||||||
|
let home = home_dir();
|
||||||
|
let sandlot_prefix = format!("{home}/.sandlot");
|
||||||
|
let dev_prefix = format!("{home}/dev");
|
||||||
|
let code_prefix = format!("{home}/code");
|
||||||
|
if host_path.starts_with(&sandlot_prefix) {
|
||||||
|
return format!("/sandlot{}", &host_path[sandlot_prefix.len()..]);
|
||||||
|
}
|
||||||
|
if host_path.starts_with(&dev_prefix) {
|
||||||
|
return format!("/host/dev{}", &host_path[dev_prefix.len()..]);
|
||||||
|
}
|
||||||
|
if host_path.starts_with(&code_prefix) {
|
||||||
|
return format!("/host/code{}", &host_path[code_prefix.len()..]);
|
||||||
|
}
|
||||||
|
host_path.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_container() {
|
||||||
|
if which::which("container").is_err() {
|
||||||
|
eprintln!("\u{2716} Apple Container is not installed. Install it with: brew install container");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a shell command, returning error on failure.
|
||||||
|
async fn run(args: &[&str], step: &str) -> Result<()> {
|
||||||
|
let mut cmd = Command::new(args[0]);
|
||||||
|
cmd.args(&args[1..]);
|
||||||
|
if !debug_mode() {
|
||||||
|
cmd.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null());
|
||||||
|
}
|
||||||
|
let output = cmd.output().await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let detail = if !stderr.is_empty() {
|
||||||
|
stderr
|
||||||
|
} else if !stdout.is_empty() {
|
||||||
|
stdout
|
||||||
|
} else {
|
||||||
|
"(no output)".to_string()
|
||||||
|
};
|
||||||
|
bail!("{step} failed (exit {}):\n{detail}", output.status.code().unwrap_or(1));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check which host source directories exist.
|
||||||
|
fn host_mounts(home: &str) -> (bool, bool) {
|
||||||
|
let dev = Path::new(&format!("{home}/dev")).exists();
|
||||||
|
let code = Path::new(&format!("{home}/code")).exists();
|
||||||
|
(dev, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the package cache is populated.
|
||||||
|
async fn has_cached_tooling() -> bool {
|
||||||
|
let cache = cache_dir();
|
||||||
|
for f in &["bun", "claude", "neofetch", "nvim.tar.gz"] {
|
||||||
|
if !Path::new(&format!("{cache}/{f}")).exists() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_container(home: &str) -> Result<()> {
|
||||||
|
let (dev, code) = host_mounts(home);
|
||||||
|
let memory = match crate::config::get_memory().await {
|
||||||
|
Some(m) => match crate::config::validate_memory(&m) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
crate::fmt::info(&format!("Invalid memory config, using default: {e}"));
|
||||||
|
crate::config::DEFAULTS_MEMORY.to_string()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => crate::config::DEFAULTS_MEMORY.to_string(),
|
||||||
|
};
|
||||||
|
let mut args: Vec<String> = vec![
|
||||||
|
"container".into(), "run".into(), "-d".into(),
|
||||||
|
"--name".into(), CONTAINER_NAME.into(),
|
||||||
|
"-m".into(), memory,
|
||||||
|
];
|
||||||
|
if dev {
|
||||||
|
args.push("--mount".into());
|
||||||
|
args.push(format!(
|
||||||
|
"type=bind,source={home}/dev,target=/host/dev,readonly"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if code {
|
||||||
|
args.push("--mount".into());
|
||||||
|
args.push(format!(
|
||||||
|
"type=bind,source={home}/code,target=/host/code,readonly"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
args.push("-v".into());
|
||||||
|
args.push(format!("{home}/.sandlot:/sandlot"));
|
||||||
|
args.push("ubuntu:24.04".into());
|
||||||
|
args.push("sleep".into());
|
||||||
|
args.push("infinity".into());
|
||||||
|
|
||||||
|
let mut cmd = Command::new(&args[0]);
|
||||||
|
cmd.args(&args[1..]);
|
||||||
|
if !debug_mode() {
|
||||||
|
cmd.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null());
|
||||||
|
}
|
||||||
|
let output = cmd.output().await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
bail!(
|
||||||
|
"Container creation failed (exit {}):\n{}",
|
||||||
|
output.status.code().unwrap_or(1),
|
||||||
|
if !stderr.is_empty() { stderr } else if !stdout.is_empty() { stdout } else { "(no output)".to_string() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_packages(cached: bool) -> Result<()> {
|
||||||
|
let packages = if cached {
|
||||||
|
"curl git fish build-essential"
|
||||||
|
} else {
|
||||||
|
"curl git fish unzip build-essential"
|
||||||
|
};
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", CONTAINER_NAME, "bash", "-c",
|
||||||
|
&format!("apt update && apt install -y {packages}"),
|
||||||
|
],
|
||||||
|
"Package installation",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_host_symlinks(home: &str) -> Result<()> {
|
||||||
|
let (dev, code) = host_mounts(home);
|
||||||
|
let mut cmds = vec![
|
||||||
|
format!("mkdir -p '{home}'"),
|
||||||
|
format!("ln -s /sandlot '{home}/.sandlot'"),
|
||||||
|
];
|
||||||
|
if dev {
|
||||||
|
cmds.push(format!("ln -s /host/dev '{home}/dev'"));
|
||||||
|
}
|
||||||
|
if code {
|
||||||
|
cmds.push(format!("ln -s /host/code '{home}/code'"));
|
||||||
|
}
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", CONTAINER_NAME, "bash", "-c",
|
||||||
|
&cmds.join(" && "),
|
||||||
|
],
|
||||||
|
"Symlink creation",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_tooling(cached: bool, log: &dyn Fn(&str)) -> Result<()> {
|
||||||
|
// Ensure cache directory exists
|
||||||
|
tokio::fs::create_dir_all(cache_dir()).await.ok();
|
||||||
|
|
||||||
|
if cached {
|
||||||
|
log("Installing packages (cached)");
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c", "mkdir -p ~/.local/bin",
|
||||||
|
],
|
||||||
|
"Create bin directory",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
"cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx",
|
||||||
|
],
|
||||||
|
"Install cached binaries",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1",
|
||||||
|
],
|
||||||
|
"Install cached Neovim",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Installing Bun");
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"env", &format!("BUN_INSTALL=/home/{USER}/.local"),
|
||||||
|
"bash", "-c", "curl -fsSL https://bun.sh/install | bash",
|
||||||
|
],
|
||||||
|
"Bun installation",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
log("Installing Claude Code");
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash",
|
||||||
|
],
|
||||||
|
"Claude Code installation",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
log("Installing neofetch");
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch",
|
||||||
|
],
|
||||||
|
"neofetch installation",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
log("Installing Neovim");
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
"curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-arm64.tar.gz -o /tmp/nvim.tar.gz && tar xzf /tmp/nvim.tar.gz -C ~/.local --strip-components=1",
|
||||||
|
],
|
||||||
|
"Neovim installation",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Cache binaries
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args([
|
||||||
|
"exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz",
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
install_persistent_tooling(log).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_persistent_tooling(log: &dyn Fn(&str)) -> Result<()> {
|
||||||
|
// Rust
|
||||||
|
let has_rust = Command::new("container")
|
||||||
|
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/rustc"])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await?;
|
||||||
|
if !has_rust.success() {
|
||||||
|
log("Installing Rust");
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"env",
|
||||||
|
&format!("RUSTUP_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1),
|
||||||
|
&format!("CARGO_HOME={}", CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1),
|
||||||
|
"bash", "-c",
|
||||||
|
"curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y",
|
||||||
|
],
|
||||||
|
"Rust installation",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// Add musl target
|
||||||
|
let cargo_home = CONTAINER_ENV.iter().find(|e| e.0 == "CARGO_HOME").unwrap().1;
|
||||||
|
let rustup_home = CONTAINER_ENV.iter().find(|e| e.0 == "RUSTUP_HOME").unwrap().1;
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"env",
|
||||||
|
&format!("RUSTUP_HOME={rustup_home}"),
|
||||||
|
&format!("CARGO_HOME={cargo_home}"),
|
||||||
|
&format!("PATH={cargo_home}/bin:$PATH"),
|
||||||
|
"rustup", "target", "add", "aarch64-unknown-linux-musl",
|
||||||
|
],
|
||||||
|
"Rust musl target",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargo config
|
||||||
|
let has_cargo_config = Command::new("container")
|
||||||
|
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-f", "/sandlot/.cargo/config.toml"])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await?;
|
||||||
|
if !has_cargo_config.success() {
|
||||||
|
let cargo_config = r#"[target.aarch64-unknown-linux-musl]\nlinker = "rust-lld"\n\n[build]\ntarget = "aarch64-unknown-linux-musl"\n"#;
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args([
|
||||||
|
"exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
&format!("echo -e '{cargo_config}' > /sandlot/.cargo/config.toml"),
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sccache
|
||||||
|
let has_sccache = Command::new("container")
|
||||||
|
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.cargo/bin/sccache"])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await?;
|
||||||
|
if !has_sccache.success() {
|
||||||
|
log("Installing sccache");
|
||||||
|
let sccache_version = "v0.14.0";
|
||||||
|
let sccache_archive = format!("sccache-{sccache_version}-aarch64-unknown-linux-musl.tar.gz");
|
||||||
|
let sccache_url = format!("https://github.com/mozilla/sccache/releases/download/{sccache_version}/{sccache_archive}");
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
&format!(
|
||||||
|
"curl -fsSL {sccache_url} | tar xz -C /tmp && cp /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl/sccache /sandlot/.cargo/bin/sccache && chmod +x /sandlot/.cargo/bin/sccache && rm -rf /tmp/sccache-{sccache_version}-aarch64-unknown-linux-musl"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"sccache installation",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.sccache"])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go
|
||||||
|
let has_go = Command::new("container")
|
||||||
|
.args(["exec", "--user", USER, CONTAINER_NAME, "test", "-x", "/sandlot/.go/bin/go"])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await?;
|
||||||
|
if !has_go.success() {
|
||||||
|
log("Installing Go");
|
||||||
|
run(
|
||||||
|
&[
|
||||||
|
"container", "exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
"mkdir -p /sandlot/.go && curl -fsSL https://go.dev/dl/go1.24.1.linux-arm64.tar.gz | tar xz -C /sandlot/.go --strip-components=1",
|
||||||
|
],
|
||||||
|
"Go installation",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args(["exec", "--user", USER, CONTAINER_NAME, "bash", "-c", "mkdir -p /sandlot/.gopath"])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn install_script(home: &str, name: &str, content: &str) -> Result<()> {
|
||||||
|
let tmp = format!("{home}/.sandlot/.{name}.tmp");
|
||||||
|
tokio::fs::write(&tmp, content).await?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?;
|
||||||
|
}
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args([
|
||||||
|
"exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
&format!("cp /sandlot/.{name}.tmp ~/.local/bin/{name}"),
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
tokio::fs::remove_file(&tmp).await.ok();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn configure_environment(home: &str, api_key: &str) -> Result<()> {
|
||||||
|
// Git identity
|
||||||
|
let git_name = Command::new("git")
|
||||||
|
.args(["config", "user.name"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let git_email = Command::new("git")
|
||||||
|
.args(["config", "user.email"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !git_name.is_empty() {
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.name", &git_name])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
if !git_email.is_empty() {
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args(["exec", "--user", USER, CONTAINER_NAME, "git", "config", "--global", "user.email", &git_email])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude settings
|
||||||
|
let activity_bin = format!("/home/{USER}/.local/bin/sandlot-activity");
|
||||||
|
let hooks = serde_json::json!({
|
||||||
|
"UserPromptSubmit": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}],
|
||||||
|
"PreToolUse": [{"hooks": [{"type": "command", "command": format!("{activity_bin} active")}]}],
|
||||||
|
});
|
||||||
|
let status_line = serde_json::json!({
|
||||||
|
"type": "command",
|
||||||
|
"command": format!("/home/{USER}/.local/bin/sandlot-statusline"),
|
||||||
|
});
|
||||||
|
let settings = serde_json::json!({
|
||||||
|
"apiKeyHelper": "~/.claude/api-key-helper.sh",
|
||||||
|
"skipDangerousModePermissionPrompt": true,
|
||||||
|
"hooks": hooks,
|
||||||
|
"statusLine": status_line,
|
||||||
|
});
|
||||||
|
let claude_json = serde_json::json!({
|
||||||
|
"hasCompletedOnboarding": true,
|
||||||
|
"effortCalloutDismissed": true,
|
||||||
|
"projects": { "/": { "hasTrustDialogAccepted": true } },
|
||||||
|
});
|
||||||
|
let settings_json = serde_json::to_string(&settings)?;
|
||||||
|
let claude_json_str = serde_json::to_string(&claude_json)?;
|
||||||
|
|
||||||
|
// API key helper (write to temp file so key never appears in ps)
|
||||||
|
let escaped_key = api_key.replace('\'', "'\\''");
|
||||||
|
let tmp = format!("{home}/.sandlot/.api-key-helper.tmp");
|
||||||
|
tokio::fs::write(&tmp, format!("#!/bin/sh\necho '{escaped_key}'\n")).await?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755)).await?;
|
||||||
|
}
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args([
|
||||||
|
"exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh",
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
tokio::fs::remove_file(&tmp).await.ok();
|
||||||
|
|
||||||
|
// Activity hook script
|
||||||
|
install_script(
|
||||||
|
home,
|
||||||
|
"sandlot-activity",
|
||||||
|
"#!/bin/bash\nP=\"${CLAUDE_PROJECT_DIR%/}\"\necho \"$1\" > \"$(dirname \"$P\")/.activity-$(basename \"$P\")\"\n",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Statusline script
|
||||||
|
install_script(
|
||||||
|
home,
|
||||||
|
"sandlot-statusline",
|
||||||
|
"#!/bin/bash\ninput=$(cat)\ncwd=$(echo \"$input\" | grep -oP '\"cwd\"\\s*:\\s*\"\\K[^\"]+' | head -1)\n[ -n \"$cwd\" ] && printf '\\033[36m\u{2387} %s\\033[0m\\n' \"$(basename \"$cwd\")\"\n",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Write Claude settings
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args([
|
||||||
|
"exec", "--user", USER, CONTAINER_NAME,
|
||||||
|
"bash", "-c",
|
||||||
|
&format!(
|
||||||
|
"mkdir -p ~/.claude\necho '{settings_json}' > ~/.claude/settings.json\necho '{claude_json_str}' > ~/.claude.json\n"
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Create and provision the container from scratch.
|
||||||
|
pub async fn create(log: &dyn Fn(&str)) -> Result<()> {
|
||||||
|
require_container();
|
||||||
|
let api_key = crate::env::require_api_key().await;
|
||||||
|
|
||||||
|
let s = status().await;
|
||||||
|
if s != "missing" {
|
||||||
|
bail!("Container already exists. Use 'sandlot vm destroy' first to recreate it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let home = home_dir();
|
||||||
|
let cached = has_cached_tooling().await;
|
||||||
|
|
||||||
|
log("Pulling image & creating container");
|
||||||
|
create_container(&home).await?;
|
||||||
|
|
||||||
|
log("Installing packages");
|
||||||
|
install_packages(cached).await?;
|
||||||
|
create_host_symlinks(&home).await?;
|
||||||
|
|
||||||
|
install_tooling(cached, log).await?;
|
||||||
|
|
||||||
|
log("Configuring environment");
|
||||||
|
configure_environment(&home, &api_key).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a stopped container.
|
||||||
|
pub async fn start() -> Result<()> {
|
||||||
|
require_container();
|
||||||
|
let s = status().await;
|
||||||
|
if s == "running" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if s == "missing" {
|
||||||
|
bail!("Container does not exist. Use 'sandlot vm create' first.");
|
||||||
|
}
|
||||||
|
run(&["container", "start", CONTAINER_NAME], "Container start").await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the sandlot container exists and is running.
|
||||||
|
pub async fn ensure(log: &dyn Fn(&str)) -> Result<()> {
|
||||||
|
require_container();
|
||||||
|
crate::env::require_api_key().await;
|
||||||
|
|
||||||
|
// Ensure container daemon is running
|
||||||
|
let mut cmd = Command::new("container");
|
||||||
|
cmd.args(["system", "start", "--enable-kernel-install"]);
|
||||||
|
if !debug_mode() {
|
||||||
|
cmd.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null());
|
||||||
|
}
|
||||||
|
let _ = cmd.output().await;
|
||||||
|
|
||||||
|
let s = status().await;
|
||||||
|
if s == "running" {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if s == "stopped" {
|
||||||
|
return start().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
create(log).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check container status.
|
||||||
|
pub async fn status() -> &'static str {
|
||||||
|
let output = Command::new("container")
|
||||||
|
.args(["list", "--format", "json", "--all"])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let output = match output {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(_) => return "missing",
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let containers: Vec<serde_json::Value> = match serde_json::from_str(text.trim()) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return "missing",
|
||||||
|
};
|
||||||
|
|
||||||
|
for c in &containers {
|
||||||
|
if c.get("configuration")
|
||||||
|
.and_then(|cfg| cfg.get("id"))
|
||||||
|
.and_then(|id| id.as_str())
|
||||||
|
== Some(CONTAINER_NAME)
|
||||||
|
{
|
||||||
|
let status_str = c
|
||||||
|
.get("status")
|
||||||
|
.and_then(|s| s.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
return if status_str == "running" {
|
||||||
|
"running"
|
||||||
|
} else {
|
||||||
|
"stopped"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launch claude in the container at the given workdir.
|
||||||
|
pub fn claude<'a>(
|
||||||
|
workdir: &'a str,
|
||||||
|
prompt: Option<&'a str>,
|
||||||
|
print: Option<&'a str>,
|
||||||
|
continue_session: bool,
|
||||||
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(i32, Option<String>)>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let cwd = container_path(workdir);
|
||||||
|
let home = home_dir();
|
||||||
|
let (dev, code) = host_mounts(&home);
|
||||||
|
let mut system_prompt_lines = vec![
|
||||||
|
"You are running inside a sandlot container (Apple Container, ubuntu:24.04).".to_string(),
|
||||||
|
format!("Your working directory is {cwd}, a git worktree managed by sandlot."),
|
||||||
|
];
|
||||||
|
if dev {
|
||||||
|
system_prompt_lines.push("The host's ~/dev is mounted read-only at /host/dev.".to_string());
|
||||||
|
}
|
||||||
|
if code {
|
||||||
|
system_prompt_lines.push("The host's ~/code is mounted read-only at /host/code.".to_string());
|
||||||
|
}
|
||||||
|
system_prompt_lines.push("The host's ~/.sandlot is mounted at /sandlot.".to_string());
|
||||||
|
system_prompt_lines.push("Bun is installed at ~/.local/bin/bun. Use bun instead of node/npm.".to_string());
|
||||||
|
system_prompt_lines.push("Rust (cargo/rustc) is installed at /sandlot/.cargo/. Go is installed at /sandlot/.go/. sccache is configured as RUSTC_WRAPPER for build caching.".to_string());
|
||||||
|
if print.is_some() {
|
||||||
|
system_prompt_lines.push("IMPORTANT: Do not use plan mode. Do not call the EnterPlanMode tool. Proceed directly with the task.".to_string());
|
||||||
|
}
|
||||||
|
let system_prompt = system_prompt_lines.join("\n");
|
||||||
|
|
||||||
|
let term = std::env::var("TERM").unwrap_or_else(|_| "xterm-256color".to_string());
|
||||||
|
let mut env_args: Vec<String> = vec![
|
||||||
|
format!("TERM={term}"),
|
||||||
|
format!("PATH={CONTAINER_PATH}"),
|
||||||
|
];
|
||||||
|
for (k, v) in CONTAINER_ENV {
|
||||||
|
env_args.push(format!("{k}={v}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut args: Vec<String> = vec![
|
||||||
|
"container".into(), "exec".into(), "-it".into(),
|
||||||
|
"--user".into(), USER.into(),
|
||||||
|
"--workdir".into(), cwd.clone(),
|
||||||
|
CONTAINER_NAME.into(), "env".into(),
|
||||||
|
];
|
||||||
|
args.extend(env_args);
|
||||||
|
args.extend([
|
||||||
|
CLAUDE_BIN.into(),
|
||||||
|
"--dangerously-skip-permissions".into(),
|
||||||
|
"--model".into(), "claude-opus-4-6".into(),
|
||||||
|
"--effort".into(), "max".into(),
|
||||||
|
"--append-system-prompt".into(), system_prompt,
|
||||||
|
]);
|
||||||
|
if continue_session {
|
||||||
|
args.push("--continue".into());
|
||||||
|
}
|
||||||
|
if let Some(p) = print {
|
||||||
|
args.push("-p".into());
|
||||||
|
args.push(p.into());
|
||||||
|
} else if let Some(p) = prompt {
|
||||||
|
args.push(p.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if print.is_some() {
|
||||||
|
let mut cmd = std::process::Command::new(&args[0]);
|
||||||
|
cmd.args(&args[1..])
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::inherit());
|
||||||
|
let child = cmd.spawn()?;
|
||||||
|
let output = child.wait_with_output()?;
|
||||||
|
let exit_code = output.status.code().unwrap_or(1);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
|
|
||||||
|
if exit_code != 0 && continue_session {
|
||||||
|
crate::fmt::info("Retrying without --continue");
|
||||||
|
return claude(workdir, prompt, print, false).await;
|
||||||
|
}
|
||||||
|
return Ok((exit_code, Some(stdout)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = std::process::Command::new(&args[0]);
|
||||||
|
cmd.args(&args[1..])
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit());
|
||||||
|
let status = cmd.spawn()?.wait()?;
|
||||||
|
let exit_code = status.code().unwrap_or(1);
|
||||||
|
|
||||||
|
if exit_code != 0 && continue_session {
|
||||||
|
crate::fmt::info("Retrying without --continue");
|
||||||
|
return claude(workdir, prompt, print, false).await;
|
||||||
|
}
|
||||||
|
Ok((exit_code, None))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open an interactive fish shell in the container.
|
||||||
|
pub async fn shell(workdir: Option<&str>) -> Result<()> {
|
||||||
|
let mut args: Vec<String> = vec![
|
||||||
|
"container".into(), "exec".into(), "-it".into(),
|
||||||
|
"--user".into(), USER.into(),
|
||||||
|
];
|
||||||
|
if let Some(wd) = workdir {
|
||||||
|
args.push("--workdir".into());
|
||||||
|
args.push(container_path(wd));
|
||||||
|
}
|
||||||
|
let mut env_args: Vec<String> = vec![
|
||||||
|
"TERM=xterm-256color".into(),
|
||||||
|
format!("PATH={CONTAINER_PATH}"),
|
||||||
|
];
|
||||||
|
for (k, v) in CONTAINER_ENV {
|
||||||
|
env_args.push(format!("{k}={v}"));
|
||||||
|
}
|
||||||
|
args.push(CONTAINER_NAME.into());
|
||||||
|
args.push("env".into());
|
||||||
|
args.extend(env_args);
|
||||||
|
args.push("fish".into());
|
||||||
|
args.push("--login".into());
|
||||||
|
|
||||||
|
let status = std::process::Command::new(&args[0])
|
||||||
|
.args(&args[1..])
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.spawn()?
|
||||||
|
.wait()?;
|
||||||
|
let _ = status;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run neofetch in the container.
|
||||||
|
pub async fn neofetch() -> Result<()> {
|
||||||
|
let mut env_args: Vec<String> = vec![format!("PATH={CONTAINER_PATH}")];
|
||||||
|
for (k, v) in CONTAINER_ENV {
|
||||||
|
env_args.push(format!("{k}={v}"));
|
||||||
|
}
|
||||||
|
let mut args: Vec<String> = vec![
|
||||||
|
"container".into(), "exec".into(),
|
||||||
|
"--user".into(), USER.into(),
|
||||||
|
CONTAINER_NAME.into(), "env".into(),
|
||||||
|
];
|
||||||
|
args.extend(env_args);
|
||||||
|
args.push("neofetch".into());
|
||||||
|
|
||||||
|
let status = std::process::Command::new(&args[0])
|
||||||
|
.args(&args[1..])
|
||||||
|
.stdin(std::process::Stdio::inherit())
|
||||||
|
.stdout(std::process::Stdio::inherit())
|
||||||
|
.stderr(std::process::Stdio::inherit())
|
||||||
|
.spawn()?
|
||||||
|
.wait()?;
|
||||||
|
let _ = status;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a bash command in the container at the given workdir, capturing output.
|
||||||
|
pub async fn exec(workdir: &str, command: &str) -> (i32, String, String) {
|
||||||
|
let env_exports: String = CONTAINER_ENV
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("export {k}={v}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
let full_cmd = format!("export PATH={CONTAINER_PATH}; {env_exports}; {command}");
|
||||||
|
let output = Command::new("container")
|
||||||
|
.args([
|
||||||
|
"exec", "--user", USER,
|
||||||
|
"--workdir", &container_path(workdir),
|
||||||
|
CONTAINER_NAME, "bash", "-c", &full_cmd,
|
||||||
|
])
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(o) => (
|
||||||
|
o.status.code().unwrap_or(1),
|
||||||
|
String::from_utf8_lossy(&o.stdout).trim().to_string(),
|
||||||
|
String::from_utf8_lossy(&o.stderr).trim().to_string(),
|
||||||
|
),
|
||||||
|
Err(_) => (1, String::new(), String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pipe input text to Claude in the container with a prompt, returning the output.
|
||||||
|
pub async fn claude_pipe(input: &str, prompt: &str) -> (i32, String, String) {
|
||||||
|
let tmp_name = format!(".claude-pipe-{}", Uuid::new_v4());
|
||||||
|
let home = home_dir();
|
||||||
|
let tmp_path = format!("{home}/.sandlot/{tmp_name}");
|
||||||
|
tokio::fs::write(&tmp_path, input).await.ok();
|
||||||
|
let escaped_prompt = prompt.replace('"', "\\\"");
|
||||||
|
let result = exec(
|
||||||
|
&format!("{home}/.sandlot"),
|
||||||
|
&format!(
|
||||||
|
"cat /sandlot/{tmp_name} | claude --model claude-opus-4-6 --effort max -p \"{escaped_prompt}\""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
tokio::fs::remove_file(&tmp_path).await.ok();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if Claude is actively working in the given worktree.
|
||||||
|
pub async fn is_claude_active(worktree: &str, branch: &str) -> bool {
|
||||||
|
let parent = Path::new(worktree).parent().unwrap_or(Path::new("."));
|
||||||
|
let file = parent.join(format!(".activity-{branch}"));
|
||||||
|
match tokio::fs::read_to_string(&file).await {
|
||||||
|
Ok(content) => content.trim() == "active",
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the activity marker for a worktree.
|
||||||
|
pub async fn set_activity(worktree: &str, branch: &str) {
|
||||||
|
let parent = Path::new(worktree).parent().unwrap_or(Path::new("."));
|
||||||
|
let file = parent.join(format!(".activity-{branch}"));
|
||||||
|
tokio::fs::write(&file, "active\n").await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the activity marker file for a worktree.
|
||||||
|
pub async fn clear_activity(worktree: &str, branch: &str) {
|
||||||
|
let parent = Path::new(worktree).parent().unwrap_or(Path::new("."));
|
||||||
|
let file = parent.join(format!(".activity-{branch}"));
|
||||||
|
tokio::fs::remove_file(&file).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the container.
|
||||||
|
pub async fn stop() -> Result<()> {
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args(["stop", CONTAINER_NAME])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop and delete the container.
|
||||||
|
pub async fn destroy() -> Result<()> {
|
||||||
|
stop().await?;
|
||||||
|
let _ = Command::new("container")
|
||||||
|
.args(["delete", CONTAINER_NAME])
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the package cache.
|
||||||
|
pub async fn clear_cache() -> bool {
|
||||||
|
let cache = cache_dir();
|
||||||
|
let existed = Path::new(&format!("{cache}/bun")).exists();
|
||||||
|
tokio::fs::remove_dir_all(&cache).await.ok();
|
||||||
|
existed
|
||||||
|
}
|
||||||
18
src/cli.ts
18
src/cli.ts
|
|
@ -35,7 +35,7 @@ const program = new Command()
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("sandlot")
|
.name("sandlot")
|
||||||
.description("Sandboxed development with Pi.")
|
.description("Sandboxed development with Claude.")
|
||||||
.configureHelp({ styleTitle: (str) => `${yellow}${str}${reset}` })
|
.configureHelp({ styleTitle: (str) => `${yellow}${str}${reset}` })
|
||||||
.helpOption(false)
|
.helpOption(false)
|
||||||
.addOption(new Option("-h, --help").hideHelp())
|
.addOption(new Option("-h, --help").hideHelp())
|
||||||
|
|
@ -59,19 +59,19 @@ program
|
||||||
program
|
program
|
||||||
.command("new")
|
.command("new")
|
||||||
.argument("[branch]", "branch name or prompt (if it contains spaces)")
|
.argument("[branch]", "branch name or prompt (if it contains spaces)")
|
||||||
.argument("[prompt]", "initial prompt for Pi")
|
.argument("[prompt]", "initial prompt for Claude")
|
||||||
.option("-p, --print <prompt>", "run Pi in non-interactive mode with -p")
|
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
|
||||||
.option("-n, --no-save", "skip auto-save after Pi exits")
|
.option("-n, --no-save", "skip auto-save after Claude exits")
|
||||||
.description("Create a new session and launch Pi")
|
.description("Create a new session and launch Claude")
|
||||||
.action(newAction)
|
.action(newAction)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("open")
|
.command("open")
|
||||||
.argument("<branch>", "branch name")
|
.argument("<branch>", "branch name")
|
||||||
.argument("[prompt]", "initial prompt for Pi")
|
.argument("[prompt]", "initial prompt for Claude")
|
||||||
.option("-p, --print <prompt>", "run Pi in non-interactive mode with -p")
|
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
|
||||||
.option("-n, --no-save", "skip auto-save after Pi exits")
|
.option("-n, --no-save", "skip auto-save after Claude exits")
|
||||||
.description("Open an existing Pi session")
|
.description("Open an existing Claude session")
|
||||||
.action(openAction)
|
.action(openAction)
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,14 @@ import { die } from "../fmt.ts"
|
||||||
import * as config from "../config.ts"
|
import * as config from "../config.ts"
|
||||||
|
|
||||||
const VALID_KEYS = Object.keys(config.DEFAULTS) as config.Key[]
|
const VALID_KEYS = Object.keys(config.DEFAULTS) as config.Key[]
|
||||||
const ARRAY_KEYS = Object.entries(config.DEFAULTS).filter(([, v]) => Array.isArray(v)).map(([k]) => k)
|
|
||||||
|
|
||||||
export async function action(args: string[]) {
|
export async function action(args: string[]) {
|
||||||
if (args.length === 0) {
|
if (args.length === 0) {
|
||||||
const cfg = await config.load()
|
const cfg = await config.load()
|
||||||
for (const key of VALID_KEYS) {
|
for (const key of VALID_KEYS) {
|
||||||
const val = cfg[key]
|
const val = cfg[key]
|
||||||
if (Array.isArray(config.DEFAULTS[key])) {
|
const display = val ?? `${config.DEFAULTS[key]} (default)`
|
||||||
const arr = (val as string[] | undefined) ?? []
|
console.log(`${key} = ${display}`)
|
||||||
console.log(`${key} = ${arr.length ? arr.join(", ") : "(empty)"}`)
|
|
||||||
} else {
|
|
||||||
console.log(`${key} = ${val ?? `${config.DEFAULTS[key]} (default)`}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -22,32 +17,6 @@ export async function action(args: string[]) {
|
||||||
const [key, ...rest] = args
|
const [key, ...rest] = args
|
||||||
if (!VALID_KEYS.includes(key as config.Key)) die(`Unknown config key: ${key}\nAvailable keys: ${VALID_KEYS.join(", ")}`)
|
if (!VALID_KEYS.includes(key as config.Key)) die(`Unknown config key: ${key}\nAvailable keys: ${VALID_KEYS.join(", ")}`)
|
||||||
|
|
||||||
// Array keys: `config hosts add foo.local`, `config hosts rm foo.local`
|
|
||||||
if (ARRAY_KEYS.includes(key)) {
|
|
||||||
const current = ((await config.get(key as config.Key)) ?? []) as string[]
|
|
||||||
if (rest.length === 0) {
|
|
||||||
if (current.length === 0) console.log("(empty)")
|
|
||||||
else current.forEach(v => console.log(v))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const [op, ...values] = rest
|
|
||||||
if (op === "add") {
|
|
||||||
if (values.length === 0) die("Usage: sandlot config hosts add <hostname>")
|
|
||||||
const updated = [...new Set([...current, ...values])]
|
|
||||||
await config.set(key as config.Key, updated as any)
|
|
||||||
updated.forEach(v => console.log(v))
|
|
||||||
} else if (op === "rm" || op === "remove") {
|
|
||||||
if (values.length === 0) die("Usage: sandlot config hosts rm <hostname>")
|
|
||||||
const updated = current.filter(v => !values.includes(v))
|
|
||||||
await config.set(key as config.Key, updated as any)
|
|
||||||
if (updated.length === 0) console.log("(empty)")
|
|
||||||
else updated.forEach(v => console.log(v))
|
|
||||||
} else {
|
|
||||||
die(`Unknown operation: ${op}\nUsage: sandlot config ${key} add|rm <value>`)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
const val = await config.get(key as config.Key)
|
const val = await config.get(key as config.Key)
|
||||||
console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`)
|
console.log(val ?? `${config.DEFAULTS[key as config.Key]} (default)`)
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ const SKIP_RESOLVE = new Set([
|
||||||
"yarn.lock",
|
"yarn.lock",
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Resolve conflict markers in files using Pi, then stage them. */
|
/** Resolve conflict markers in files using Claude, then stage them. */
|
||||||
export async function resolveConflicts(
|
export async function resolveConflicts(
|
||||||
files: string[],
|
files: string[],
|
||||||
cwd: string,
|
cwd: string,
|
||||||
|
|
@ -124,13 +124,13 @@ export async function resolveConflicts(
|
||||||
throw new Error(`Failed to read conflicted file: ${file}`)
|
throw new Error(`Failed to read conflicted file: ${file}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolved = await vm.piPipe(
|
const resolved = await vm.claudePipe(
|
||||||
content,
|
content,
|
||||||
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
|
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (resolved.exitCode !== 0 || !resolved.stdout.trim()) {
|
if (resolved.exitCode !== 0 || !resolved.stdout.trim()) {
|
||||||
throw new Error(`Pi failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`)
|
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n")
|
await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n")
|
||||||
|
|
@ -162,7 +162,7 @@ export async function mergeAndClose(branch: string, opts?: { force?: boolean }):
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve conflicts with Pi
|
// Resolve conflicts with Claude
|
||||||
spin.text = `Resolving ${conflicts.length} conflict(s)`
|
spin.text = `Resolving ${conflicts.length} conflict(s)`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -210,7 +210,7 @@ export async function saveChanges(worktree: string, branch: string, message?: st
|
||||||
spin.text = "Generating commit message"
|
spin.text = "Generating commit message"
|
||||||
const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text()
|
const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text()
|
||||||
|
|
||||||
const gen = await vm.piPipe(
|
const gen = await vm.claudePipe(
|
||||||
diff,
|
diff,
|
||||||
"Write a commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.",
|
"Write a commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { basename } from "path"
|
import { basename } from "path"
|
||||||
|
import { homedir } from "os"
|
||||||
import { stat } from "fs/promises"
|
import { stat } from "fs/promises"
|
||||||
import * as git from "../git.ts"
|
import * as git from "../git.ts"
|
||||||
import * as vm from "../vm.ts"
|
import * as vm from "../vm.ts"
|
||||||
|
|
@ -43,7 +44,7 @@ async function resolveStatus(
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
try { await stat(s.worktree) } catch { return "idle" }
|
try { await stat(s.worktree) } catch { return "idle" }
|
||||||
if (vmRunning) {
|
if (vmRunning) {
|
||||||
const active = await vm.isPiActive(s.worktree, s.branch).catch(() => false)
|
const active = await vm.isClaudeActive(s.worktree, s.branch).catch(() => false)
|
||||||
if (active && s.in_review) return "review"
|
if (active && s.in_review) return "review"
|
||||||
if (active) return "active"
|
if (active) return "active"
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +58,7 @@ async function resolveStatus(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear in_review flags for sessions where Pi is no longer active. */
|
/** Clear in_review flags for sessions where Claude is no longer active. */
|
||||||
async function clearStaleReviews(
|
async function clearStaleReviews(
|
||||||
sessions: state.GlobalSession[],
|
sessions: state.GlobalSession[],
|
||||||
statusMap: Map<state.GlobalSession, string>,
|
statusMap: Map<state.GlobalSession, string>,
|
||||||
|
|
@ -74,9 +75,26 @@ async function clearStaleReviews(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backfillPrompts(_sessions: { worktree: string; prompt?: string }[], _vmRunning: boolean) {
|
async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) {
|
||||||
// Pi doesn't maintain a history.jsonl like Claude did.
|
if (!vmRunning) return
|
||||||
// Prompts are populated from state.json at session creation time.
|
const needsPrompt = sessions.filter(s => !s.prompt)
|
||||||
|
if (needsPrompt.length === 0) return
|
||||||
|
const result = await vm.exec(homedir() + "/.sandlot", "cat /home/ubuntu/.claude/history.jsonl 2>/dev/null").catch(() => null)
|
||||||
|
if (!result || result.exitCode !== 0 || !result.stdout) return
|
||||||
|
|
||||||
|
const byProject = new Map<string, string>()
|
||||||
|
for (const line of result.stdout.split("\n")) {
|
||||||
|
if (!line) continue
|
||||||
|
try {
|
||||||
|
const e = JSON.parse(line)
|
||||||
|
if (e.project && e.display) byProject.set(e.project, e.display)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of needsPrompt) {
|
||||||
|
const display = byProject.get(vm.containerPath(s.worktree))
|
||||||
|
if (display) s.prompt = display
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command ──────────────────────────────────────────────────────────
|
// ── Command ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ export async function action(
|
||||||
|
|
||||||
if (opts.print) {
|
if (opts.print) {
|
||||||
spin.text = "Running prompt…"
|
spin.text = "Running prompt…"
|
||||||
const result = await vm.pi(worktreeAbs, { prompt, print: opts.print })
|
const result = await vm.claude(worktreeAbs, { prompt, print: opts.print })
|
||||||
if (result.output) {
|
if (result.output) {
|
||||||
spin.stop()
|
spin.stop()
|
||||||
process.stdout.write(renderMarkdown(result.output) + "\n")
|
process.stdout.write(renderMarkdown(result.output) + "\n")
|
||||||
|
|
@ -134,7 +134,7 @@ export async function action(
|
||||||
spin.succeed("Done")
|
spin.succeed("Done")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await vm.pi(worktreeAbs, { prompt, print: opts.print })
|
await vm.claude(worktreeAbs, { prompt, print: opts.print })
|
||||||
}
|
}
|
||||||
|
|
||||||
await vm.clearActivity(worktreeAbs, branch)
|
await vm.clearActivity(worktreeAbs, branch)
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export async function action(
|
||||||
|
|
||||||
if (opts.print) {
|
if (opts.print) {
|
||||||
spin.text = "Running prompt…"
|
spin.text = "Running prompt…"
|
||||||
const result = await vm.pi(session.worktree, { prompt, print: opts.print, continue: true })
|
const result = await vm.claude(session.worktree, { prompt, print: opts.print, continue: true })
|
||||||
if (result.output) {
|
if (result.output) {
|
||||||
spin.stop()
|
spin.stop()
|
||||||
process.stdout.write(renderMarkdown(result.output) + "\n")
|
process.stdout.write(renderMarkdown(result.output) + "\n")
|
||||||
|
|
@ -30,7 +30,7 @@ export async function action(
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
spin.succeed("Session ready")
|
spin.succeed("Session ready")
|
||||||
await vm.pi(session.worktree, { prompt, print: opts.print, continue: true })
|
await vm.claude(session.worktree, { prompt, print: opts.print, continue: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
await vm.clearActivity(session.worktree, branch)
|
await vm.clearActivity(session.worktree, branch)
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export async function action(branch: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchSpin.stop()
|
fetchSpin.stop()
|
||||||
console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Pi...`)
|
console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
|
||||||
const resolveSpin = spinner("Starting container", branch)
|
const resolveSpin = spinner("Starting container", branch)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -74,11 +74,11 @@ Your thoughts, in brief.
|
||||||
try {
|
try {
|
||||||
if (opts.print) {
|
if (opts.print) {
|
||||||
spin.text = "Running review…"
|
spin.text = "Running review…"
|
||||||
const result = await vm.pi(session.worktree, { print: prompt })
|
const result = await vm.claude(session.worktree, { print: prompt })
|
||||||
if (result.output) process.stdout.write(result.output + "\n")
|
if (result.output) process.stdout.write(result.output + "\n")
|
||||||
} else {
|
} else {
|
||||||
spin.succeed("Session ready")
|
spin.succeed("Session ready")
|
||||||
await vm.pi(session.worktree, { prompt })
|
await vm.claude(session.worktree, { prompt })
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
spin.stop()
|
spin.stop()
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ export async function action(branch: string) {
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const gen = await vm.piPipe(
|
const gen = await vm.claudePipe(
|
||||||
diff,
|
diff,
|
||||||
"Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.",
|
"Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ export function register(program: Command) {
|
||||||
sessions.map(async (sess): Promise<[string, string]> => {
|
sessions.map(async (sess): Promise<[string, string]> => {
|
||||||
const key = `${basename(sess.repoRoot)}/${sess.branch}`
|
const key = `${basename(sess.repoRoot)}/${sess.branch}`
|
||||||
try {
|
try {
|
||||||
if (await vm.isPiActive(sess.worktree, sess.branch)) return [key, "active"]
|
if (await vm.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"]
|
||||||
if (await git.isDirty(sess.worktree)) return [key, "dirty"]
|
if (await git.isDirty(sess.worktree)) return [key, "dirty"]
|
||||||
if (await git.hasNewCommits(sess.worktree)) return [key, "saved"]
|
if (await git.hasNewCommits(sess.worktree)) return [key, "saved"]
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,14 @@ import { join } from "path"
|
||||||
const CONFIG_DIR = join(homedir(), ".config", "sandlot")
|
const CONFIG_DIR = join(homedir(), ".config", "sandlot")
|
||||||
const CONFIG_PATH = join(CONFIG_DIR, "config.json")
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json")
|
||||||
|
|
||||||
export const DEFAULTS: Record<string, string | string[]> = {
|
export const DEFAULTS = {
|
||||||
memory: "16G",
|
memory: "16G",
|
||||||
hosts: [],
|
} as const
|
||||||
}
|
|
||||||
|
|
||||||
export type Key = keyof typeof DEFAULTS
|
export type Key = keyof typeof DEFAULTS
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
memory?: string
|
memory?: string
|
||||||
hosts?: string[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_MEMORY_MB = 512
|
const MIN_MEMORY_MB = 512
|
||||||
|
|
|
||||||
130
src/vm.ts
130
src/vm.ts
|
|
@ -9,7 +9,7 @@ import { get as getConfig, DEFAULTS, validateMemory } from "./config.ts"
|
||||||
const DEBUG = !!process.env.DEBUG
|
const DEBUG = !!process.env.DEBUG
|
||||||
const CONTAINER_NAME = "sandlot"
|
const CONTAINER_NAME = "sandlot"
|
||||||
const USER = "ubuntu"
|
const USER = "ubuntu"
|
||||||
const PI_BIN = `/sandlot/.pi-bin/pi/pi`
|
const CLAUDE_BIN = `/home/${USER}/.local/bin/claude`
|
||||||
const CONTAINER_PATH = `/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`
|
const CONTAINER_PATH = `/sandlot/bin:/sandlot/.cargo/bin:/sandlot/.go/bin:/sandlot/.gopath/bin:/home/${USER}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`
|
||||||
const CONTAINER_ENV = {
|
const CONTAINER_ENV = {
|
||||||
RUSTUP_HOME: "/sandlot/.rustup",
|
RUSTUP_HOME: "/sandlot/.rustup",
|
||||||
|
|
@ -85,8 +85,8 @@ async function createContainer(home: string): Promise<void> {
|
||||||
/** Install base system packages (as root). */
|
/** Install base system packages (as root). */
|
||||||
async function installPackages(cached: boolean): Promise<void> {
|
async function installPackages(cached: boolean): Promise<void> {
|
||||||
const packages = cached
|
const packages = cached
|
||||||
? "curl git fish build-essential avahi-daemon libnss-mdns"
|
? "curl git fish build-essential"
|
||||||
: "curl git fish unzip build-essential avahi-daemon libnss-mdns"
|
: "curl git fish unzip build-essential"
|
||||||
await run(
|
await run(
|
||||||
$`container exec ${CONTAINER_NAME} bash -c ${`apt update && apt install -y ${packages}`}`,
|
$`container exec ${CONTAINER_NAME} bash -c ${`apt update && apt install -y ${packages}`}`,
|
||||||
"Package installation")
|
"Package installation")
|
||||||
|
|
@ -107,12 +107,12 @@ const CACHE_DIR = join(homedir(), '.sandlot', '.cache')
|
||||||
|
|
||||||
/** Check whether the package cache is populated. */
|
/** Check whether the package cache is populated. */
|
||||||
async function hasCachedTooling(): Promise<boolean> {
|
async function hasCachedTooling(): Promise<boolean> {
|
||||||
const files = ['bun', 'neofetch', 'nvim.tar.gz']
|
const files = ['bun', 'claude', 'neofetch', 'nvim.tar.gz']
|
||||||
const checks = await Promise.all(files.map(f => Bun.file(join(CACHE_DIR, f)).exists()))
|
const checks = await Promise.all(files.map(f => Bun.file(join(CACHE_DIR, f)).exists()))
|
||||||
return checks.every(Boolean)
|
return checks.every(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Install Bun, neofetch, and Neovim using cached binaries when available. Pi is installed persistently. */
|
/** Install Bun, Claude Code, neofetch, and Neovim using cached binaries when available. */
|
||||||
async function installTooling(cached: boolean, log?: (msg: string) => void): Promise<void> {
|
async function installTooling(cached: boolean, log?: (msg: string) => void): Promise<void> {
|
||||||
// Ensure cache directory exists on the host (mounted at /sandlot/.cache in the container)
|
// Ensure cache directory exists on the host (mounted at /sandlot/.cache in the container)
|
||||||
await $`mkdir -p ${CACHE_DIR}`.quiet()
|
await $`mkdir -p ${CACHE_DIR}`.quiet()
|
||||||
|
|
@ -123,12 +123,11 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro
|
||||||
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin"}`,
|
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.local/bin"}`,
|
||||||
"Create bin directory")
|
"Create bin directory")
|
||||||
await run(
|
await run(
|
||||||
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.cache/bun /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx"}`,
|
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp /sandlot/.cache/bun /sandlot/.cache/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx"}`,
|
||||||
"Install cached binaries")
|
"Install cached binaries")
|
||||||
await run(
|
await run(
|
||||||
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`,
|
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"tar xzf /sandlot/.cache/nvim.tar.gz -C ~/.local --strip-components=1"}`,
|
||||||
"Install cached Neovim")
|
"Install cached Neovim")
|
||||||
await installPersistentTooling(log)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,6 +136,11 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro
|
||||||
$`container exec --user ${USER} ${CONTAINER_NAME} env BUN_INSTALL=/home/${USER}/.local bash -c ${"curl -fsSL https://bun.sh/install | bash"}`,
|
$`container exec --user ${USER} ${CONTAINER_NAME} env BUN_INSTALL=/home/${USER}/.local bash -c ${"curl -fsSL https://bun.sh/install | bash"}`,
|
||||||
"Bun installation")
|
"Bun installation")
|
||||||
|
|
||||||
|
log?.("Installing Claude Code")
|
||||||
|
await run(
|
||||||
|
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://claude.ai/install.sh | bash"}`,
|
||||||
|
"Claude Code installation")
|
||||||
|
|
||||||
log?.("Installing neofetch")
|
log?.("Installing neofetch")
|
||||||
await run(
|
await run(
|
||||||
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch"}`,
|
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"curl -fsSL https://raw.githubusercontent.com/dylanaraps/neofetch/master/neofetch -o ~/.local/bin/neofetch && chmod +x ~/.local/bin/neofetch"}`,
|
||||||
|
|
@ -148,22 +152,13 @@ async function installTooling(cached: boolean, log?: (msg: string) => void): Pro
|
||||||
"Neovim installation")
|
"Neovim installation")
|
||||||
|
|
||||||
// Cache binaries for next time
|
// Cache binaries for next time
|
||||||
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet()
|
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"cp ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet()
|
||||||
|
|
||||||
await installPersistentTooling(log)
|
await installPersistentTooling(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Install Pi, Rust, and Go to /sandlot/ so they persist across container recreates. */
|
/** Install Rust and Go to /sandlot/ so they persist across container recreates. */
|
||||||
async function installPersistentTooling(log?: (msg: string) => void): Promise<void> {
|
async function installPersistentTooling(log?: (msg: string) => void): Promise<void> {
|
||||||
// Pi — skip if already installed on the persistent mount
|
|
||||||
const hasPi = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.pi-bin/pi/pi`.nothrow().quiet()
|
|
||||||
if (hasPi.exitCode !== 0) {
|
|
||||||
log?.("Installing Pi")
|
|
||||||
await run(
|
|
||||||
$`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p /sandlot/.pi-bin && curl -fsSL https://github.com/badlogic/pi-mono/releases/latest/download/pi-linux-arm64.tar.gz | tar xz -C /sandlot/.pi-bin"}`,
|
|
||||||
"Pi installation")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rust — skip if already installed on the persistent mount
|
// Rust — skip if already installed on the persistent mount
|
||||||
const hasRust = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.cargo/bin/rustc`.nothrow().quiet()
|
const hasRust = await $`container exec --user ${USER} ${CONTAINER_NAME} test -x /sandlot/.cargo/bin/rustc`.nothrow().quiet()
|
||||||
if (hasRust.exitCode !== 0) {
|
if (hasRust.exitCode !== 0) {
|
||||||
|
|
@ -217,72 +212,40 @@ async function installScript(home: string, name: string, content: string): Promi
|
||||||
await Bun.file(tmp).unlink()
|
await Bun.file(tmp).unlink()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Configure git identity, API key (via auth.json), activity extension, and Pi settings. */
|
/** Configure git identity, API key helper, activity hook, and Claude settings. */
|
||||||
async function configureEnvironment(home: string, apiKey: string): Promise<void> {
|
async function configureEnvironment(home: string, apiKey: string): Promise<void> {
|
||||||
const gitName = (await $`git config user.name`.quiet().text()).trim()
|
const gitName = (await $`git config user.name`.quiet().text()).trim()
|
||||||
const gitEmail = (await $`git config user.email`.quiet().text()).trim()
|
const gitEmail = (await $`git config user.email`.quiet().text()).trim()
|
||||||
if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet()
|
if (gitName) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.name ${gitName}`.quiet()
|
||||||
if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet()
|
if (gitEmail) await $`container exec --user ${USER} ${CONTAINER_NAME} git config --global user.email ${gitEmail}`.quiet()
|
||||||
|
|
||||||
const settingsJson = JSON.stringify({
|
const activityBin = `/home/${USER}/.local/bin/sandlot-activity`
|
||||||
defaultProvider: "anthropic",
|
const hooks = {
|
||||||
defaultModel: "claude-opus-4-6",
|
UserPromptSubmit: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }],
|
||||||
defaultThinkingLevel: "high",
|
PreToolUse: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }],
|
||||||
quietStartup: true,
|
}
|
||||||
})
|
const statusLine = { type: "command", command: `/home/${USER}/.local/bin/sandlot-statusline` }
|
||||||
const authJson = JSON.stringify({
|
const settingsJson = JSON.stringify({ apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks, statusLine })
|
||||||
anthropic: { type: "api_key", key: apiKey },
|
const claudeJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: true, projects: { "/": { hasTrustDialogAccepted: true } } })
|
||||||
})
|
|
||||||
|
|
||||||
// Write auth.json to a temp file and copy it in so the key
|
// Write the helper script to a temp file and copy it in so the key
|
||||||
// never appears in a process argument visible in `ps`.
|
// never appears in a process argument visible in `ps`.
|
||||||
const tmp = `${home}/.sandlot/.auth-json.tmp`
|
const tmp = `${home}/.sandlot/.api-key-helper.tmp`
|
||||||
await Bun.write(tmp, authJson)
|
await Bun.write(tmp, `#!/bin/sh\necho '${apiKey.replace(/'/g, "'\\''")}'\n`)
|
||||||
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.pi/agent && cp /sandlot/.auth-json.tmp ~/.pi/agent/auth.json && chmod 600 ~/.pi/agent/auth.json"}`.quiet()
|
await $`chmod +x ${tmp}`.quiet()
|
||||||
|
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.claude && cp /sandlot/.api-key-helper.tmp ~/.claude/api-key-helper.sh"}`.quiet()
|
||||||
await Bun.file(tmp).unlink()
|
await Bun.file(tmp).unlink()
|
||||||
|
|
||||||
// Write the activity-tracking extension
|
await installScript(home, "sandlot-activity", `#!/bin/bash\nP="\${CLAUDE_PROJECT_DIR%/}"\necho "$1" > "$(dirname "$P")/.activity-$(basename "$P")"\n`)
|
||||||
const extensionContent = `import { writeFileSync } from "fs";
|
await installScript(home, "sandlot-statusline", `#!/bin/bash\ninput=$(cat)\ncwd=$(echo "$input" | grep -oP '"cwd"\\s*:\\s*"\\K[^"]+' | head -1)\n[ -n "$cwd" ] && printf '\\033[36m\u2387 %s\\033[0m\\n' "$(basename "$cwd")"\n`)
|
||||||
import { dirname, basename, join } from "path";
|
|
||||||
export default function (pi) {
|
|
||||||
function markActive(ctx) {
|
|
||||||
try {
|
|
||||||
const cwd = ctx.cwd;
|
|
||||||
const file = join(dirname(cwd), ".activity-" + basename(cwd));
|
|
||||||
writeFileSync(file, "active\\n");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
pi.on("before_agent_start", async (_event, ctx) => { markActive(ctx); });
|
|
||||||
pi.on("tool_call", async (_event, ctx) => { markActive(ctx); });
|
|
||||||
}
|
|
||||||
`
|
|
||||||
const extTmp = `${home}/.sandlot/.sandlot-activity-ext.tmp`
|
|
||||||
await Bun.write(extTmp, extensionContent)
|
|
||||||
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${"mkdir -p ~/.pi/agent/extensions && cp /sandlot/.sandlot-activity-ext.tmp ~/.pi/agent/extensions/sandlot-activity.ts"}`.quiet()
|
|
||||||
await Bun.file(extTmp).unlink()
|
|
||||||
|
|
||||||
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`
|
await $`container exec --user ${USER} ${CONTAINER_NAME} bash -c ${`
|
||||||
mkdir -p ~/.pi/agent
|
mkdir -p ~/.claude
|
||||||
echo '${settingsJson}' > ~/.pi/agent/settings.json
|
echo '${settingsJson}' > ~/.claude/settings.json
|
||||||
|
echo '${claudeJson}' > ~/.claude.json
|
||||||
`}`.quiet()
|
`}`.quiet()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve hostnames on the host (via mDNS) and add them to the container's /etc/hosts.
|
|
||||||
* Reads the "hosts" array from config (e.g. ["claude.toes.local"]). */
|
|
||||||
async function syncLocalHosts(): Promise<void> {
|
|
||||||
const hostnames = (await getConfig("hosts")) as string[] | undefined
|
|
||||||
if (!hostnames?.length) return
|
|
||||||
const entries: string[] = []
|
|
||||||
for (const name of hostnames) {
|
|
||||||
const out = (await $`dscacheutil -q host -a name ${name}`.nothrow().quiet().text()).trim()
|
|
||||||
const match = out.match(/ip_address:\s+(\S+)/)
|
|
||||||
if (match) entries.push(`${match[1]} ${name}`)
|
|
||||||
}
|
|
||||||
if (!entries.length) return
|
|
||||||
const block = entries.join("\\n")
|
|
||||||
await $`container exec ${CONTAINER_NAME} bash -c ${`grep -v '# sandlot-hosts' /etc/hosts > /tmp/hosts.clean; echo -e '${block}' | sed 's/$/ # sandlot-hosts/' >> /tmp/hosts.clean; cp /tmp/hosts.clean /etc/hosts`}`.nothrow().quiet()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── create() ────────────────────────────────────────────────────────
|
// ── create() ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Create and provision the container from scratch. Fails if it already exists. */
|
/** Create and provision the container from scratch. Fails if it already exists. */
|
||||||
|
|
@ -310,7 +273,6 @@ export async function create(log?: (msg: string) => void): Promise<void> {
|
||||||
|
|
||||||
log?.("Configuring environment")
|
log?.("Configuring environment")
|
||||||
await configureEnvironment(home, apiKey)
|
await configureEnvironment(home, apiKey)
|
||||||
await syncLocalHosts()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start a stopped container. */
|
/** Start a stopped container. */
|
||||||
|
|
@ -320,7 +282,6 @@ export async function start(): Promise<void> {
|
||||||
if (s === "running") return
|
if (s === "running") return
|
||||||
if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.")
|
if (s === "missing") throw new Error("Container does not exist. Use 'sandlot vm create' first.")
|
||||||
await run($`container start ${CONTAINER_NAME}`, "Container start")
|
await run($`container start ${CONTAINER_NAME}`, "Container start")
|
||||||
await syncLocalHosts()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ensure the sandlot container exists and is running. Creates and provisions on first use. */
|
/** Ensure the sandlot container exists and is running. Creates and provisions on first use. */
|
||||||
|
|
@ -333,10 +294,7 @@ export async function ensure(log?: (msg: string) => void): Promise<void> {
|
||||||
else await $`container system start --enable-kernel-install`.nothrow().quiet()
|
else await $`container system start --enable-kernel-install`.nothrow().quiet()
|
||||||
|
|
||||||
const s = await status()
|
const s = await status()
|
||||||
if (s === "running") {
|
if (s === "running") return
|
||||||
await syncLocalHosts()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s === "stopped") {
|
if (s === "stopped") {
|
||||||
await start()
|
await start()
|
||||||
|
|
@ -360,8 +318,8 @@ export async function status(): Promise<"running" | "stopped" | "missing"> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Launch pi in the container at the given workdir. */
|
/** Launch claude in the container at the given workdir. */
|
||||||
export async function pi(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<{ exitCode: number; output?: string }> {
|
export async function claude(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<{ exitCode: number; output?: string }> {
|
||||||
const cwd = containerPath(workdir)
|
const cwd = containerPath(workdir)
|
||||||
const mounts = hostMounts(homedir())
|
const mounts = hostMounts(homedir())
|
||||||
const systemPromptLines = [
|
const systemPromptLines = [
|
||||||
|
|
@ -382,7 +340,7 @@ export async function pi(workdir: string, opts?: { prompt?: string; print?: stri
|
||||||
|
|
||||||
const term = process.env.TERM || "xterm-256color"
|
const term = process.env.TERM || "xterm-256color"
|
||||||
const envArgs = [`TERM=${term}`, `PATH=${CONTAINER_PATH}`, ...Object.entries(CONTAINER_ENV).map(([k, v]) => `${k}=${v}`)]
|
const envArgs = [`TERM=${term}`, `PATH=${CONTAINER_PATH}`, ...Object.entries(CONTAINER_ENV).map(([k, v]) => `${k}=${v}`)]
|
||||||
const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", ...envArgs, PI_BIN, "--append-system-prompt", systemPrompt]
|
const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", ...envArgs, CLAUDE_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--effort", "max", "--append-system-prompt", systemPrompt]
|
||||||
if (opts?.continue) args.push("--continue")
|
if (opts?.continue) args.push("--continue")
|
||||||
if (opts?.print) args.push("-p", opts.print)
|
if (opts?.print) args.push("-p", opts.print)
|
||||||
else if (opts?.prompt) args.push(opts.prompt)
|
else if (opts?.prompt) args.push(opts.prompt)
|
||||||
|
|
@ -393,7 +351,7 @@ export async function pi(workdir: string, opts?: { prompt?: string; print?: stri
|
||||||
const exitCode = await proc.exited
|
const exitCode = await proc.exited
|
||||||
if (exitCode !== 0 && opts?.continue) {
|
if (exitCode !== 0 && opts?.continue) {
|
||||||
info("Retrying without --continue")
|
info("Retrying without --continue")
|
||||||
return pi(workdir, { ...opts, continue: false })
|
return claude(workdir, { ...opts, continue: false })
|
||||||
}
|
}
|
||||||
return { exitCode, output }
|
return { exitCode, output }
|
||||||
}
|
}
|
||||||
|
|
@ -402,7 +360,7 @@ export async function pi(workdir: string, opts?: { prompt?: string; print?: stri
|
||||||
const exitCode = await proc.exited
|
const exitCode = await proc.exited
|
||||||
if (exitCode !== 0 && opts?.continue) {
|
if (exitCode !== 0 && opts?.continue) {
|
||||||
info("Retrying without --continue")
|
info("Retrying without --continue")
|
||||||
return pi(workdir, { ...opts, continue: false })
|
return claude(workdir, { ...opts, continue: false })
|
||||||
}
|
}
|
||||||
return { exitCode }
|
return { exitCode }
|
||||||
}
|
}
|
||||||
|
|
@ -437,23 +395,23 @@ export async function exec(workdir: string, command: string): Promise<{ exitCode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pipe input text to Pi in the container with a prompt, returning the output. */
|
/** Pipe input text to Claude in the container with a prompt, returning the output. */
|
||||||
export async function piPipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
export async function claudePipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||||
const tmpName = `.pi-pipe-${crypto.randomUUID()}`
|
const tmpName = `.claude-pipe-${crypto.randomUUID()}`
|
||||||
const tmpPath = join(homedir(), '.sandlot', tmpName)
|
const tmpPath = join(homedir(), '.sandlot', tmpName)
|
||||||
try {
|
try {
|
||||||
await Bun.write(tmpPath, input)
|
await Bun.write(tmpPath, input)
|
||||||
return await exec(
|
return await exec(
|
||||||
join(homedir(), '.sandlot'),
|
join(homedir(), '.sandlot'),
|
||||||
`cat /sandlot/${tmpName} | /sandlot/.pi-bin/pi/pi -p "${prompt.replace(/"/g, '\\"')}"`,
|
`cat /sandlot/${tmpName} | claude --model claude-opus-4-6 --effort max -p "${prompt.replace(/"/g, '\\"')}"`,
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await Bun.file(tmpPath).unlink().catch(() => {})
|
await Bun.file(tmpPath).unlink().catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if Pi is actively working in the given worktree (based on activity hook). */
|
/** Check if Claude is actively working in the given worktree (based on activity hook). */
|
||||||
export async function isPiActive(worktree: string, branch: string): Promise<boolean> {
|
export async function isClaudeActive(worktree: string, branch: string): Promise<boolean> {
|
||||||
const file = `${dirname(worktree)}/.activity-${branch}`
|
const file = `${dirname(worktree)}/.activity-${branch}`
|
||||||
try {
|
try {
|
||||||
const content = await Bun.file(file).text()
|
const content = await Bun.file(file).text()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user