Change the commit message generation prompts from requesting
50/72-rule multi-line messages to single-line subjects only.
This avoids unnecessary body text in automated commits and
ensures the first line is always used via `.split("\n")[0]`.
158 lines
5.1 KiB
TypeScript
158 lines
5.1 KiB
TypeScript
import { join } from "path"
|
|
import { $ } from "bun"
|
|
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 type { Session } from "../state.ts"
|
|
|
|
/** Look up a session by branch, dying if it doesn't exist. */
|
|
export async function requireSession(branch: string): Promise<{ root: string; session: Session }> {
|
|
const root = await git.repoRoot()
|
|
const session = await state.getSession(root, branch)
|
|
if (!session) {
|
|
die(`No session found for branch "${branch}".`)
|
|
}
|
|
return { root, session }
|
|
}
|
|
|
|
/** Resolve conflict markers in files using Claude, then stage them. */
|
|
export async function resolveConflicts(
|
|
files: string[],
|
|
cwd: string,
|
|
onFile: (file: string) => void,
|
|
): Promise<void> {
|
|
for (const file of files) {
|
|
onFile(file)
|
|
const content = await Bun.file(join(cwd, file)).text()
|
|
|
|
const resolved = await vm.claudePipe(
|
|
content,
|
|
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
|
|
)
|
|
|
|
if (resolved.exitCode !== 0) {
|
|
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`)
|
|
}
|
|
|
|
await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n")
|
|
await git.stageFile(file, cwd)
|
|
}
|
|
}
|
|
|
|
/** 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 spin = spinner("Merging", branch)
|
|
const conflicts = await git.merge(branch, root, opts?.squash ? { squash: true } : undefined)
|
|
|
|
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 branch`)
|
|
await closeAction(branch)
|
|
return
|
|
}
|
|
|
|
// Resolve conflicts with Claude
|
|
spin.text = `Resolving ${conflicts.length} conflict(s)`
|
|
|
|
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 single-line commit message for these changes, max 50 characters. no body, no blank line, just the subject. output only the message, no quotes or extra text.",
|
|
)
|
|
if (gen.exitCode === 0 && gen.stdout.trim()) {
|
|
await git.commit(gen.stdout.trim().split("\n")[0], 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)
|
|
|
|
await $`git -C ${worktree} add .`.nothrow().quiet()
|
|
|
|
const check = await $`git -C ${worktree} diff --staged --quiet`.nothrow().quiet()
|
|
if (check.exitCode === 0) {
|
|
spin.fail("No changes to commit")
|
|
return false
|
|
}
|
|
|
|
let msg: string
|
|
if (message) {
|
|
msg = message
|
|
} else {
|
|
spin.text = "Starting container"
|
|
await vm.ensure((m) => { spin.text = m })
|
|
|
|
spin.text = "Generating commit message"
|
|
const diff = await $`git -C ${worktree} diff --staged`.nothrow().quiet().text()
|
|
|
|
const gen = await vm.claudePipe(
|
|
diff,
|
|
"write a single-line commit message for these changes, max 50 characters. no body, no blank line, just the subject. output only the message, no quotes or extra text.",
|
|
)
|
|
|
|
if (gen.exitCode !== 0) {
|
|
spin.fail("Failed to generate commit message")
|
|
if (gen.stderr) console.error(gen.stderr)
|
|
return false
|
|
}
|
|
msg = gen.stdout
|
|
}
|
|
|
|
spin.text = "Committing"
|
|
const commit = await $`git -C ${worktree} commit -m ${msg}`.nothrow().quiet()
|
|
if (commit.exitCode !== 0) {
|
|
spin.fail("Commit failed")
|
|
if (commit.stderr) console.error(commit.stderr.toString().trim())
|
|
return false
|
|
}
|
|
|
|
spin.succeed(`Saved: ${msg.split("\n")[0]}`)
|
|
return true
|
|
}
|