Compare commits

...

4 Commits

5 changed files with 90 additions and 28 deletions

View File

@ -10,6 +10,7 @@ import { action as openAction } from "./commands/open.ts"
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 checkoutAction } from "./commands/checkout.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"
@ -81,6 +82,14 @@ program
.description("Remove a session (alias for close)")
.action((branch: string, opts: { force?: boolean }) => closeAction(branch, opts))
program
.command("checkout")
.alias("co")
.argument("<branch>", "branch name")
.option("-f, --force", "checkout even if there are unsaved changes")
.description("Close the session and check out the branch locally")
.action((branch: string, opts: { force?: boolean }) => checkoutAction(branch, opts))
// ── Branch ──────────────────────────────────────────────────────────
program.commandsGroup("Branch Commands:")

21
src/commands/checkout.ts Normal file
View File

@ -0,0 +1,21 @@
import * as git from "../git.ts"
import { die } from "../fmt.ts"
import { requireSession, teardownSession } from "./helpers.ts"
export async function action(branch: string, opts: { force?: boolean } = {}) {
const { root, session } = await requireSession(branch)
if (await git.isDirty(session.worktree)) {
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first.`)
}
if (!opts.force && await git.isDirty(root)) {
die(`Working tree has uncommitted changes that may conflict with checkout. Commit or stash them first, or use -f to force.`)
}
await teardownSession(root, branch, session.worktree)
await git.checkout(branch, root)
console.log(`✔ Checked out ${branch}`)
}

View File

@ -1,36 +1,18 @@
import { join } from "path"
import { unlink } from "fs/promises"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { die } from "../fmt.ts"
import { requireSession, teardownSession } from "./helpers.ts"
export async function action(branch: string, opts: { force?: boolean } = {}) {
const root = await git.repoRoot()
const session = await state.getSession(root, branch)
const { root, session } = await requireSession(branch)
if (!session) {
die(`No session found for branch "${branch}"`)
}
const worktreeAbs = session.worktree
if (!opts.force && await git.isDirty(worktreeAbs)) {
if (!opts.force && await git.isDirty(session.worktree)) {
die(`Branch "${branch}" has unsaved changes. Run "sandlot save ${branch}" first, or use -f to force.`)
}
await vm.clearActivity(worktreeAbs, branch)
await git.removeWorktree(worktreeAbs, root)
.catch((e) => console.warn(`⚠ Failed to remove worktree: ${e.message}`))
await unlink(join(root, '.sandlot', branch))
.catch(() => {}) // symlink may not exist
await teardownSession(root, branch, session.worktree)
await git.deleteLocalBranch(branch, root)
.catch((e) => console.warn(`⚠ Failed to delete branch ${branch}: ${e.message}`))
await state.removeSession(root, branch)
console.log(`✔ Closed session ${branch}`)
}

View File

@ -1,11 +1,12 @@
import { join } from "path"
import { basename, join } from "path"
import { homedir } from "os"
import { mkdir, symlink, unlink } from "fs/promises"
import { $ } from "bun"
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 type { Session } from "../state.ts"
/** Look up a session by branch, dying if it doesn't exist. */
@ -18,6 +19,53 @@ export async function requireSession(branch: string): Promise<{ root: string; se
return { root, session }
}
/** Look up a session by branch, recreating the worktree/session if the branch exists but the session doesn't. */
export async function ensureSession(branch: string): Promise<{ root: string; session: Session }> {
const root = await git.repoRoot()
const existing = await state.getSession(root, branch)
if (existing) return { root, session: existing }
// No session — check if the branch exists
const exists = await git.branchExists(branch, root)
if (!exists) {
die(`No session or branch found for "${branch}".`)
}
// Recreate worktree and session
const worktreeAbs = join(homedir(), '.sandlot', basename(root), branch)
try {
await git.createWorktree(branch, worktreeAbs, root)
await mkdir(join(root, '.sandlot'), { recursive: true })
await symlink(worktreeAbs, join(root, '.sandlot', branch))
} catch (err) {
// Clean up on failure — but do NOT delete the branch (it already existed)
await git.removeWorktree(worktreeAbs, root).catch(() => {})
await unlink(join(root, '.sandlot', branch)).catch(() => {})
die(`Failed to recreate session: ${(err as Error).message ?? err}`)
}
const session: Session = {
branch,
worktree: worktreeAbs,
created_at: new Date().toISOString(),
}
await state.setSession(root, session)
return { root, session }
}
/** Tear down a session: clear activity, remove worktree, unlink symlink, remove state. */
export async function teardownSession(root: string, branch: string, worktree: string): Promise<void> {
await vm.clearActivity(worktree, branch)
await git.removeWorktree(worktree, root)
.catch((e) => console.warn(`⚠ Failed to remove worktree: ${e.message}`))
await unlink(join(root, '.sandlot', branch))
.catch(() => {}) // symlink may not exist
await state.removeSession(root, branch)
}
/** Resolve conflict markers in files using Claude, then stage them. */
export async function resolveConflicts(
files: string[],
@ -63,7 +111,8 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean })
await squashCommit(branch, root)
}
spin.succeed(`${label} ${branch} into current branch`)
await closeAction(branch)
if (session) await teardownSession(root, branch, session.worktree)
await git.deleteLocalBranch(branch, root).catch(() => {})
return
}
@ -87,7 +136,8 @@ export async function mergeAndClose(branch: string, opts?: { squash?: boolean })
process.exit(1)
}
await closeAction(branch)
if (session) await teardownSession(root, branch, session.worktree)
await git.deleteLocalBranch(branch, root).catch(() => {})
}
/** Generate a commit message for a squash-merge via Claude and commit. */

View File

@ -2,14 +2,14 @@ import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts"
import { renderMarkdown } from "../markdown.ts"
import { requireSession, saveChanges } from "./helpers.ts"
import { ensureSession, saveChanges } from "./helpers.ts"
export async function action(
branch: string,
prompt: string | undefined,
opts: { print?: string; save?: boolean },
) {
const { root, session } = await requireSession(branch)
const { root, session } = await ensureSession(branch)
const effectivePrompt = opts.print || prompt
if (effectivePrompt) {