Compare commits
10 Commits
rust-rewri
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 86d426f92c | |||
| 049fc4b00b | |||
| 50517227db | |||
| 9a334992ad | |||
| e64564a045 | |||
| 9eded80a0e | |||
| 846f2cd021 | |||
| 894a0455a7 | |||
| a7e3a6333b | |||
| d30a5b94ab |
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.claude()` → launch Claude Code in container at worktree path
|
5. `vm.pi()` → launch Pi 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,11 +85,13 @@ 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, Claude Code, neofetch, and Neovim
|
- Provisioned once on first use: apt installs `curl git fish unzip`, then installs Bun, neofetch, and Neovim
|
||||||
- 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
|
- Binary caching: after first install, bun/neofetch/nvim are cached in `~/.sandlot/.cache/` — subsequent `vm create` uses cached copies, skipping downloads
|
||||||
- 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)
|
- Persistent tooling: Pi, Rust, Go, and sccache are installed to `/sandlot/` paths that survive container recreates
|
||||||
- Claude settings: `skipDangerousModePermissionPrompt: true`, activity tracking hooks (`UserPromptSubmit` / `Stop`) in container
|
- Pi is installed as a standalone binary from GitHub releases (`pi-linux-arm64.tar.gz`) to `/sandlot/.pi-bin/`
|
||||||
- Also writes `~/.claude.json` with `hasCompletedOnboarding: true` and `effortCalloutDismissed: true`
|
- 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)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
@ -138,23 +140,21 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
|
||||||
|
|
||||||
## Key Implementation Notes
|
## Key Implementation Notes
|
||||||
|
|
||||||
- `vm.exec()` prepends `export PATH=$HOME/.local/bin:$PATH` so `claude` binary is found
|
- `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.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()` 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()` runs Claude with `--dangerously-skip-permissions`, `--model claude-opus-4-6`, and `--append-system-prompt` (system prompt describes the container environment)
|
- `vm.pi()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue`
|
||||||
- `vm.claude()` retry logic: if exit code is non-zero and `--continue` was used, retries without `--continue`
|
- `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.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.isPiActive()` reads activity marker files written by the in-container `sandlot-activity.ts` Pi extension
|
||||||
- `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.claude()` to resume the previous conversation
|
- `sandlot open` always passes `continue: true` to `vm.pi()` to resume the previous conversation
|
||||||
- `sandlot save` uses `vm.claudePipe()` to generate commit messages from the staged diff
|
- `sandlot save` uses `vm.piPipe()` to generate commit messages from the staged diff
|
||||||
- `sandlot merge` and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically
|
- `sandlot merge` and `sandlot rebase` use `vm.piPipe()` 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 `claudePipe()`; 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 `piPipe()`; 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 (Claude 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 (Pi not active) and clears them from state
|
||||||
- `sandlot new` and `sandlot open` auto-save changes when Claude exits (disable with `--no-save`)
|
- `sandlot new` and `sandlot open` auto-save changes when Pi 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,3 +59,17 @@ 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.50",
|
"version": "0.0.54",
|
||||||
"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
1
rust-sandlot/.gitignore
vendored
|
|
@ -1 +0,0 @@
|
||||||
target/
|
|
||||||
1872
rust-sandlot/Cargo.lock
generated
1872
rust-sandlot/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -1,24 +0,0 @@
|
||||||
[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"
|
|
||||||
|
|
@ -1,895 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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)\""
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,414 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
use anyhow::Result;
|
|
||||||
|
|
||||||
use super::helpers::merge_and_close;
|
|
||||||
|
|
||||||
pub async fn action(branch: &str, force: bool) -> Result<()> {
|
|
||||||
merge_and_close(branch, force).await
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,435 +0,0 @@
|
||||||
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.")
|
|
||||||
}
|
|
||||||
|
|
@ -1,341 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,908 +0,0 @@
|
||||||
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 Claude.")
|
.description("Sandboxed development with Pi.")
|
||||||
.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 Claude")
|
.argument("[prompt]", "initial prompt for Pi")
|
||||||
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
|
.option("-p, --print <prompt>", "run Pi in non-interactive mode with -p")
|
||||||
.option("-n, --no-save", "skip auto-save after Claude exits")
|
.option("-n, --no-save", "skip auto-save after Pi exits")
|
||||||
.description("Create a new session and launch Claude")
|
.description("Create a new session and launch Pi")
|
||||||
.action(newAction)
|
.action(newAction)
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("open")
|
.command("open")
|
||||||
.argument("<branch>", "branch name")
|
.argument("<branch>", "branch name")
|
||||||
.argument("[prompt]", "initial prompt for Claude")
|
.argument("[prompt]", "initial prompt for Pi")
|
||||||
.option("-p, --print <prompt>", "run Claude in non-interactive mode with -p")
|
.option("-p, --print <prompt>", "run Pi in non-interactive mode with -p")
|
||||||
.option("-n, --no-save", "skip auto-save after Claude exits")
|
.option("-n, --no-save", "skip auto-save after Pi exits")
|
||||||
.description("Open an existing Claude session")
|
.description("Open an existing Pi session")
|
||||||
.action(openAction)
|
.action(openAction)
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,19 @@ 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]
|
||||||
const display = val ?? `${config.DEFAULTS[key]} (default)`
|
if (Array.isArray(config.DEFAULTS[key])) {
|
||||||
console.log(`${key} = ${display}`)
|
const arr = (val as string[] | undefined) ?? []
|
||||||
|
console.log(`${key} = ${arr.length ? arr.join(", ") : "(empty)"}`)
|
||||||
|
} else {
|
||||||
|
console.log(`${key} = ${val ?? `${config.DEFAULTS[key]} (default)`}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -17,6 +22,32 @@ 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 Claude, then stage them. */
|
/** Resolve conflict markers in files using Pi, 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.claudePipe(
|
const resolved = await vm.piPipe(
|
||||||
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(`Claude failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`)
|
throw new Error(`Pi 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 Claude
|
// Resolve conflicts with Pi
|
||||||
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.claudePipe(
|
const gen = await vm.piPipe(
|
||||||
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,5 +1,4 @@
|
||||||
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"
|
||||||
|
|
@ -44,7 +43,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.isClaudeActive(s.worktree, s.branch).catch(() => false)
|
const active = await vm.isPiActive(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"
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +57,7 @@ async function resolveStatus(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear in_review flags for sessions where Claude is no longer active. */
|
/** Clear in_review flags for sessions where Pi 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>,
|
||||||
|
|
@ -75,26 +74,9 @@ async function clearStaleReviews(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) {
|
async function backfillPrompts(_sessions: { worktree: string; prompt?: string }[], _vmRunning: boolean) {
|
||||||
if (!vmRunning) return
|
// Pi doesn't maintain a history.jsonl like Claude did.
|
||||||
const needsPrompt = sessions.filter(s => !s.prompt)
|
// Prompts are populated from state.json at session creation time.
|
||||||
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.claude(worktreeAbs, { prompt, print: opts.print })
|
const result = await vm.pi(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.claude(worktreeAbs, { prompt, print: opts.print })
|
await vm.pi(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.claude(session.worktree, { prompt, print: opts.print, continue: true })
|
const result = await vm.pi(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.claude(session.worktree, { prompt, print: opts.print, continue: true })
|
await vm.pi(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 Claude...`)
|
console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Pi...`)
|
||||||
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.claude(session.worktree, { print: prompt })
|
const result = await vm.pi(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.claude(session.worktree, { prompt })
|
await vm.pi(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.claudePipe(
|
const gen = await vm.piPipe(
|
||||||
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.isClaudeActive(sess.worktree, sess.branch)) return [key, "active"]
|
if (await vm.isPiActive(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,14 +5,16 @@ 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 = {
|
export const DEFAULTS: Record<string, string | string[]> = {
|
||||||
memory: "16G",
|
memory: "16G",
|
||||||
} as const
|
hosts: [],
|
||||||
|
}
|
||||||
|
|
||||||
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 CLAUDE_BIN = `/home/${USER}/.local/bin/claude`
|
const PI_BIN = `/sandlot/.pi-bin/pi/pi`
|
||||||
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"
|
? "curl git fish build-essential avahi-daemon libnss-mdns"
|
||||||
: "curl git fish unzip build-essential"
|
: "curl git fish unzip build-essential avahi-daemon libnss-mdns"
|
||||||
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', 'claude', 'neofetch', 'nvim.tar.gz']
|
const files = ['bun', '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, Claude Code, neofetch, and Neovim using cached binaries when available. */
|
/** Install Bun, neofetch, and Neovim using cached binaries when available. Pi is installed persistently. */
|
||||||
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,11 +123,12 @@ 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/claude /sandlot/.cache/neofetch ~/.local/bin/ && chmod +x ~/.local/bin/bun ~/.local/bin/claude ~/.local/bin/neofetch && ln -sf bun ~/.local/bin/bunx"}`,
|
$`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"}`,
|
||||||
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,11 +137,6 @@ 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"}`,
|
||||||
|
|
@ -152,13 +148,22 @@ 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/claude ~/.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/neofetch /sandlot/.cache/ && cp /tmp/nvim.tar.gz /sandlot/.cache/nvim.tar.gz"}`.nothrow().quiet()
|
||||||
|
|
||||||
await installPersistentTooling(log)
|
await installPersistentTooling(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Install Rust and Go to /sandlot/ so they persist across container recreates. */
|
/** Install Pi, 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) {
|
||||||
|
|
@ -212,40 +217,72 @@ 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 helper, activity hook, and Claude settings. */
|
/** Configure git identity, API key (via auth.json), activity extension, and Pi 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 activityBin = `/home/${USER}/.local/bin/sandlot-activity`
|
const settingsJson = JSON.stringify({
|
||||||
const hooks = {
|
defaultProvider: "anthropic",
|
||||||
UserPromptSubmit: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }],
|
defaultModel: "claude-opus-4-6",
|
||||||
PreToolUse: [{ hooks: [{ type: "command", command: `${activityBin} active` }] }],
|
defaultThinkingLevel: "high",
|
||||||
}
|
quietStartup: true,
|
||||||
const statusLine = { type: "command", command: `/home/${USER}/.local/bin/sandlot-statusline` }
|
})
|
||||||
const settingsJson = JSON.stringify({ apiKeyHelper: "~/.claude/api-key-helper.sh", skipDangerousModePermissionPrompt: true, hooks, statusLine })
|
const authJson = JSON.stringify({
|
||||||
const claudeJson = JSON.stringify({ hasCompletedOnboarding: true, effortCalloutDismissed: true, projects: { "/": { hasTrustDialogAccepted: true } } })
|
anthropic: { type: "api_key", key: apiKey },
|
||||||
|
})
|
||||||
|
|
||||||
// Write the helper script to a temp file and copy it in so the key
|
// Write auth.json 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/.api-key-helper.tmp`
|
const tmp = `${home}/.sandlot/.auth-json.tmp`
|
||||||
await Bun.write(tmp, `#!/bin/sh\necho '${apiKey.replace(/'/g, "'\\''")}'\n`)
|
await Bun.write(tmp, authJson)
|
||||||
await $`chmod +x ${tmp}`.quiet()
|
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 $`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()
|
||||||
|
|
||||||
await installScript(home, "sandlot-activity", `#!/bin/bash\nP="\${CLAUDE_PROJECT_DIR%/}"\necho "$1" > "$(dirname "$P")/.activity-$(basename "$P")"\n`)
|
// Write the activity-tracking extension
|
||||||
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`)
|
const extensionContent = `import { writeFileSync } from "fs";
|
||||||
|
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 ~/.claude
|
mkdir -p ~/.pi/agent
|
||||||
echo '${settingsJson}' > ~/.claude/settings.json
|
echo '${settingsJson}' > ~/.pi/agent/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. */
|
||||||
|
|
@ -273,6 +310,7 @@ 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. */
|
||||||
|
|
@ -282,6 +320,7 @@ 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. */
|
||||||
|
|
@ -294,7 +333,10 @@ 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") return
|
if (s === "running") {
|
||||||
|
await syncLocalHosts()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (s === "stopped") {
|
if (s === "stopped") {
|
||||||
await start()
|
await start()
|
||||||
|
|
@ -318,8 +360,8 @@ export async function status(): Promise<"running" | "stopped" | "missing"> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Launch claude in the container at the given workdir. */
|
/** Launch pi in the container at the given workdir. */
|
||||||
export async function claude(workdir: string, opts?: { prompt?: string; print?: string; continue?: boolean }): Promise<{ exitCode: number; output?: string }> {
|
export async function pi(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 = [
|
||||||
|
|
@ -340,7 +382,7 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?:
|
||||||
|
|
||||||
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, CLAUDE_BIN, "--dangerously-skip-permissions", "--model", "claude-opus-4-6", "--effort", "max", "--append-system-prompt", systemPrompt]
|
const args = ["container", "exec", "-it", "--user", USER, "--workdir", cwd, CONTAINER_NAME, "env", ...envArgs, PI_BIN, "--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)
|
||||||
|
|
@ -351,7 +393,7 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?:
|
||||||
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 claude(workdir, { ...opts, continue: false })
|
return pi(workdir, { ...opts, continue: false })
|
||||||
}
|
}
|
||||||
return { exitCode, output }
|
return { exitCode, output }
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +402,7 @@ export async function claude(workdir: string, opts?: { prompt?: string; print?:
|
||||||
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 claude(workdir, { ...opts, continue: false })
|
return pi(workdir, { ...opts, continue: false })
|
||||||
}
|
}
|
||||||
return { exitCode }
|
return { exitCode }
|
||||||
}
|
}
|
||||||
|
|
@ -395,23 +437,23 @@ export async function exec(workdir: string, command: string): Promise<{ exitCode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pipe input text to Claude in the container with a prompt, returning the output. */
|
/** Pipe input text to Pi in the container with a prompt, returning the output. */
|
||||||
export async function claudePipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
export async function piPipe(input: string, prompt: string): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||||
const tmpName = `.claude-pipe-${crypto.randomUUID()}`
|
const tmpName = `.pi-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} | claude --model claude-opus-4-6 --effort max -p "${prompt.replace(/"/g, '\\"')}"`,
|
`cat /sandlot/${tmpName} | /sandlot/.pi-bin/pi/pi -p "${prompt.replace(/"/g, '\\"')}"`,
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await Bun.file(tmpPath).unlink().catch(() => {})
|
await Bun.file(tmpPath).unlink().catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check if Claude is actively working in the given worktree (based on activity hook). */
|
/** Check if Pi is actively working in the given worktree (based on activity hook). */
|
||||||
export async function isClaudeActive(worktree: string, branch: string): Promise<boolean> {
|
export async function isPiActive(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