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/squash.ts b/src/commands/squash.ts new file mode 100644 index 0000000..070135a --- /dev/null +++ b/src/commands/squash.ts @@ -0,0 +1,43 @@ +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() + 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.squashMerge(branch, root) + + if (conflicts.length === 0) { + await git.commit(`squash ${branch}`, root) + console.log(`✔ Squash-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 }) + await resolveConflicts(conflicts, root, (file) => { spin.text = `Resolving ${file}` }) + + await git.commit(`squash ${branch}`, root) + spin.succeed(`Resolved ${conflicts.length} conflict(s) and squash-merged ${branch}`) + } catch (err) { + spin.fail(String((err as Error).message ?? err)) + await git.abortMerge(root) + process.exit(1) + } + + await closeAction(branch) +} diff --git a/src/git.ts b/src/git.ts index 1f5c475..1bb8423 100644 --- a/src/git.ts +++ b/src/git.ts @@ -101,6 +101,28 @@ export async function merge(branch: string, cwd: string): Promise { throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`) } +/** Squash-merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */ +export async function squashMerge(branch: string, cwd: string): Promise { + const result = await $`git merge --squash ${branch}`.cwd(cwd).nothrow().quiet() + if (result.exitCode === 0) return [] + + // Check for unmerged (conflicted) files + 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 + + // Not a conflict — some other merge failure + throw new Error(`Failed to squash-merge branch "${branch}": ${result.stderr.toString().trim()}`) +} + +/** 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. */ export async function stageFile(file: string, cwd: string): Promise { await $`git add ${file}`.cwd(cwd).nothrow().quiet()