Compare commits

...

17 Commits

Author SHA1 Message Date
5c378ab239 Merge branch 'conflict-resolution'
# Conflicts:
#	src/git.ts
2026-04-10 10:13:03 -07:00
a7285a4347 Add comment explaining why clearActivity is called before process.exit
process.exit(1) skips the finally block, so cleanup must happen
explicitly in the catch block.
2026-04-10 10:06:00 -07:00
101651b107 Extract shared gitError helper to deduplicate stderr formatting
Also clear activity on failed merge and improve error context for
conflicted file reads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 10:02:23 -07:00
fbb8680376 Guard against empty stdout when resolving merge conflicts
Claude can exit 0 but produce no output, leaving the file blank.
2026-04-10 09:41:39 -07:00
f226f30e6f Handle reset failure gracefully when squash produces no changes
The reset during early exit could throw if the worktree is already in a
conflicted state, masking the real "no changes" diagnostic.
2026-04-10 08:58:45 -07:00
b4d0d9e948 Add "(no output)" fallback to all git error messages
The helpers already handled empty stderr but the git module did not,
leading to confusing error messages with a trailing colon and no detail.
2026-04-10 08:54:17 -07:00
106ff20de7 Fix squash error handling and exit codes
Skip unnecessary rollback when reset never happened, show fallback
notice when AI commit message generation fails, and exit non-zero
when squash produces no changes.
2026-04-10 08:44:18 -07:00
374454f0fb Add missing lockfiles to skip list and show fallback for empty stderr
Several ecosystem lockfiles (Nix flake, Gradle, npm-shrinkwrap, Swift PM)
were missing from SKIP_RESOLVE, causing unnecessary conflict resolution
attempts. Empty stderr on failure produced confusing error messages.
2026-04-10 08:44:17 -07:00
cbb6bac1b9 Expand the lock-file skip list for merge conflict resolution
Add bun.lockb, mix.lock, Pipfile.lock, Podfile.lock, pubspec.lock, and
uv.lock to SKIP_RESOLVE so generated files from additional ecosystems are
auto-resolved with theirs during rebases. Sort entries alphabetically.
2026-04-10 08:29:45 -07:00
f1825c65da Move reset into try block so rollback covers all failure paths
Hoist mainBranch lookup before the new-commits check to avoid a
duplicate call, and report clearly when rollback itself fails so
the user knows to consult git reflog.
2026-04-10 08:19:58 -07:00
85cf9cc02f Expand lock-file skip list and extract checkoutTheirs into git module
Covers all common ecosystem lock files (npm, yarn, pnpm, Go, Ruby,
PHP, Poetry) so merge conflicts in any of them are auto-resolved
with --theirs rather than sent to Claude.
2026-04-10 08:17:55 -07:00
27afe67aec Rework squash to collapse commits in-place instead of merging
The old squash-merge workflow closed the branch, which made it
impossible to keep working after squashing. Now squash uses
git reset --soft to the merge base, preserving all changes as a
single commit on the current branch. Rolls back to the original
HEAD on failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 08:12:57 -07:00
a9043d154d Rework squash command to collapse commits in-place
Instead of squash-merging into main, `sandlot squash` now soft-resets
to the merge base and recommits, keeping the branch independent.
This removes the --force flag and all squash-merge logic from
mergeAndClose, which goes back to being a plain merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 08:07:51 -07:00
cc61e09384 Propagate git-add failures instead of silently swallowing them
The old `stageFile` call discarded non-zero exit codes, hiding issues
like unresolved conflicts or missing files from the caller.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 08:07:43 -07:00
e694ab06d7 Accept theirs for conflicting lock files instead of deleting them
Removing lock files during conflict resolution caused missing dependencies
after the merge. Checking out theirs and staging preserves a valid lockfile.
2026-04-07 17:55:40 -07:00
29cbf29b76 Remove lock files instead of resolving them during conflict resolution
Lock files like bun.lock and Cargo.lock get regenerated, so resolving
their conflict markers is unnecessary work.
2026-04-07 09:26:55 -07:00
1143bf08c9 Skip AI conflict resolution for lock files
Lock files like bun.lock and Cargo.lock contain auto-generated content
that Claude cannot meaningfully merge. Remove them during conflict
resolution so they get regenerated cleanly.
2026-04-07 09:21:11 -07:00
5 changed files with 156 additions and 62 deletions

