Compare commits
17 Commits
dfb62f7646
...
5c378ab239
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c378ab239 | |||
| a7285a4347 | |||
| 101651b107 | |||
| fbb8680376 | |||
| f226f30e6f | |||
| b4d0d9e948 | |||
| 106ff20de7 | |||
| 374454f0fb | |||
| cbb6bac1b9 | |||
| f1825c65da | |||
| 85cf9cc02f | |||
| 27afe67aec | |||
| a9043d154d | |||
| cc61e09384 | |||
| e694ab06d7 | |||
| 29cbf29b76 | |||
| 1143bf08c9 |
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -141,9 +141,8 @@ program
|
||||||
program
|
program
|
||||||
.command("squash")
|
.command("squash")
|
||||||
.argument("<branch>", "branch name")
|
.argument("<branch>", "branch name")
|
||||||
.option("-f, --force", "allow merging into a non-main branch")
|
.description("Squash all commits on a branch into a single commit")
|
||||||
.description("Squash-merge a branch into main and close the session")
|
.action((branch: string) => squashAction(branch))
|
||||||
.action((branch: string, opts: { force?: boolean }) => squashAction(branch, opts))
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("rebase")
|
.command("rebase")
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,29 @@ export async function teardownSession(root: string, branch: string, worktree: st
|
||||||
await state.removeSession(root, branch)
|
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. */
|
/** Resolve conflict markers in files using Claude, then stage them. */
|
||||||
export async function resolveConflicts(
|
export async function resolveConflicts(
|
||||||
files: string[],
|
files: string[],
|
||||||
|
|
@ -90,15 +113,24 @@ export async function resolveConflicts(
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i]
|
const file = files[i]
|
||||||
onFile(file, i + 1, files.length)
|
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(
|
const resolved = await vm.claudePipe(
|
||||||
content,
|
content,
|
||||||
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
|
"resolve this merge conflict. output ONLY the resolved file content with no markdown fences, no explanation, no surrounding text.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (resolved.exitCode !== 0) {
|
if (resolved.exitCode !== 0 || !resolved.stdout.trim()) {
|
||||||
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr}`)
|
throw new Error(`Claude failed to resolve ${file}: ${resolved.stderr.trim() || "(no output)"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await Bun.write(join(cwd, file), resolved.stdout.trimEnd() + "\n")
|
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. */
|
/** 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> {
|
export async function mergeAndClose(branch: string, opts?: { force?: boolean }): Promise<void> {
|
||||||
const root = await git.repoRoot()
|
const root = await git.repoRoot()
|
||||||
const main = await git.mainBranch(root)
|
const main = await git.mainBranch(root)
|
||||||
const current = await git.currentBranch(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.`)
|
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = opts?.squash ? "Squash-merged" : "Merged"
|
|
||||||
const spin = spinner("Merging", branch)
|
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 (conflicts.length === 0) {
|
||||||
if (opts?.squash) {
|
spin.succeed(`Merged ${branch} into ${current}`)
|
||||||
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}`)
|
|
||||||
if (session) await teardownSession(root, branch, session.worktree)
|
if (session) await teardownSession(root, branch, session.worktree)
|
||||||
await git.deleteLocalBranch(branch, root).catch(() => {})
|
await git.deleteLocalBranch(branch, root).catch(() => {})
|
||||||
return
|
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}`
|
spin.text = total > 1 ? `(${i}/${total}) Resolving ${file}` : `Resolving ${file}`
|
||||||
})
|
})
|
||||||
|
|
||||||
if (opts?.squash) {
|
|
||||||
await squashCommit(branch, root)
|
|
||||||
} else {
|
|
||||||
await git.commitMerge(root)
|
await git.commitMerge(root)
|
||||||
}
|
spin.succeed(`Resolved ${conflicts.length} conflict(s) and merged ${branch}`)
|
||||||
spin.succeed(`Resolved ${conflicts.length} conflict(s) and ${label.toLowerCase()} ${branch}`)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
spin.fail(message)
|
spin.fail(message)
|
||||||
|
if (session) await vm.clearActivity(session.worktree, branch) // process.exit below skips finally
|
||||||
await git.abortMerge(root)
|
await git.abortMerge(root)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -166,25 +188,6 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean; f
|
||||||
await git.deleteLocalBranch(branch, root).catch(() => {})
|
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. */
|
/** 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,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 }) {
|
export async function action(branch: string) {
|
||||||
await mergeAndClose(branch, { squash: true, force: opts?.force })
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
src/git.ts
56
src/git.ts
|
|
@ -2,6 +2,12 @@ import { existsSync } from "fs"
|
||||||
import { rm } from "fs/promises"
|
import { rm } from "fs/promises"
|
||||||
import { $ } from "bun"
|
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. */
|
/** Get the repo root from a working directory. */
|
||||||
export async function repoRoot(cwd?: string): Promise<string> {
|
export async function repoRoot(cwd?: string): Promise<string> {
|
||||||
const result = await $`git rev-parse --show-toplevel`.cwd(cwd ?? ".").nothrow().quiet()
|
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 (result.exitCode !== 0) {
|
||||||
if (switchedFromBranch) await checkout(branch, cwd).catch(() => {})
|
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" }
|
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> {
|
export async function checkout(branch: string, cwd: string): Promise<void> {
|
||||||
const result = await $`git checkout ${branch}`.cwd(cwd).nothrow().quiet()
|
const result = await $`git checkout ${branch}`.cwd(cwd).nothrow().quiet()
|
||||||
if (result.exitCode !== 0) {
|
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. */
|
/** 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 +119,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 gitError(`Failed to merge branch "${branch}"`, result.stderr)
|
||||||
throw new Error(`Failed to ${label} branch "${branch}": ${result.stderr.toString().trim()}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return the staged diff as text. */
|
/** 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> {
|
export async function commit(message: string, cwd: string): Promise<void> {
|
||||||
const result = await $`git commit -m ${message}`.cwd(cwd).nothrow().quiet()
|
const result = await $`git commit -m ${message}`.cwd(cwd).nothrow().quiet()
|
||||||
if (result.exitCode !== 0) {
|
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. */
|
/** Stage a file. */
|
||||||
export async function stageFile(file: string, cwd: string): Promise<void> {
|
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. */
|
/** Finalize a merge commit after resolving conflicts. */
|
||||||
export async function commitMerge(cwd: string): Promise<void> {
|
export async function commitMerge(cwd: string): Promise<void> {
|
||||||
const result = await $`git commit --no-edit`.cwd(cwd).nothrow().quiet()
|
const result = await $`git commit --no-edit`.cwd(cwd).nothrow().quiet()
|
||||||
if (result.exitCode !== 0) {
|
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()
|
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. */
|
/** 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)
|
||||||
|
|
@ -181,7 +211,7 @@ export async function rebaseContinue(cwd: string): Promise<string[]> {
|
||||||
const files = unmerged.trim().split("\n").filter(Boolean)
|
const files = unmerged.trim().split("\n").filter(Boolean)
|
||||||
if (files.length > 0) return files
|
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. */
|
/** Abort an in-progress rebase. */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user