237 lines
8.1 KiB
TypeScript
237 lines
8.1 KiB
TypeScript
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/<branch> symlink and prune empty parent dirs up to .sandlot/. */
|
|
export async function unlinkSessionSymlink(root: string, branch: string): Promise<void> {
|
|
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<void> {
|
|
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)
|
|
}
|
|
|
|
/** Generated files to skip AI resolution — accept theirs and move on (basename match so `packages/foo/bun.lock` is also covered). */
|
|
const SKIP_RESOLVE = new Set([
|
|
"bun.lock",
|
|
"bun.lockb",
|
|
"Cargo.lock",
|
|
"composer.lock",
|
|
"Gemfile.lock",
|
|
"go.sum",
|
|
"mix.lock",
|
|
"package-lock.json",
|
|
"Pipfile.lock",
|
|
"pnpm-lock.yaml",
|
|
"Podfile.lock",
|
|
"poetry.lock",
|
|
"pubspec.lock",
|
|
"flake.lock",
|
|
"gradle.lockfile",
|
|
"npm-shrinkwrap.json",
|
|
"Package.resolved",
|
|
"uv.lock",
|
|
"yarn.lock",
|
|
])
|
|
|
|
/** 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<void> {
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i]
|
|
onFile(file, i + 1, files.length)
|
|
|
|
if (SKIP_RESOLVE.has(basename(file))) {
|
|
await git.checkoutTheirs(file, cwd)
|
|
await git.stageFile(file, cwd)
|
|
continue
|
|
}
|
|
|
|
const content = await Bun.file(join(cwd, file)).text().catch(() => {
|
|
throw new Error(`Failed to read conflicted file: ${file}`)
|
|
})
|
|
|
|
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 || !resolved.stdout.trim()) {
|
|
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`)
|
|
}
|
|
|
|
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<void> {
|
|
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)
|
|
if (session) await vm.clearActivity(session.worktree, branch) // process.exit below skips finally
|
|
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<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 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
|
|
}
|