Merge branch 'sandlot-rebase'

This commit is contained in:
Chris Wanstrath 2026-02-21 07:54:44 -08:00
commit 12ba2c7c82
5 changed files with 130 additions and 18 deletions

View File

@ -10,6 +10,7 @@ import { action as reviewAction } from "./commands/review.ts"
import { action as shellAction } from "./commands/shell.ts"
import { action as closeAction } from "./commands/close.ts"
import { action as mergeAction } from "./commands/merge.ts"
import { action as rebaseAction } from "./commands/rebase.ts"
import { action as saveAction } from "./commands/save.ts"
import { action as diffAction } from "./commands/diff.ts"
import { action as showAction } from "./commands/show.ts"
@ -96,6 +97,14 @@ program
.description("Merge a branch into main and close the session")
.action(mergeAction)
// ── sandlot rebase ───────────────────────────────────────────────────
program
.command("rebase")
.argument("<branch>", "branch name")
.description("Rebase a branch onto the latest main")
.action(rebaseAction)
// ── sandlot save ─────────────────────────────────────────────────────
program

View File

@ -1,3 +1,4 @@
import { join } from "path"
import { $ } from "bun"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
@ -16,6 +17,30 @@ export async function requireSession(branch: string): Promise<{ root: string; se
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)
}
}
/** 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)

View File

@ -1,10 +1,10 @@
import { join } from "path"
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"
export async function action(branch: string) {
const root = await git.repoRoot()
@ -28,23 +28,7 @@ export async function action(branch: string) {
try {
await vm.ensure((msg) => { spin.text = msg })
for (const file of conflicts) {
spin.text = `Resolving ${file}`
const content = await Bun.file(join(root, 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(root, file), resolved.stdout + "\n")
await git.stageFile(file, root)
}
await resolveConflicts(conflicts, root, (file) => { spin.text = `Resolving ${file}` })
await git.commitMerge(root)
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)

57
src/commands/rebase.ts Normal file
View File

@ -0,0 +1,57 @@
import { $ } from "bun"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import { spinner } from "../spinner.ts"
import { die } from "../fmt.ts"
import { requireSession, resolveConflicts } from "./helpers.ts"
const MAX_REBASE_ROUNDS = 10
export async function action(branch: string) {
const { root, session } = await requireSession(branch)
const worktree = session.worktree
if (await git.isDirty(worktree)) {
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
}
const main = await git.mainBranch(root)
const fetchSpin = spinner("Fetching origin", branch)
await $`git -C ${root} fetch origin ${main}`.nothrow().quiet()
fetchSpin.text = `Rebasing onto origin/${main}`
let conflicts = await git.rebase(`origin/${main}`, worktree)
if (conflicts.length === 0) {
fetchSpin.succeed(`Rebased ${branch} onto ${main}`)
return
}
fetchSpin.stop()
console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
const resolveSpin = spinner("Starting container", branch)
try {
await vm.ensure((msg) => { resolveSpin.text = msg })
let round = 1
while (conflicts.length > 0) {
if (round > MAX_REBASE_ROUNDS) {
throw new Error(`Exceeded ${MAX_REBASE_ROUNDS} conflict resolution rounds — aborting rebase`)
}
await resolveConflicts(conflicts, worktree, (file) => {
resolveSpin.text = `Resolving ${file} (round ${round})`
})
conflicts = await git.rebaseContinue(worktree)
round++
}
resolveSpin.succeed(`Rebased ${branch} onto ${main} (resolved ${round - 1} conflict round(s))`)
} catch (err) {
resolveSpin.fail(String((err as Error).message ?? err))
await git.rebaseAbort(worktree)
process.exit(1)
}
}

View File

@ -119,6 +119,43 @@ export async function abortMerge(cwd: string): Promise<void> {
await $`git merge --abort`.cwd(cwd).nothrow().quiet()
}
/** Rebase the current branch onto another. Returns conflicted file paths, or empty array if clean. */
export async function rebase(onto: string, cwd: string): Promise<string[]> {
// Bail early if a rebase is already in progress
const inProgress = await $`git -C ${cwd} rev-parse --verify --quiet REBASE_HEAD`.nothrow().quiet()
if (inProgress.exitCode === 0) {
throw new Error(`A rebase is already in progress. Run "git -C ${cwd} rebase --abort" to cancel it first.`)
}
const result = await $`git rebase ${onto}`.cwd(cwd).nothrow().quiet()
if (result.exitCode === 0) return []
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
// No conflicts but rebase still failed — include stderr for diagnostics
const stderr = result.stderr.toString().trim()
throw new Error(`Rebase onto "${onto}" failed: ${stderr || "(no output from git)"}`)
}
/** Continue a rebase after resolving conflicts. Returns conflicted files for the next commit, or empty if done. */
export async function rebaseContinue(cwd: string): Promise<string[]> {
const result = await $`git -c core.editor=true rebase --continue`.cwd(cwd).nothrow().quiet()
if (result.exitCode === 0) return []
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
throw new Error(`Rebase --continue failed: ${result.stderr.toString().trim()}`)
}
/** Abort an in-progress rebase. */
export async function rebaseAbort(cwd: string): Promise<void> {
await $`git rebase --abort`.cwd(cwd).nothrow().quiet()
}
/** Check if a worktree has uncommitted changes. */
export async function isDirty(worktreePath: string): Promise<boolean> {
const result = await $`git -C ${worktreePath} status --porcelain`.nothrow().quiet()