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 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-04-10 08:07:51 -07:00
parent dfb62f7646
commit a9043d154d
4 changed files with 61 additions and 42 deletions

View File

@ -141,9 +141,8 @@ program
program
.command("squash")
.argument("<branch>", "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")

View File

@ -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<void> {
/** Merge a branch into main, resolve conflicts if needed, and close the session. */
export async function mergeAndClose(branch: string, opts?: { force?: boolean }): Promise<void> {
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<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. */
export async function saveChanges(worktree: string, branch: string, message?: string): Promise<boolean> {
const spin = spinner("Staging changes", branch)

View File

@ -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`)
}

View File

@ -151,6 +151,14 @@ export async function abortMerge(cwd: string): Promise<void> {
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()}`)
}
}
/** 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[]> {
// Bail early if a rebase is already in progress (check for rebase state directories, not REBASE_HEAD which can be stale)