View File

@ -45,7 +45,7 @@ src/
list.ts # Show all active sessions with status
save.ts # Stage all changes and commit
merge.ts # Merge branch into main with conflict resolution
squash.ts # Squash-merge branch into main (AI commit message) and close
squash.ts # Squash all commits on a branch into a single commit (in-place)
rebase.ts # Rebase branch onto main with conflict resolution
review.ts # Launch grumpy code review with Claude
diff.ts # Show uncommitted changes or full branch diff
@ -148,9 +148,9 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
- `sandlot new` accepts a prompt instead of a branch name — derives a 2-word branch name via the Anthropic API (Haiku), falling back to simple text munging
- `sandlot open` always passes `continue: true` to `vm.claude()` to resume the previous conversation
- `sandlot save` uses `vm.claudePipe()` to generate commit messages from the staged diff
- `sandlot merge`, `sandlot squash`, and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically
- `sandlot squash` generates an AI commit message for the squash commit via `claudePipe()`; falls back to `"squash <branch>"`
- `sandlot merge` and `sandlot squash` both delegate to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up
- `sandlot merge` and `sandlot rebase` use `vm.claudePipe()` to resolve merge conflicts automatically
- `sandlot squash` collapses all branch commits into a single commit in-place via `git reset --soft` to the merge base, then generates an AI commit message via `claudePipe()`; falls back to `"squash <branch>"`. Rolls back to the original HEAD on failure.
- `sandlot merge` delegates to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up
- `sandlot list` discovers missing session prompts by parsing Claude's `history.jsonl` from inside the container
- `sandlot list` shows five status icons: idle (dim `◯`), active (cyan `◎`), dirty/unsaved (yellow `◐`), saved (green `●`), review (magenta `⦿`)
- `sandlot review` sets `in_review` on the session during the review and clears it in a `finally` block on exit; `list` detects stale `in_review` flags (Claude not active) and clears them from state

View File

@ -141,9 +141,8 @@ program
program
.command("squash")
.argument("<branch>", "branch name")
.option("-f, --force", "allow merging into a non-main branch")
.description("Squash-merge a branch into main and close the session")
.action((branch: string, opts: { force?: boolean }) => squashAction(branch, opts))
.description("Squash all commits on a branch into a single commit")
.action((branch: string) => squashAction(branch))
program
.command("rebase")

View File

