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:
parent
dfb62f7646
commit
a9043d154d
|
|
@ -141,9 +141,8 @@ program
|
||||||
program
|
program
|
||||||
.command("squash")
|
.command("squash")
|
||||||
.argument("<branch>", "branch name")
|
.argument("<branch>", "branch name")
|
||||||
.option("-f, --force", "allow merging into a non-main branch")
|
.description("Squash all commits on a branch into a single commit")
|
||||||
.description("Squash-merge a branch into main and close the session")
|
.action((branch: string) => squashAction(branch))
|
||||||
.action((branch: string, opts: { force?: boolean }) => squashAction(branch, opts))
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("rebase")
|
.command("rebase")
|
||||||
|
|
|
||||||
|
|
@ -106,8 +106,8 @@ export async function resolveConflicts(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge (or squash-merge) a branch into main, resolve conflicts if needed, and close the session. */
|
/** 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> {
|
export async function mergeAndClose(branch: string, opts?: { 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)
|
||||||
|
|
@ -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.`)
|
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, opts?.squash ? { squash: true } : undefined)
|
const conflicts = await git.merge(branch, root)
|
||||||
|
|
||||||
if (conflicts.length === 0) {
|
if (conflicts.length === 0) {
|
||||||
if (opts?.squash) {
|
spin.succeed(`Merged ${branch} into ${current}`)
|
||||||
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
|
||||||
|
|
@ -147,12 +140,8 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean; f
|
||||||
spin.text = total > 1 ? `(${i}/${total}) Resolving ${file}` : `Resolving ${file}`
|
spin.text = total > 1 ? `(${i}/${total}) Resolving ${file}` : `Resolving ${file}`
|
||||||
})
|
})
|
||||||
|
|
||||||
if (opts?.squash) {
|
await git.commitMerge(root)
|
||||||
await squashCommit(branch, root)
|
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)
|
||||||
} 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)
|
||||||
|
|
@ -166,25 +155,6 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean; f
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
export async function action(branch: string) {
|
||||||
await mergeAndClose(branch, { squash: true, force: opts?.force })
|
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`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,14 @@ 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()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user