Compare commits

..

No commits in common. "5c378ab239ae4718710d50089b82ae8f1d0fc94d" and "dfb62f7646f6014b036b76bfe9c45d28679425cb" have entirely different histories.

5 changed files with 62 additions and 156 deletions

View File

@ -45,7 +45,7 @@ src/
list.ts # Show all active sessions with status list.ts # Show all active sessions with status
save.ts # Stage all changes and commit save.ts # Stage all changes and commit
merge.ts # Merge branch into main with conflict resolution merge.ts # Merge branch into main with conflict resolution
squash.ts # Squash all commits on a branch into a single commit (in-place) squash.ts # Squash-merge branch into main (AI commit message) and close
rebase.ts # Rebase branch onto main with conflict resolution rebase.ts # Rebase branch onto main with conflict resolution
review.ts # Launch grumpy code review with Claude review.ts # Launch grumpy code review with Claude
diff.ts # Show uncommitted changes or full branch diff diff.ts # Show uncommitted changes or full branch diff
@ -148,9 +148,9 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
- `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.claude()` to resume the previous conversation
- `sandlot save` uses `vm.claudePipe()` to generate commit messages from the staged diff - `sandlot save` uses `vm.claudePipe()` to generate commit messages from the staged diff
- `sandlot merge` and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically - `sandlot merge`, `sandlot squash`, and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically
- `sandlot squash` collapses all branch commits into a single commit in-place via `git reset --soft` to the merge base, then generates an AI commit message via `claudePipe()`; falls back to `"squash <branch>"`. Rolls back to the original HEAD on failure. - `sandlot squash` generates an AI commit message for the squash commit via `claudePipe()`; falls back to `"squash <branch>"`
- `sandlot merge` delegates to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up - `sandlot merge` and `sandlot squash` both delegate 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` 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 (Claude not active) and clears them from state

View File

@ -141,8 +141,9 @@ program
program program
.command("squash") .command("squash")
.argument("<branch>", "branch name") .argument("<branch>", "branch name")
.description("Squash all commits on a branch into a single commit") .option("-f, --force", "allow merging into a non-main branch")
.action((branch: string) => squashAction(branch)) .description("Squash-merge a branch into main and close the session")
.action((branch: string, opts: { force?: boolean }) => squashAction(branch, opts))
program program
.command("rebase") .command("rebase")

View File

@ -81,29 +81,6 @@ export async function teardownSession(root: string, branch: string, worktree: st
await state.removeSession(root, branch) await state.removeSession(root, branch)
} }
/** Generated files to skip AI resolution — accept theirs and move on (basename match so `packages/foo/bun.lock` is also covered). */
const SKIP_RESOLVE = new Set([
"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",
])
/** Resolve conflict markers in files using Claude, then stage them. */ /** Resolve conflict markers in files using Claude, then stage them. */
export async function resolveConflicts( export async function resolveConflicts(
files: string[], files: string[],
@ -113,24 +90,15 @@ export async function resolveConflicts(
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i] const file = files[i]
onFile(file, i + 1, files.length) onFile(file, i + 1, files.length)
const content = await Bun.file(join(cwd, file)).text()
if (SKIP_RESOLVE.has(basename(file))) {
await git.checkoutTheirs(file, cwd)
await git.stageFile(file, cwd)
continue
}
const content = await Bun.file(join(cwd, file)).text().catch(() => {
throw new Error(`Failed to read conflicted file: ${file}`)
})
const resolved = await vm.claudePipe( const resolved = await vm.claudePipe(
content, content,
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.", "resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
) )
if (resolved.exitCode !== 0 || !resolved.stdout.trim()) { if (resolved.exitCode !== 0) {
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`) throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`)
} }
await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n") await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n")
@ -138,8 +106,8 @@ export async function resolveConflicts(
} }
} }
/** Merge a branch into main, resolve conflicts if needed, and close the session. */ /** Merge (or squash-merge) a branch into main, resolve conflicts if needed, and close the session. */
export async function mergeAndClose(branch: string, opts?: { force?: boolean }): Promise<void> { export async function mergeAndClose(branch: string, opts?: { squash?: boolean; force?: boolean }): Promise<void> {
const root = await git.repoRoot() const root = await git.repoRoot()
const main = await git.mainBranch(root) const main = await git.mainBranch(root)
const current = await git.currentBranch(root) const current = await git.currentBranch(root)
@ -152,11 +120,18 @@ export async function mergeAndClose(branch: string, opts?: { force?: boolean }):
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`) die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
} }
const label = opts?.squash ? "Squash-merged" : "Merged"
const spin = spinner("Merging", branch) const spin = spinner("Merging", branch)
const conflicts = await git.merge(branch, root) const conflicts = await git.merge(branch, root, opts?.squash ? { squash: true } : undefined)
if (conflicts.length === 0) { if (conflicts.length === 0) {
spin.succeed(`Merged ${branch} into ${current}`) if (opts?.squash) {
spin.text = "Starting container"
await vm.ensure((msg) => { spin.text = msg })
spin.text = "Generating commit message"
await squashCommit(branch, root)
}
spin.succeed(`${label} ${branch} into ${current}`)
if (session) await teardownSession(root, branch, session.worktree) if (session) await teardownSession(root, branch, session.worktree)
await git.deleteLocalBranch(branch, root).catch(() => {}) await git.deleteLocalBranch(branch, root).catch(() => {})
return return
@ -172,12 +147,15 @@ export async function mergeAndClose(branch: string, opts?: { force?: boolean }):
spin.text = total > 1 ? `(${i}/${total}) Resolving ${file}` : `Resolving ${file}` spin.text = total > 1 ? `(${i}/${total}) Resolving ${file}` : `Resolving ${file}`
}) })
await git.commitMerge(root) if (opts?.squash) {
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) await squashCommit(branch, root)
} else {
await git.commitMerge(root)
}
spin.succeed(`Resolved ${conflicts.length} conflict(s) and ${label.toLowerCase()} ${branch}`)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
spin.fail(message) spin.fail(message)
if (session) await vm.clearActivity(session.worktree, branch) // process.exit below skips finally
await git.abortMerge(root) await git.abortMerge(root)
process.exit(1) process.exit(1)
} finally { } finally {
@ -188,6 +166,25 @@ export async function mergeAndClose(branch: string, opts?: { force?: boolean }):
await git.deleteLocalBranch(branch, root).catch(() => {}) await git.deleteLocalBranch(branch, root).catch(() => {})
} }
/** Generate a commit message for a squash-merge via Claude and commit. */
async function squashCommit(branch: string, cwd: string): Promise<void> {
const diff = await git.diffStaged(cwd)
if (diff.trim()) {
const gen = await vm.claudePipe(
diff,
"Write a commit message summarizing all changes in this squash merge. 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.",
)
if (gen.exitCode === 0 && gen.stdout.trim()) {
await git.commit(gen.stdout.trim(), cwd)
return
}
}
// Fallback if diff is empty or Claude fails
await git.commit(`squash ${branch}`, cwd)
}
/** Stage all changes, generate a commit message, and commit. Returns true on success. */ /** Stage all changes, generate a commit message, and commit. Returns true on success. */
export async function saveChanges(worktree: string, branch: string, message?: string): Promise<boolean> { export async function saveChanges(worktree: string, branch: string, message?: string): Promise<boolean> {
const spin = spinner("Staging changes", branch) const spin = spinner("Staging changes", branch)

View File

@ -1,67 +1,5 @@
import * as git from "../git.ts" import { mergeAndClose } from "./helpers.ts"
import * as vm from "../vm.ts"
import { spinner } from "../spinner.ts"
import { die } from "../fmt.ts"
import { requireSession } from "./helpers.ts"
export async function action(branch: string) { export async function action(branch: string, opts?: { force?: boolean }) {
const { session } = await requireSession(branch) await mergeAndClose(branch, { squash: true, force: opts?.force })
const worktree = session.worktree
if (await git.isDirty(worktree)) {
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
}
const main = await git.mainBranch(worktree)
if (!await git.hasNewCommits(worktree)) {
die(`Branch "${branch}" has no commits beyond ${main}.`)
}
const base = await git.mergeBase(main, "HEAD", worktree)
const originalHead = await git.headRef(worktree)
const spin = spinner("Squashing", branch)
let didReset = false
try {
await git.resetSoft(base, worktree)
didReset = true
spin.text = "Starting container"
await vm.ensure((msg) => { spin.text = msg })
spin.text = "Generating commit message"
const diff = await git.diffStaged(worktree)
if (!diff.trim()) {
await git.resetSoft(originalHead, worktree).catch(() => {})
spin.fail("No changes after squash")
process.exit(1)
}
const gen = await vm.claudePipe(
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.",
)
if (gen.exitCode !== 0 || !gen.stdout.trim()) {
spin.text = "AI commit message failed, using fallback"
}
const msg = (gen.exitCode === 0 && gen.stdout.trim()) || `squash ${branch}`
await git.commit(msg, worktree)
spin.succeed(`Squashed ${branch} into a single commit`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!didReset) {
spin.fail(`Squash failed: ${message}`)
} else {
const restored = await git.resetSoft(originalHead, worktree).then(() => true).catch(() => false)
spin.fail(restored
? `Squash failed, changes restored: ${message}`
: `Squash failed and rollback failed — check "git reflog" in the worktree: ${message}`)
}
process.exit(1)
}
} }

View File

@ -2,12 +2,6 @@ import { existsSync } from "fs"
import { rm } from "fs/promises" import { rm } from "fs/promises"
import { $ } from "bun" import { $ } from "bun"
/** Format a git error with a fallback for empty stderr. */
function gitError(action: string, stderr: Buffer | string): Error {
const msg = stderr.toString().trim()
return new Error(`${action}: ${msg || "(no output)"}`)
}
/** Get the repo root from a working directory. */ /** Get the repo root from a working directory. */
export async function repoRoot(cwd?: string): Promise<string> { export async function repoRoot(cwd?: string): Promise<string> {
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").nothrow().quiet() const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").nothrow().quiet()
@ -78,7 +72,7 @@ export async function createWorktree(branch: string, worktreePath: string, cwd:
} }
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
if (switchedFromBranch) await checkout(branch, cwd).catch(() => {}) if (switchedFromBranch) await checkout(branch, cwd).catch(() => {})
throw gitError(`Failed to create worktree for "${branch}"`, result.stderr) throw new Error(`Failed to create worktree for "${branch}": ${result.stderr.toString().trim()}`)
} }
return { branchCreated: exists !== "local" } return { branchCreated: exists !== "local" }
} }
@ -104,13 +98,16 @@ export async function deleteLocalBranch(branch: string, cwd: string): Promise<vo
export async function checkout(branch: string, cwd: string): Promise<void> { export async function checkout(branch: string, cwd: string): Promise<void> {
const result = await $`git checkout ${branch}`.cwd(cwd).nothrow().quiet() const result = await $`git checkout ${branch}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw gitError(`Failed to checkout branch "${branch}"`, result.stderr) throw new Error(`Failed to checkout branch "${branch}": ${result.stderr.toString().trim()}`)
} }
} }
/** Merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */ /** Merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */
export async function merge(branch: string, cwd: string): Promise<string[]> { export async function merge(branch: string, cwd: string, opts?: { squash?: boolean }): Promise<string[]> {
const result = await $`git merge ${branch}`.cwd(cwd).nothrow().quiet() const cmd = opts?.squash
? $`git merge --squash ${branch}`.cwd(cwd).nothrow().quiet()
: $`git merge ${branch}`.cwd(cwd).nothrow().quiet()
const result = await cmd
if (result.exitCode === 0) return [] if (result.exitCode === 0) return []
// Check for unmerged (conflicted) files // Check for unmerged (conflicted) files
@ -119,7 +116,8 @@ export async function merge(branch: string, cwd: string): Promise<string[]> {
if (files.length > 0) return files if (files.length > 0) return files
// Not a conflict — some other merge failure // Not a conflict — some other merge failure
throw gitError(`Failed to merge branch "${branch}"`, result.stderr) const label = opts?.squash ? "squash-merge" : "merge"
throw new Error(`Failed to ${label} branch "${branch}": ${result.stderr.toString().trim()}`)
} }
/** Return the staged diff as text. */ /** Return the staged diff as text. */
@ -131,31 +129,20 @@ export async function diffStaged(cwd: string): Promise<string> {
export async function commit(message: string, cwd: string): Promise<void> { export async function commit(message: string, cwd: string): Promise<void> {
const result = await $`git commit -m ${message}`.cwd(cwd).nothrow().quiet() const result = await $`git commit -m ${message}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw gitError("Failed to commit", result.stderr) throw new Error(`Failed to commit: ${result.stderr.toString().trim()}`)
}
}
/** Accept "theirs" version of a conflicted file. */
export async function checkoutTheirs(file: string, cwd: string): Promise<void> {
const result = await $`git checkout --theirs -- ${file}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw gitError(`Failed to checkout theirs for ${file}`, result.stderr)
} }
} }
/** Stage a file. */ /** Stage a file. */
export async function stageFile(file: string, cwd: string): Promise<void> { export async function stageFile(file: string, cwd: string): Promise<void> {
const result = await $`git add ${file}`.cwd(cwd).nothrow().quiet() await $`git add ${file}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw gitError(`Failed to stage ${file}`, result.stderr)
}
} }
/** Finalize a merge commit after resolving conflicts. */ /** Finalize a merge commit after resolving conflicts. */
export async function commitMerge(cwd: string): Promise<void> { export async function commitMerge(cwd: string): Promise<void> {
const result = await $`git commit --no-edit`.cwd(cwd).nothrow().quiet() const result = await $`git commit --no-edit`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw gitError("Failed to commit merge", result.stderr) throw new Error(`Failed to commit merge: ${result.stderr.toString().trim()}`)
} }
} }
@ -164,23 +151,6 @@ export async function abortMerge(cwd: string): Promise<void> {
await $`git merge --abort`.cwd(cwd).nothrow().quiet() await $`git merge --abort`.cwd(cwd).nothrow().quiet()
} }
/** Soft-reset to a given ref (keeps changes staged). */
export async function resetSoft(ref: string, cwd: string): Promise<void> {
const result = await $`git reset --soft ${ref}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw new Error(`Failed to reset to "${ref}": ${result.stderr.toString().trim()}`)
}
}
/** Get the full SHA of HEAD. */
export async function headRef(cwd: string): Promise<string> {
const result = await $`git rev-parse HEAD`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw new Error("Could not resolve HEAD.")
}
return result.text().trim()
}
/** Rebase the current branch onto another. Returns conflicted file paths, or empty array if clean. */ /** Rebase the current branch onto another. Returns conflicted file paths, or empty array if clean. */
export async function rebase(onto: string, cwd: string): Promise<string[]> { export async function rebase(onto: string, cwd: string): Promise<string[]> {
// Bail early if a rebase is already in progress (check for rebase state directories, not REBASE_HEAD which can be stale) // Bail early if a rebase is already in progress (check for rebase state directories, not REBASE_HEAD which can be stale)
@ -211,7 +181,7 @@ export async function rebaseContinue(cwd: string): Promise<string[]> {
const files = unmerged.trim().split("\n").filter(Boolean) const files = unmerged.trim().split("\n").filter(Boolean)
if (files.length > 0) return files if (files.length > 0) return files
throw gitError("Rebase --continue failed", result.stderr) throw new Error(`Rebase --continue failed: ${result.stderr.toString().trim()}`)
} }
/** Abort an in-progress rebase. */ /** Abort an in-progress rebase. */