diff --git a/src/cli.ts b/src/cli.ts index 55242dc..35a018d 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 squashAction } from "./commands/squash.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" @@ -97,6 +98,14 @@ program .description("Merge a branch into main and close the session") .action(mergeAction) +// ── sandlot squash ─────────────────────────────────────────────────── + +program + .command("squash") + .argument("", "branch name") + .description("Squash-merge a branch into main and close the session") + .action(squashAction) + // ── sandlot rebase ─────────────────────────────────────────────────── program diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 02de7a8..a760c78 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -5,6 +5,7 @@ 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 type { Session } from "../state.ts" /** 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 { + 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 { + 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. */ export async function saveChanges(worktree: string, branch: string, message?: string): Promise { const spin = spinner("Staging changes", branch) diff --git a/src/commands/merge.ts b/src/commands/merge.ts index 6e542bf..1f4d05e 100644 --- a/src/commands/merge.ts +++ b/src/commands/merge.ts @@ -1,171 +1,5 @@ -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" - -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)` -} +import { mergeAndClose } from "./helpers.ts" export async function action(branch: string) { - 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 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 && 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 && 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) + await mergeAndClose(branch) } diff --git a/src/commands/squash.ts b/src/commands/squash.ts new file mode 100644 index 0000000..2f0d341 --- /dev/null +++ b/src/commands/squash.ts @@ -0,0 +1,5 @@ +import { mergeAndClose } from "./helpers.ts" + +export async function action(branch: string) { + await mergeAndClose(branch, { squash: true }) +} diff --git a/src/git.ts b/src/git.ts index 62519d3..379e139 100644 --- a/src/git.ts +++ b/src/git.ts @@ -88,8 +88,11 @@ export async function checkout(branch: string, cwd: string): Promise { } /** 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 { - const result = await $`git merge ${branch}`.cwd(cwd).nothrow().quiet() +export async function merge(branch: string, cwd: string, opts?: { squash?: boolean }): Promise { + 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 [] // Check for unmerged (conflicted) files @@ -98,7 +101,21 @@ export async function merge(branch: string, cwd: string): Promise { if (files.length > 0) return files // 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 { + 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 { + 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. */