import { basename, dirname, join } from "path" import { homedir } from "os" import { mkdir, rmdir, symlink, unlink } from "fs/promises" 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 type { Session } from "../state.ts" /** Remove a .sandlot/ symlink and prune empty parent dirs up to .sandlot/. */ export async function unlinkSessionSymlink(root: string, branch: string): Promise { const sandlotDir = join(root, '.sandlot') const symlinkPath = join(sandlotDir, branch) await unlink(symlinkPath).catch(() => {}) // Walk up from the symlink's parent, removing empty dirs, stopping at .sandlot/ itself let dir = dirname(symlinkPath) while (dir !== sandlotDir) { const ok = await rmdir(dir).then(() => true, () => false) if (!ok) break dir = dirname(dir) } } /** 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 } } /** Look up a session by branch, recreating the worktree/session if the branch exists but the session doesn't. */ export async function ensureSession(branch: string): Promise<{ root: string; session: Session }> { const root = await git.repoRoot() const existing = await state.getSession(root, branch) if (existing) return { root, session: existing } // No session — check if the branch exists const exists = await git.branchExists(branch, root) if (!exists) { die(`No session or branch found for "${branch}".`) } // Recreate worktree and session const worktreeAbs = join(homedir(), '.sandlot', basename(root), branch) try { await git.createWorktree(branch, worktreeAbs, root) const symlinkPath = join(root, '.sandlot', branch) await mkdir(dirname(symlinkPath), { recursive: true }) await symlink(worktreeAbs, symlinkPath) } catch (err) { // Clean up on failure — but do NOT delete the branch (it already existed) await git.removeWorktree(worktreeAbs, root).catch(() => {}) await unlinkSessionSymlink(root, branch) die(`Failed to recreate session: ${(err as Error).message ?? err}`) } const session: Session = { branch, worktree: worktreeAbs, created_at: new Date().toISOString(), } await state.setSession(root, session) return { root, session } } /** Tear down a session: clear activity, remove worktree, unlink symlink, remove state. */ export async function teardownSession(root: string, branch: string, worktree: string): Promise { await vm.clearActivity(worktree, branch) await git.removeWorktree(worktree, root) .catch((e) => console.warn(`⚠ Failed to remove worktree: ${e.message}`)) await unlinkSessionSymlink(root, branch) await state.removeSession(root, branch) } /** Resolve conflict markers in files using Claude, then stage them. */ export async function resolveConflicts( files: string[], cwd: string, onFile: (file: string, index: number, total: number) => void, ): Promise { for (let i = 0; i < files.length; i++) { const file = files[i] onFile(file, i + 1, files.length) 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 a branch into main, resolve conflicts if needed, and close the session. */ export async function mergeAndClose(branch: string, opts?: { force?: boolean }): Promise { const root = await git.repoRoot() const main = await git.mainBranch(root) const current = await git.currentBranch(root) if (current !== main && !opts?.force) { die(`You must be on "${main}" to merge. Currently on "${current}". Use --force to merge into "${current}" anyway.`) } 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 spin = spinner("Merging", branch) const conflicts = await git.merge(branch, root) if (conflicts.length === 0) { spin.succeed(`Merged ${branch} into ${current}`) if (session) await teardownSession(root, branch, session.worktree) await git.deleteLocalBranch(branch, root).catch(() => {}) return } // Resolve conflicts with Claude spin.text = `Resolving ${conflicts.length} conflict(s)` try { await vm.ensure((msg) => { spin.text = msg }) if (session) await vm.setActivity(session.worktree, branch) await resolveConflicts(conflicts, root, (file, i, total) => { spin.text = total > 1 ? `(${i}/${total}) Resolving ${file}` : `Resolving ${file}` }) await git.commitMerge(root) spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`) } catch (err) { const message = err instanceof Error ? err.message : String(err) spin.fail(message) await git.abortMerge(root) process.exit(1) } finally { if (session) await vm.clearActivity(session.worktree, branch) } if (session) await teardownSession(root, branch, session.worktree) await git.deleteLocalBranch(branch, root).catch(() => {}) } /** 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 commit message for this diff. Subject line: imperative mood, max 72 characters, no period. If the changes warrant it, add a blank line then a brief body explaining why, not what. Output only the raw commit message, no quotes or markdown.", ) 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 }