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>
This commit is contained in:
parent
a9043d154d
commit
27afe67aec
|
|
@ -45,7 +45,7 @@ src/
|
||||||
list.ts # Show all active sessions with status
|
list.ts # Show all active sessions with status
|
||||||
save.ts # Stage all changes and commit
|
save.ts # Stage all changes and commit
|
||||||
merge.ts # Merge branch into main with conflict resolution
|
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
|
rebase.ts # Rebase branch onto main with conflict resolution
|
||||||
review.ts # Launch grumpy code review with Claude
|
review.ts # Launch grumpy code review with Claude
|
||||||
diff.ts # Show uncommitted changes or full branch diff
|
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 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 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 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 merge` 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 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` and `sandlot squash` both delegate to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up
|
- `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` 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 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
|
- `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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { $ } from "bun"
|
|
||||||
import * as git from "../git.ts"
|
import * as git from "../git.ts"
|
||||||
import * as vm from "../vm.ts"
|
import * as vm from "../vm.ts"
|
||||||
import { spinner } from "../spinner.ts"
|
import { spinner } from "../spinner.ts"
|
||||||
|
|
@ -9,39 +8,52 @@ export async function action(branch: string) {
|
||||||
const { session } = await requireSession(branch)
|
const { session } = await requireSession(branch)
|
||||||
const worktree = session.worktree
|
const worktree = session.worktree
|
||||||
|
|
||||||
const main = await git.mainBranch(worktree)
|
if (await git.isDirty(worktree)) {
|
||||||
const base = await git.mergeBase(main, "HEAD", worktree)
|
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
|
||||||
const head = (await $`git rev-parse HEAD`.cwd(worktree).nothrow().quiet().text()).trim()
|
}
|
||||||
|
|
||||||
if (head === base) {
|
if (!await git.hasNewCommits(worktree)) {
|
||||||
|
const main = await git.mainBranch(worktree)
|
||||||
die(`Branch "${branch}" has no commits beyond ${main}.`)
|
die(`Branch "${branch}" has no commits beyond ${main}.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const main = await git.mainBranch(worktree)
|
||||||
|
const base = await git.mergeBase(main, "HEAD", worktree)
|
||||||
|
const originalHead = await git.headRef(worktree)
|
||||||
|
|
||||||
const spin = spinner("Squashing", branch)
|
const spin = spinner("Squashing", branch)
|
||||||
|
|
||||||
await git.resetSoft(base, worktree)
|
await git.resetSoft(base, worktree)
|
||||||
|
|
||||||
spin.text = "Starting container"
|
try {
|
||||||
await vm.ensure((msg) => { spin.text = msg })
|
spin.text = "Starting container"
|
||||||
|
await vm.ensure((msg) => { spin.text = msg })
|
||||||
|
|
||||||
spin.text = "Generating commit message"
|
spin.text = "Generating commit message"
|
||||||
const diff = await git.diffStaged(worktree)
|
const diff = await git.diffStaged(worktree)
|
||||||
|
|
||||||
if (!diff.trim()) {
|
if (!diff.trim()) {
|
||||||
spin.fail("No changes after squash")
|
await git.resetSoft(originalHead, worktree)
|
||||||
return
|
spin.fail("No changes after squash")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
await git.commit(gen.stdout.trim(), worktree)
|
||||||
|
} else {
|
||||||
|
await git.commit(`squash ${branch}`, worktree)
|
||||||
|
}
|
||||||
|
|
||||||
|
spin.succeed(`Squashed ${branch} into a single commit`)
|
||||||
|
} catch (err) {
|
||||||
|
await git.resetSoft(originalHead, worktree).catch(() => {})
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
spin.fail(`Squash failed, changes restored: ${message}`)
|
||||||
|
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()) {
|
|
||||||
await git.commit(gen.stdout.trim(), worktree)
|
|
||||||
} else {
|
|
||||||
await git.commit(`squash ${branch}`, worktree)
|
|
||||||
}
|
|
||||||
|
|
||||||
spin.succeed(`Squashed ${branch} into a single commit`)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
src/git.ts
19
src/git.ts
|
|
@ -103,11 +103,8 @@ 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, opts?: { squash?: boolean }): Promise<string[]> {
|
export async function merge(branch: string, cwd: string): Promise<string[]> {
|
||||||
const cmd = opts?.squash
|
const result = await $`git merge ${branch}`.cwd(cwd).nothrow().quiet()
|
||||||
? $`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
|
||||||
|
|
@ -116,8 +113,7 @@ export async function merge(branch: string, cwd: string, opts?: { squash?: boole
|
||||||
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
|
||||||
const label = opts?.squash ? "squash-merge" : "merge"
|
throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`)
|
||||||
throw new Error(`Failed to ${label} branch "${branch}": ${result.stderr.toString().trim()}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return the staged diff as text. */
|
/** Return the staged diff as text. */
|
||||||
|
|
@ -159,6 +155,15 @@ export async function resetSoft(ref: string, cwd: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** 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[]> {
|
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)
|
// Bail early if a rebase is already in progress (check for rebase state directories, not REBASE_HEAD which can be stale)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user