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 { 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 { 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 { 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 { 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 }