Add rebase command with AI-assisted conflict resolution
This commit is contained in:
parent
71830c6bd5
commit
0c5e44bb5d
|
|
@ -10,6 +10,7 @@ import { action as reviewAction } from "./commands/review.ts"
|
||||||
import { action as shellAction } from "./commands/shell.ts"
|
import { action as shellAction } from "./commands/shell.ts"
|
||||||
import { action as closeAction } from "./commands/close.ts"
|
import { action as closeAction } from "./commands/close.ts"
|
||||||
import { action as mergeAction } from "./commands/merge.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 saveAction } from "./commands/save.ts"
|
||||||
import { action as diffAction } from "./commands/diff.ts"
|
import { action as diffAction } from "./commands/diff.ts"
|
||||||
import { action as showAction } from "./commands/show.ts"
|
import { action as showAction } from "./commands/show.ts"
|
||||||
|
|
@ -95,6 +96,14 @@ program
|
||||||
.description("Merge a branch into main and close the session")
|
.description("Merge a branch into main and close the session")
|
||||||
.action(mergeAction)
|
.action(mergeAction)
|
||||||
|
|
||||||
|
// ── sandlot rebase ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("rebase")
|
||||||
|
.argument("<branch>", "branch name")
|
||||||
|
.description("Rebase a branch onto the latest main")
|
||||||
|
.action(rebaseAction)
|
||||||
|
|
||||||
// ── sandlot save ─────────────────────────────────────────────────────
|
// ── sandlot save ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
63
src/commands/rebase.ts
Normal file
63
src/commands/rebase.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { join } from "path"
|
||||||
|
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 } from "./helpers.ts"
|
||||||
|
|
||||||
|
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 spin = spinner("Fetching origin", branch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $`git -C ${root} fetch origin ${main}`.nothrow().quiet()
|
||||||
|
spin.text = `Rebasing onto origin/${main}`
|
||||||
|
|
||||||
|
let conflicts = await git.rebase(`origin/${main}`, worktree)
|
||||||
|
if (conflicts.length === 0) {
|
||||||
|
spin.succeed(`Rebased ${branch} onto ${main}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve conflicts with Claude, looping for each rebased commit
|
||||||
|
console.log(`◆ Rebase conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
|
||||||
|
await vm.ensure((msg) => { spin.text = msg })
|
||||||
|
|
||||||
|
let round = 1
|
||||||
|
while (conflicts.length > 0) {
|
||||||
|
for (const file of conflicts) {
|
||||||
|
spin.text = `Resolving ${file} (round ${round})`
|
||||||
|
const content = await Bun.file(join(worktree, 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(worktree, file), resolved.stdout + "\n")
|
||||||
|
await git.stageFile(file, worktree)
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts = await git.rebaseContinue(worktree)
|
||||||
|
round++
|
||||||
|
}
|
||||||
|
|
||||||
|
spin.succeed(`Rebased ${branch} onto ${main} (resolved ${round - 1} conflict round(s))`)
|
||||||
|
} catch (err) {
|
||||||
|
spin.fail(String((err as Error).message ?? err))
|
||||||
|
await git.rebaseAbort(worktree)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/git.ts
29
src/git.ts
|
|
@ -119,6 +119,35 @@ export async function abortMerge(cwd: string): Promise<void> {
|
||||||
await $`git merge --abort`.cwd(cwd).nothrow().quiet()
|
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[]> {
|
||||||
|
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
|
||||||
|
|
||||||
|
throw new Error(`Failed to rebase onto "${onto}": ${result.stderr.toString().trim()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Check if a worktree has uncommitted changes. */
|
||||||
export async function isDirty(worktreePath: string): Promise<boolean> {
|
export async function isDirty(worktreePath: string): Promise<boolean> {
|
||||||
const result = await $`git -C ${worktreePath} status --porcelain`.nothrow().quiet()
|
const result = await $`git -C ${worktreePath} status --porcelain`.nothrow().quiet()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user