Add squash-merge command with conflict resolution

This commit is contained in:
Chris Wanstrath 2026-02-21 08:39:37 -08:00
parent 1e71b3b4a4
commit 668f66d3c9
3 changed files with 74 additions and 0 deletions

View File

@ -10,6 +10,7 @@ import { action as reviewAction } from "./commands/review.ts"
import { action as shellAction } from "./commands/shell.ts"
import { action as closeAction } from "./commands/close.ts"
import { action as mergeAction } from "./commands/merge.ts"
import { action as squashAction } from "./commands/squash.ts"
import { action as rebaseAction } from "./commands/rebase.ts"
import { action as saveAction } from "./commands/save.ts"
import { action as diffAction } from "./commands/diff.ts"
@ -97,6 +98,14 @@ program
.description("Merge a branch into main and close the session")
.action(mergeAction)
// ── sandlot squash ───────────────────────────────────────────────────
program
.command("squash")
.argument("<branch>", "branch name")
.description("Squash-merge a branch into main and close the session")
.action(squashAction)
// ── sandlot rebase ───────────────────────────────────────────────────
program

43
src/commands/squash.ts Normal file
View File

@ -0,0 +1,43 @@
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts"
import { die } from "../fmt.ts"
import { action as closeAction } from "./close.ts"
import { resolveConflicts } from "./helpers.ts"
export async function action(branch: string) {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
if (session && await git.isDirty(session.worktree)) {
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
}
const conflicts = await git.squashMerge(branch, root)
if (conflicts.length === 0) {
await git.commit(`squash ${branch}`, root)
console.log(`✔ Squash-merged ${branch} into current branch`)
await closeAction(branch)
return
}
// Resolve conflicts with Claude
console.log(`◆ Merge conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
const spin = spinner("Starting container", branch)
try {
await vm.ensure((msg) => { spin.text = msg })
await resolveConflicts(conflicts, root, (file) => { spin.text = `Resolving ${file}` })
await git.commit(`squash ${branch}`, root)
spin.succeed(`Resolved ${conflicts.length} conflict(s) and squash-merged ${branch}`)
} catch (err) {
spin.fail(String((err as Error).message ?? err))
await git.abortMerge(root)
process.exit(1)
}
await closeAction(branch)
}

View File

@ -101,6 +101,28 @@ export async function merge(branch: string, cwd: string): Promise<string[]> {
throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`)
}
/** Squash-merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */
export async function squashMerge(branch: string, cwd: string): Promise<string[]> {
const result = await $`git merge --squash ${branch}`.cwd(cwd).nothrow().quiet()
if (result.exitCode === 0) return []
// Check for unmerged (conflicted) files
const unmerged = await $`git diff --name-only --diff-filter=U`.cwd(cwd).nothrow().quiet().text()
const files = unmerged.trim().split("\n").filter(Boolean)
if (files.length > 0) return files
// Not a conflict — some other merge failure
throw new Error(`Failed to squash-merge branch "${branch}": ${result.stderr.toString().trim()}`)
}
/** Commit staged changes with a message. */
export async function commit(message: string, cwd: string): Promise<void> {
const result = await $`git commit -m ${message}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw new Error(`Failed to commit: ${result.stderr.toString().trim()}`)
}
}
/** Stage a file. */
export async function stageFile(file: string, cwd: string): Promise<void> {
await $`git add ${file}`.cwd(cwd).nothrow().quiet()