Refactor merge/squash into shared mergeAndClose helper with AI-generated squash commit messages
This commit is contained in:
parent
668f66d3c9
commit
31df93016b
|
|
@ -5,6 +5,7 @@ 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 type { Session } from "../state.ts"
|
||||
|
||||
/** Look up a session by branch, dying if it doesn't exist. */
|
||||
|
|
@ -41,6 +42,70 @@ 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 }): Promise<void> {
|
||||
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 label = opts?.squash ? "Squash-merged" : "Merged"
|
||||
const conflicts = await git.merge(branch, root, opts?.squash ? { squash: true } : undefined)
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
if (opts?.squash) {
|
||||
await squashCommit(branch, root)
|
||||
}
|
||||
console.log(`✔ ${label} ${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}` })
|
||||
|
||||
if (opts?.squash) {
|
||||
await squashCommit(branch, root)
|
||||
} else {
|
||||
await git.commitMerge(root)
|
||||
}
|
||||
spin.succeed(`Resolved ${conflicts.length} conflict(s) and ${label.toLowerCase()} ${branch}`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
spin.fail(message)
|
||||
await git.abortMerge(root)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await closeAction(branch)
|
||||
}
|
||||
|
||||
/** 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 short commit message summarizing these changes. output only the message, no quotes or extra text",
|
||||
)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,42 +1,5 @@
|
|||
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"
|
||||
import { mergeAndClose } 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.merge(branch, root)
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
console.log(`✔ 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.commitMerge(root)
|
||||
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)
|
||||
} catch (err) {
|
||||
spin.fail(String((err as Error).message ?? err))
|
||||
await git.abortMerge(root)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await closeAction(branch)
|
||||
await mergeAndClose(branch)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,5 @@
|
|||
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"
|
||||
import { mergeAndClose } 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)
|
||||
await mergeAndClose(branch, { squash: true })
|
||||
}
|
||||
|
|
|
|||
25
src/git.ts
25
src/git.ts
|
|
@ -88,8 +88,11 @@ export async function checkout(branch: string, cwd: string): Promise<void> {
|
|||
}
|
||||
|
||||
/** Merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */
|
||||
export async function merge(branch: string, cwd: string): Promise<string[]> {
|
||||
const result = await $`git merge ${branch}`.cwd(cwd).nothrow().quiet()
|
||||
export async function merge(branch: string, cwd: string, opts?: { squash?: boolean }): Promise<string[]> {
|
||||
const cmd = opts?.squash
|
||||
? $`git merge --squash ${branch}`.cwd(cwd).nothrow().quiet()
|
||||
: $`git merge ${branch}`.cwd(cwd).nothrow().quiet()
|
||||
const result = await cmd
|
||||
if (result.exitCode === 0) return []
|
||||
|
||||
// Check for unmerged (conflicted) files
|
||||
|
|
@ -98,21 +101,13 @@ export async function merge(branch: string, cwd: string): Promise<string[]> {
|
|||
if (files.length > 0) return files
|
||||
|
||||
// Not a conflict — some other merge failure
|
||||
throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`)
|
||||
const label = opts?.squash ? "squash-merge" : "merge"
|
||||
throw new Error(`Failed to ${label} 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()}`)
|
||||
/** Return the staged diff as text. */
|
||||
export async function diffStaged(cwd: string): Promise<string> {
|
||||
return await $`git diff --staged`.cwd(cwd).nothrow().quiet().text()
|
||||
}
|
||||
|
||||
/** Commit staged changes with a message. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user