Merge branch 'sandlot-squash'
# Conflicts: # src/commands/merge.ts
This commit is contained in:
commit
a5fb4c2555
|
|
@ -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 squashAction } from "./commands/squash.ts"
|
||||||
import { action as rebaseAction } from "./commands/rebase.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"
|
||||||
|
|
@ -97,6 +98,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 squash ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("squash")
|
||||||
|
.argument("<branch>", "branch name")
|
||||||
|
.description("Squash-merge a branch into main and close the session")
|
||||||
|
.action(squashAction)
|
||||||
|
|
||||||
// ── sandlot rebase ───────────────────────────────────────────────────
|
// ── sandlot rebase ───────────────────────────────────────────────────
|
||||||
|
|
||||||
program
|
program
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import * as vm from "../vm.ts"
|
||||||
import * as state from "../state.ts"
|
import * as state from "../state.ts"
|
||||||
import { spinner } from "../spinner.ts"
|
import { spinner } from "../spinner.ts"
|
||||||
import { die } from "../fmt.ts"
|
import { die } from "../fmt.ts"
|
||||||
|
import { action as closeAction } from "./close.ts"
|
||||||
import type { Session } from "../state.ts"
|
import type { Session } from "../state.ts"
|
||||||
|
|
||||||
/** Look up a session by branch, dying if it doesn't exist. */
|
/** Look up a session by branch, dying if it doesn't exist. */
|
||||||
|
|
@ -41,6 +42,70 @@ export async function resolveConflicts(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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<void> {
|
||||||
|
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 conflicts = await git.merge(branch, root, opts?.squash ? { squash: true } : undefined)
|
||||||
|
|
||||||
|
if (conflicts.length === 0) {
|
||||||
|
if (opts?.squash) {
|
||||||
|
await squashCommit(branch, root)
|
||||||
|
}
|
||||||
|
console.log(`✔ ${label} ${branch} into current branch`)
|
||||||
|
await closeAction(branch)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve conflicts with Claude
|
||||||
|
console.log(`◆ Merge conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
|
||||||
|
const spin = spinner("Starting container", branch)
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
const diff = await git.diffStaged(cwd)
|
||||||
|
|
||||||
|
if (diff.trim()) {
|
||||||
|
const gen = await vm.claudePipe(
|
||||||
|
diff,
|
||||||
|
"write a short commit message summarizing these changes. output only the message, no quotes or extra text",
|
||||||
|
)
|
||||||
|
if (gen.exitCode === 0 && gen.stdout.trim()) {
|
||||||
|
await git.commit(gen.stdout.trim(), 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. */
|
/** 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> {
|
export async function saveChanges(worktree: string, branch: string, message?: string): Promise<boolean> {
|
||||||
const spin = spinner("Staging changes", branch)
|
const spin = spinner("Staging changes", branch)
|
||||||
|
|
|
||||||
|
|
@ -1,171 +1,5 @@
|
||||||
import { join } from "path"
|
import { mergeAndClose } from "./helpers.ts"
|
||||||
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"
|
|
||||||
|
|
||||||
const MAX_DIFF_LINES = 300
|
|
||||||
const MAX_LOG_LINES = 50
|
|
||||||
const MAX_STAT_LINES = 100
|
|
||||||
const MAX_CONFLICT_REGIONS = 15
|
|
||||||
const MAX_CONFLICTED_LINES = 200
|
|
||||||
|
|
||||||
interface ConflictAnalysis {
|
|
||||||
regions: number
|
|
||||||
conflictedLines: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function analyzeConflicts(content: string): ConflictAnalysis {
|
|
||||||
let regions = 0
|
|
||||||
let conflictedLines = 0
|
|
||||||
let inConflict = false
|
|
||||||
|
|
||||||
for (const line of content.split("\n")) {
|
|
||||||
if (line.startsWith("<<<<<<<")) {
|
|
||||||
regions++
|
|
||||||
inConflict = true
|
|
||||||
} else if (line.startsWith(">>>>>>>")) {
|
|
||||||
inConflict = false
|
|
||||||
} else if (inConflict) {
|
|
||||||
conflictedLines++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { regions, conflictedLines }
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncate(text: string, maxLines = MAX_DIFF_LINES): string {
|
|
||||||
if (!text) return text
|
|
||||||
const lines = text.split("\n")
|
|
||||||
if (lines.length <= maxLines) return text
|
|
||||||
return lines.slice(0, maxLines).join("\n") + `\n... (truncated, ${lines.length - maxLines} more lines)`
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function action(branch: string) {
|
export async function action(branch: string) {
|
||||||
const root = await git.repoRoot()
|
await mergeAndClose(branch)
|
||||||
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 conflicts = await git.merge(branch, root)
|
|
||||||
|
|
||||||
if (conflicts.length === 0) {
|
|
||||||
console.log(`✔ Merged ${branch} into current branch`)
|
|
||||||
await closeAction(branch)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve conflicts with Claude
|
|
||||||
console.log(`◆ Merge conflicts in ${conflicts.length} file(s). Resolving with Claude...`)
|
|
||||||
const spin = spinner("Starting container", branch)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await vm.ensure((msg) => { spin.text = msg })
|
|
||||||
|
|
||||||
// Gather merge context
|
|
||||||
spin.text = "Gathering merge context"
|
|
||||||
const current = await git.currentBranch(root)
|
|
||||||
const base = await git.mergeBase("HEAD", branch, root)
|
|
||||||
const branchLog = await git.commitLog(`${base}..${branch}`, root)
|
|
||||||
const branchDiffStat = await git.diffStat(`${base}..${branch}`, root)
|
|
||||||
|
|
||||||
// Read conflicted files, fetch diffs, and partition by complexity
|
|
||||||
spin.text = "Checking conflict complexity"
|
|
||||||
const fileEntries = await Promise.all(
|
|
||||||
conflicts.map(async (file) => {
|
|
||||||
const [content, oursDiff, theirsDiff] = await Promise.all([
|
|
||||||
Bun.file(join(root, file)).text(),
|
|
||||||
git.fileDiff(base, "HEAD", file, root),
|
|
||||||
git.fileDiff(base, branch, file, root),
|
|
||||||
])
|
|
||||||
return { file, content, oursDiff, theirsDiff, ...analyzeConflicts(content) }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const resolvable: typeof fileEntries = []
|
|
||||||
const skipped: string[] = []
|
|
||||||
for (const entry of fileEntries) {
|
|
||||||
if (entry.regions > MAX_CONFLICT_REGIONS || entry.conflictedLines > MAX_CONFLICTED_LINES) {
|
|
||||||
skipped.push(entry.file)
|
|
||||||
} else {
|
|
||||||
resolvable.push(entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvable.length === 0) {
|
|
||||||
spin.fail("All conflicts are too complex for auto-resolution")
|
|
||||||
console.log("\nThe following files need manual resolution:")
|
|
||||||
for (const file of skipped) console.log(` - ${file}`)
|
|
||||||
console.log("\nYour repo is in a MERGING state. To finish:")
|
|
||||||
console.log(" Resolve the files, then run: git add <files> && git commit --no-edit")
|
|
||||||
console.log(" Or abort with: git merge --abort")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const preamble = [
|
|
||||||
`## Merge Context`,
|
|
||||||
`Merging branch \`${branch}\` into \`${current}\``,
|
|
||||||
``,
|
|
||||||
`### Commits on \`${branch}\`:`,
|
|
||||||
(branchLog && truncate(branchLog, MAX_LOG_LINES)) || "(no commits)",
|
|
||||||
``,
|
|
||||||
`### Overall changes on \`${branch}\`:`,
|
|
||||||
(branchDiffStat && truncate(branchDiffStat, MAX_STAT_LINES)) || "(no changes)",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
for (const { file, content, oursDiff, theirsDiff } of resolvable) {
|
|
||||||
spin.text = `Resolving ${file}`
|
|
||||||
|
|
||||||
const context = [
|
|
||||||
preamble,
|
|
||||||
``,
|
|
||||||
`---`,
|
|
||||||
`## Conflicted File: ${file}`,
|
|
||||||
``,
|
|
||||||
`### Changes made on current branch (\`${current}\`):`,
|
|
||||||
(oursDiff && truncate(oursDiff)) || "(no changes to this file)",
|
|
||||||
``,
|
|
||||||
`### Changes made on incoming branch (\`${branch}\`):`,
|
|
||||||
(theirsDiff && truncate(theirsDiff)) || "(no changes to this file)",
|
|
||||||
``,
|
|
||||||
`### File with conflict markers:`,
|
|
||||||
content,
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
const resolved = await vm.claudePipe(
|
|
||||||
context,
|
|
||||||
"Resolve the merge conflicts in the file shown at the end. Use the diffs and commit history to understand the intent of each side. Preserve all non-conflicting changes from both sides. Output ONLY the resolved file content — 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (skipped.length > 0) {
|
|
||||||
spin.succeed(`Resolved ${resolvable.length} of ${conflicts.length} conflict(s)`)
|
|
||||||
console.log("\nThe following files are too complex for auto-resolution:")
|
|
||||||
for (const file of skipped) console.log(` - ${file}`)
|
|
||||||
console.log("\nYour repo is in a MERGING state. To finish:")
|
|
||||||
console.log(" Resolve the remaining files, then run: git add <files> && git commit --no-edit")
|
|
||||||
console.log(" Or abort with: git merge --abort")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await git.commitMerge(root)
|
|
||||||
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)
|
|
||||||
} catch (err) {
|
|
||||||
spin.fail(String((err as Error).message ?? err))
|
|
||||||
await git.abortMerge(root)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
await closeAction(branch)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
src/commands/squash.ts
Normal file
5
src/commands/squash.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { mergeAndClose } from "./helpers.ts"
|
||||||
|
|
||||||
|
export async function action(branch: string) {
|
||||||
|
await mergeAndClose(branch, { squash: true })
|
||||||
|
}
|
||||||
23
src/git.ts
23
src/git.ts
|
|
@ -88,8 +88,11 @@ export async function checkout(branch: string, cwd: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */
|
/** Merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */
|
||||||
export async function merge(branch: string, cwd: string): Promise<string[]> {
|
export async function merge(branch: string, cwd: string, opts?: { squash?: boolean }): Promise<string[]> {
|
||||||
const result = await $`git merge ${branch}`.cwd(cwd).nothrow().quiet()
|
const cmd = opts?.squash
|
||||||
|
? $`git merge --squash ${branch}`.cwd(cwd).nothrow().quiet()
|
||||||
|
: $`git merge ${branch}`.cwd(cwd).nothrow().quiet()
|
||||||
|
const result = await cmd
|
||||||
if (result.exitCode === 0) return []
|
if (result.exitCode === 0) return []
|
||||||
|
|
||||||
// Check for unmerged (conflicted) files
|
// Check for unmerged (conflicted) files
|
||||||
|
|
@ -98,7 +101,21 @@ export async function merge(branch: string, cwd: string): Promise<string[]> {
|
||||||
if (files.length > 0) return files
|
if (files.length > 0) return files
|
||||||
|
|
||||||
// Not a conflict — some other merge failure
|
// Not a conflict — some other merge failure
|
||||||
throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`)
|
const label = opts?.squash ? "squash-merge" : "merge"
|
||||||
|
throw new Error(`Failed to ${label} branch "${branch}": ${result.stderr.toString().trim()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the staged diff as text. */
|
||||||
|
export async function diffStaged(cwd: string): Promise<string> {
|
||||||
|
return await $`git diff --staged`.cwd(cwd).nothrow().quiet().text()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit staged changes with a message. */
|
||||||
|
export async function commit(message: string, cwd: string): Promise<void> {
|
||||||
|
const result = await $`git commit -m ${message}`.cwd(cwd).nothrow().quiet()
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Failed to commit: ${result.stderr.toString().trim()}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stage a file. */
|
/** Stage a file. */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user