@ -81,6 +81,29 @@ export async function teardownSession(root: string, branch: string, worktree: st
await state.removeSession(root, branch)
}
/** Generated files to skip AI resolution — accept theirs and move on (basename match so `packages/foo/bun.lock` is also covered). */
const SKIP_RESOLVE = new Set([
"bun.lock",
"bun.lockb",
"Cargo.lock",
"composer.lock",
"Gemfile.lock",
"go.sum",
"mix.lock",
"package-lock.json",
"Pipfile.lock",
"pnpm-lock.yaml",
"Podfile.lock",
"poetry.lock",
"pubspec.lock",
"flake.lock",
"gradle.lockfile",
"npm-shrinkwrap.json",
"Package.resolved",
"uv.lock",
"yarn.lock",
])
/** Resolve conflict markers in files using Claude, then stage them. */
export async function resolveConflicts(
files: string[],
@ -90,15 +113,24 @@ export async function resolveConflicts(
for (let i = 0; i < files.length; i++) {
const file = files[i]
onFile(file, i + 1, files.length)
const content = await Bun.file(join(cwd, file)).text()
if (SKIP_RESOLVE.has(basename(file))) {
await git.checkoutTheirs(file, cwd)
await git.stageFile(file, cwd)
continue
}
const content = await Bun.file(join(cwd, file)).text().catch(() => {
throw new Error(`Failed to read conflicted file: ${file}`)
})
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}`)
if (resolved.exitCode !== 0 || !resolved.stdout.trim()) {
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`)
}
await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n")
@ -106,8 +138,8 @@ 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; force?: boolean }): Promise<void> {
/** Merge a branch into main, resolve conflicts if needed, and close the session. */
export async function mergeAndClose(branch: string, opts?: { force?: boolean }): Promise<void> {
const root = await git.repoRoot()
const main = await git.mainBranch(root)
const current = await git.currentBranch(root)
@ -120,18 +152,11 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean; f
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
}
const label = opts?.squash ? "Squash-merged" : "Merged"
const spin = spinner("Merging", branch)
const conflicts = await git.merge(branch, root, opts?.squash ? { squash: true } : undefined)
const conflicts = await git.merge(branch, root)
if (conflicts.length === 0) {
if (opts?.squash) {
spin.text = "Starting container"
await vm.ensure((msg) => { spin.text = msg })
spin.text = "Generating commit message"
await squashCommit(branch, root)
}
spin.succeed(`${label} ${branch} into ${current}`)
spin.succeed(`Merged ${branch} into ${current}`)
if (session) await teardownSession(root, branch, session.worktree)
await git.deleteLocalBranch(branch, root).catch(() => {})
return
@ -147,15 +172,12 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean; f
spin.text = total > 1 ? `(${i}/${total}) Resolving ${file}` : `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}`)
await git.commitMerge(root)
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
spin.fail(message)
if (session) await vm.clearActivity(session.worktree, branch) // process.exit below skips finally
await git.abortMerge(root)
process.exit(1)
} finally {
@ -166,25 +188,6 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean; f
await git.deleteLocalBranch(branch, root).catch(() => {})
}
/** 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 commit message summarizing all changes in this squash merge. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.",
)
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<boolean> {
const spin = spinner("Staging changes", branch)

View File

@ -1,5 +1,67 @@
import { mergeAndClose } from "./helpers.ts"
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, opts?: { force?: boolean }) {
await mergeAndClose(branch, { squash: true, force: opts?.force })
export async function action(branch: string) {
const { 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(worktree)
if (!await git.hasNewCommits(worktree)) {
die(`Branch "${branch}" has no commits beyond ${main}.`)
}
const base = await git.mergeBase(main, "HEAD", worktree)
const originalHead = await git.headRef(worktree)
const spin = spinner("Squashing", branch)
let didReset = false
try {
await git.resetSoft(base, worktree)
didReset = true
spin.text = "Starting container"
await vm.ensure((msg) => { spin.text = msg })
spin.text = "Generating commit message"
const diff = await git.diffStaged(worktree)
if (!diff.trim()) {
await git.resetSoft(originalHead, worktree).catch(() => {})
spin.fail("No changes after squash")
process.exit(1)
}
const gen = await vm.claudePipe(
diff,
"Write a commit message summarizing all changes. Subject line: imperative mood, max 72 characters, no period. Add a blank line then a concise body with the key changes as bullet points. Output only the raw commit message, no quotes or markdown.",
)
if (gen.exitCode !== 0 || !gen.stdout.trim()) {
spin.text = "AI commit message failed, using fallback"
}
const msg = (gen.exitCode === 0 && gen.stdout.trim()) || `squash ${branch}`
await git.commit(msg, worktree)
spin.succeed(`Squashed ${branch} into a single commit`)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!didReset) {
spin.fail(`Squash failed: ${message}`)
} else {
const restored = await git.resetSoft(originalHead, worktree).then(() => true).catch(() => false)
spin.fail(restored
? `Squash failed, changes restored: ${message}`
: `Squash failed and rollback failed — check "git reflog" in the worktree: ${message}`)
}
process.exit(1)
}
}

View File

@ -2,6 +2,12 @@ import { existsSync } from "fs"
import { rm } from "fs/promises"
import { $ } from "bun"
/** Format a git error with a fallback for empty stderr. */
function gitError(action: string, stderr: Buffer | string): Error {
const msg = stderr.toString().trim()
return new Error(`${action}: ${msg || "(no output)"}`)
}
/** Get the repo root from a working directory. */
export async function repoRoot(cwd?: string): Promise<string> {
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").nothrow().quiet()
@ -72,7 +78,7 @@ export async function createWorktree(branch: string, worktreePath: string, cwd:
}
if (result.exitCode !== 0) {
if (switchedFromBranch) await checkout(branch, cwd).catch(() => {})
throw new Error(`Failed to create worktree for "${branch}": ${result.stderr.toString().trim()}`)
throw gitError(`Failed to create worktree for "${branch}"`, result.stderr)
}
return { branchCreated: exists !== "local" }
}
@ -98,16 +104,13 @@ export async function deleteLocalBranch(branch: string, cwd: string): Promise<vo
export async function checkout(branch: string, cwd: string): Promise<void> {
const result = await $`git checkout ${branch}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw new Error(`Failed to checkout branch "${branch}": ${result.stderr.toString().trim()}`)
throw gitError(`Failed to checkout branch "${branch}"`, result.stderr)
}
}
/** Merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */
export async function merge(branch: string, cwd: string, opts?: { squash?: boolean }): Promise<string[]> {
const cmd = opts?.squash
? $`git merge --squash ${branch}`.cwd(cwd).nothrow().quiet()
: $`git merge ${branch}`.cwd(cwd).nothrow().quiet()
const result = await cmd
export async function merge(branch: string, cwd: string): Promise<string[]> {
const result = await $`git merge ${branch}`.cwd(cwd).nothrow().quiet()
if (result.exitCode === 0) return []
// Check for unmerged (conflicted) files
@ -116,8 +119,7 @@ export async function merge(branch: string, cwd: string, opts?: { squash?: boole
if (files.length > 0) return files
// Not a conflict — some other merge failure
const label = opts?.squash ? "squash-merge" : "merge"
throw new Error(`Failed to ${label} branch "${branch}": ${result.stderr.toString().trim()}`)
throw gitError(`Failed to merge branch "${branch}"`, result.stderr)
}
/** Return the staged diff as text. */
@ -129,20 +131,31 @@ export async function diffStaged(cwd: string): Promise<string> {
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()}`)
throw gitError("Failed to commit", result.stderr)
}
}
/** Accept "theirs" version of a conflicted file. */
export async function checkoutTheirs(file: string, cwd: string): Promise<void> {
const result = await $`git checkout --theirs -- ${file}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw gitError(`Failed to checkout theirs for ${file}`, result.stderr)
}
}
/** Stage a file. */
export async function stageFile(file: string, cwd: string): Promise<void> {
await $`git add ${file}`.cwd(cwd).nothrow().quiet()
const result = await $`git add ${file}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw gitError(`Failed to stage ${file}`, result.stderr)
}
}
/** Finalize a merge commit after resolving conflicts. */
export async function commitMerge(cwd: string): Promise<void> {
const result = await $`git commit --no-edit`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw new Error(`Failed to commit merge: ${result.stderr.toString().trim()}`)
throw gitError("Failed to commit merge", result.stderr)
}
}
@ -151,6 +164,23 @@ export async function abortMerge(cwd: string): Promise<void> {
await $`git merge --abort`.cwd(cwd).nothrow().quiet()
}
/** Soft-reset to a given ref (keeps changes staged). */
export async function resetSoft(ref: string, cwd: string): Promise<void> {
const result = await $`git reset --soft ${ref}`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw new Error(`Failed to reset to "${ref}": ${result.stderr.toString().trim()}`)
}
}
/** Get the full SHA of HEAD. */
export async function headRef(cwd: string): Promise<string> {
const result = await $`git rev-parse HEAD`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
throw new Error("Could not resolve HEAD.")
}
return result.text().trim()
}
/** 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 (check for rebase state directories, not REBASE_HEAD which can be stale)
@ -181,7 +211,7 @@ export async function rebaseContinue(cwd: string): Promise<string[]> {
const files = unmerged.trim().split("\n").filter(Boolean)
if (files.length > 0) return files
throw new Error(`Rebase --continue failed: ${result.stderr.toString().trim()}`)
throw gitError("Rebase --continue failed", result.stderr)
}
/** Abort an in-progress rebase. */