From a9043d154de476b5686a70d793096c2f4896f4ff Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 10 Apr 2026 08:07:51 -0700 Subject: [PATCH] Rework squash command to collapse commits in-place Instead of squash-merging into main, `sandlot squash` now soft-resets to the merge base and recommits, keeping the branch independent. This removes the --force flag and all squash-merge logic from mergeAndClose, which goes back to being a plain merge. Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 5 ++--- src/commands/helpers.ts | 42 ++++++------------------------------ src/commands/squash.ts | 48 ++++++++++++++++++++++++++++++++++++++--- src/git.ts | 8 +++++++ 4 files changed, 61 insertions(+), 42 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 723323e..bd9465e 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -141,9 +141,8 @@ program program .command("squash") .argument("", "branch name") - .option("-f, --force", "allow merging into a non-main branch") - .description("Squash-merge a branch into main and close the session") - .action((branch: string, opts: { force?: boolean }) => squashAction(branch, opts)) + .description("Squash all commits on a branch into a single commit") + .action((branch: string) => squashAction(branch)) program .command("rebase") diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 7a22a02..67da571 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -106,8 +106,8 @@ export async function resolveConflicts( } } -/** Merge (or squash-merge) a branch into main, resolve conflicts if needed, and close the session. */ -export async function mergeAndClose(branch: string, opts?: { squash?: boolean; force?: boolean }): Promise { +/** Merge a branch into main, resolve conflicts if needed, and close the session. */ +export async function mergeAndClose(branch: string, opts?: { force?: boolean }): Promise { const root = await git.repoRoot() const main = await git.mainBranch(root) const current = await git.currentBranch(root) @@ -120,18 +120,11 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean; f die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`) } - const label = opts?.squash ? "Squash-merged" : "Merged" const spin = spinner("Merging", branch) - const conflicts = await git.merge(branch, root, opts?.squash ? { squash: true } : undefined) + const conflicts = await git.merge(branch, root) if (conflicts.length === 0) { - 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}`) + spin.succeed(`Merged ${branch} into ${current}`) if (session) await teardownSession(root, branch, session.worktree) await git.deleteLocalBranch(branch, root).catch(() => {}) return @@ -147,12 +140,8 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean; f spin.text = total > 1 ? `(${i}/${total}) Resolving ${file}` : `Resolving ${file}` }) - if (opts?.squash) { - await squashCommit(branch, root) - } else { - await git.commitMerge(root) - } - spin.succeed(`Resolved ${conflicts.length} conflict(s) and ${label.toLowerCase()} ${branch}`) + await git.commitMerge(root) + spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) } catch (err) { const message = err instanceof Error ? err.message : String(err) spin.fail(message) @@ -166,25 +155,6 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean; f 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 { - 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. */ export async function saveChanges(worktree: string, branch: string, message?: string): Promise { const spin = spinner("Staging changes", branch) diff --git a/src/commands/squash.ts b/src/commands/squash.ts index 32efca3..10baad8 100644 --- a/src/commands/squash.ts +++ b/src/commands/squash.ts @@ -1,5 +1,47 @@ -import { mergeAndClose } from "./helpers.ts" +import { $ } from "bun" +import * as git from "../git.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, opts?: { force?: boolean }) { - await mergeAndClose(branch, { squash: true, force: opts?.force }) +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 (head === base) { + die(`Branch "${branch}" has no commits beyond ${main}.`) + } + + const spin = spinner("Squashing", branch) + + await git.resetSoft(base, worktree) + + 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()) { + 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`) } diff --git a/src/git.ts b/src/git.ts index 06c7928..e954c29 100644 --- a/src/git.ts +++ b/src/git.ts @@ -151,6 +151,14 @@ export async function abortMerge(cwd: string): Promise { 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 { + 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()}`) + } +} + /** 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)