diff --git a/CLAUDE.md b/CLAUDE.md index 97b979b..c86126a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ src/ list.ts # Show all active sessions with status save.ts # Stage all changes and commit merge.ts # Merge branch into main with conflict resolution - squash.ts # Squash-merge branch into main (AI commit message) and close + squash.ts # Squash all commits on a branch into a single commit (in-place) rebase.ts # Rebase branch onto main with conflict resolution review.ts # Launch grumpy code review with Claude 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 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 merge`, `sandlot squash`, and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically -- `sandlot squash` generates an AI commit message for the squash commit via `claudePipe()`; falls back to `"squash "` -- `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 merge` and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically +- `sandlot squash` collapses all branch commits into a single commit in-place via `git reset --soft` to the merge base, then generates an AI commit message via `claudePipe()`; falls back to `"squash "`. 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 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 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 diff --git a/src/commands/squash.ts b/src/commands/squash.ts index 10baad8..6ab91c5 100644 --- a/src/commands/squash.ts +++ b/src/commands/squash.ts @@ -1,4 +1,3 @@ -import { $ } from "bun" import * as git from "../git.ts" import * as vm from "../vm.ts" import { spinner } from "../spinner.ts" @@ -9,39 +8,52 @@ export async function action(branch: string) { const { session } = await requireSession(branch) const worktree = session.worktree - const main = await git.mainBranch(worktree) - const base = await git.mergeBase(main, "HEAD", worktree) - const head = (await $`git rev-parse HEAD`.cwd(worktree).nothrow().quiet().text()).trim() + if (await git.isDirty(worktree)) { + die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`) + } - if (head === base) { + if (!await git.hasNewCommits(worktree)) { + const main = await git.mainBranch(worktree) die(`Branch "${branch}" has no commits beyond ${main}.`) } + const main = await git.mainBranch(worktree) + const base = await git.mergeBase(main, "HEAD", worktree) + const originalHead = await git.headRef(worktree) + const spin = spinner("Squashing", branch) await git.resetSoft(base, worktree) - spin.text = "Starting container" - await vm.ensure((msg) => { spin.text = msg }) + try { + spin.text = "Starting container" + await vm.ensure((msg) => { spin.text = msg }) - spin.text = "Generating commit message" - const diff = await git.diffStaged(worktree) + spin.text = "Generating commit message" + const diff = await git.diffStaged(worktree) - if (!diff.trim()) { - spin.fail("No changes after squash") - return + if (!diff.trim()) { + await git.resetSoft(originalHead, worktree) + spin.fail("No changes after squash") + return + } + + 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()) { + await git.commit(gen.stdout.trim(), worktree) + } else { + await git.commit(`squash ${branch}`, worktree) + } + + spin.succeed(`Squashed ${branch} into a single commit`) + } catch (err) { + await git.resetSoft(originalHead, worktree).catch(() => {}) + const message = err instanceof Error ? err.message : String(err) + spin.fail(`Squash failed, changes restored: ${message}`) + 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()) { - await git.commit(gen.stdout.trim(), worktree) - } else { - await git.commit(`squash ${branch}`, worktree) - } - - spin.succeed(`Squashed ${branch} into a single commit`) } diff --git a/src/git.ts b/src/git.ts index e954c29..9e05e42 100644 --- a/src/git.ts +++ b/src/git.ts @@ -103,11 +103,8 @@ export async function checkout(branch: string, cwd: string): Promise { } /** Merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */ -export async function merge(branch: string, cwd: string, opts?: { squash?: boolean }): Promise { - const cmd = opts?.squash - ? $`git merge --squash ${branch}`.cwd(cwd).nothrow().quiet() - : $`git merge ${branch}`.cwd(cwd).nothrow().quiet() - const result = await cmd +export async function merge(branch: string, cwd: string): Promise { + const result = await $`git merge ${branch}`.cwd(cwd).nothrow().quiet() if (result.exitCode === 0) return [] // Check for unmerged (conflicted) files @@ -116,8 +113,7 @@ export async function merge(branch: string, cwd: string, opts?: { squash?: boole if (files.length > 0) return files // Not a conflict — some other merge failure - const label = opts?.squash ? "squash-merge" : "merge" - throw new Error(`Failed to ${label} branch "${branch}": ${result.stderr.toString().trim()}`) + throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`) } /** Return the staged diff as text. */ @@ -159,6 +155,15 @@ export async function resetSoft(ref: string, cwd: string): Promise { } } +/** Get the full SHA of HEAD. */ +export async function headRef(cwd: string): Promise { + 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. */ export async function rebase(onto: string, cwd: string): Promise { // Bail early if a rebase is already in progress (check for rebase state directories, not REBASE_HEAD which can be stale)