simplify
This commit is contained in:
parent
b5c507570e
commit
6f6d921c54
36
README.md
36
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# sandlot
|
||||
|
||||
A CLI for branch-based development using git worktrees and [Apple containers](https://github.com/apple/container). Each branch gets its own worktree and isolated VM. Commits are auto-summarized by Claude. Merging cleans everything up.
|
||||
A CLI for branch-based development using git worktrees and [Apple containers](https://github.com/apple/container). Each branch gets its own worktree and isolated VM.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
|
@ -18,16 +18,6 @@ bun install
|
|||
bun link
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
Set your Anthropic API key in `~/.env`:
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
This file is loaded automatically on startup. Variables already set in the environment take precedence.
|
||||
|
||||
## Usage
|
||||
|
||||
Run all commands from inside a cloned git repo.
|
||||
|
|
@ -40,32 +30,17 @@ sandlot new fix-POST
|
|||
|
||||
Creates a worktree at `.sandlot/fix-POST/`, boots a VM mapped to it, and drops you into a shell.
|
||||
|
||||
### Save your work
|
||||
|
||||
```bash
|
||||
sandlot save # AI-generated commit message
|
||||
sandlot save "wip: rough validation" # manual message
|
||||
```
|
||||
|
||||
Stages all changes, commits, and pushes to origin.
|
||||
|
||||
### Merge and tear down
|
||||
|
||||
```bash
|
||||
sandlot push main
|
||||
```
|
||||
|
||||
Merges the session branch into the target, pushes, then tears down the VM, worktree, and branch. If there are merge conflicts, Claude resolves them and asks for confirmation.
|
||||
|
||||
### Other commands
|
||||
|
||||
```bash
|
||||
sandlot list # show all sessions
|
||||
sandlot open <branch> # re-enter a session's VM
|
||||
sandlot stop <branch> # stop a VM without destroying it
|
||||
sandlot rm <branch> # tear down without merging
|
||||
sandlot rm <branch> # tear down session (VM, worktree, local branch)
|
||||
```
|
||||
|
||||
Use git directly for commits, pushes, merges, etc. The worktree is a normal git checkout.
|
||||
|
||||
## Project config
|
||||
|
||||
Optionally add a `sandlot.json` to your repo root:
|
||||
|
|
@ -77,9 +52,6 @@ Optionally add a `sandlot.json` to your repo root:
|
|||
"memory": "8GB",
|
||||
"image": "ubuntu:24.04",
|
||||
"mounts": { "/path/to/deps": "/deps" }
|
||||
},
|
||||
"ai": {
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
107
SPEC.md
107
SPEC.md
|
|
@ -1,12 +1,12 @@
|
|||
# sandlot
|
||||
|
||||
A CLI for branch-based development using git worktrees and Apple containers. Each branch gets its own worktree and isolated VM. Commits are auto-summarized by Claude. Merging cleans everything up.
|
||||
A CLI for branch-based development using git worktrees and Apple containers. Each branch gets its own worktree and isolated VM.
|
||||
|
||||
## Concepts
|
||||
|
||||
**Sandlot** is a thin workflow layer over three things: git worktrees, [Apple containers](https://github.com/apple/container), and the Claude API. The idea is that spinning up a branch should give you a fully isolated environment — filesystem and runtime — with zero setup. When you're done, you merge and everything tears down.
|
||||
**Sandlot** is a thin workflow layer over two things: git worktrees and [Apple containers](https://github.com/apple/container). The idea is that spinning up a branch should give you a fully isolated environment — filesystem and runtime — with zero setup. When you're done, use git to merge and `sandlot rm` to clean up.
|
||||
|
||||
A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot <branch>` and destroyed on merge.
|
||||
A sandlot **session** is a (worktree, VM) pair tied to a branch. Sessions are created with `sandlot new <branch>` and destroyed with `sandlot rm <branch>`.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
|
@ -34,16 +34,6 @@ bun link
|
|||
|
||||
This makes `sandlot` available globally.
|
||||
|
||||
### Configure
|
||||
|
||||
Set your Anthropic API key:
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
Add to your shell profile (`.zshrc`, `.bashrc`, etc.) to persist it.
|
||||
|
||||
## CLI
|
||||
|
||||
### `sandlot new <branch>`
|
||||
|
|
@ -62,87 +52,6 @@ Booting VM...
|
|||
root@fix-POST:~#
|
||||
```
|
||||
|
||||
### `sandlot save [message]`
|
||||
|
||||
Stage and commit all changes. If a message is provided, use it. If not, generate one with Claude.
|
||||
|
||||
1. Runs `git add .` in the worktree
|
||||
2. If no message provided: diffs staged changes against the last commit, sends the diff to Claude API (claude-sonnet-4-20250514) to generate a commit message
|
||||
3. Commits with the message
|
||||
4. Pushes the branch to origin
|
||||
|
||||
If there are no changes, does nothing.
|
||||
|
||||
The AI-generated commit message is a single-line summary (≤72 chars) followed by an optional body with more detail if the diff is substantial.
|
||||
|
||||
```
|
||||
$ sandlot save
|
||||
Staged 3 files
|
||||
Commit: Fix POST handler to validate request body before processing
|
||||
Pushed fix-POST → origin/fix-POST
|
||||
|
||||
$ sandlot save "wip: rough cut of validation"
|
||||
Staged 3 files
|
||||
Commit: wip: rough cut of validation
|
||||
Pushed fix-POST → origin/fix-POST
|
||||
```
|
||||
|
||||
### `sandlot push <target>`
|
||||
|
||||
Push the current session's branch into `<target>`, then tear everything down.
|
||||
|
||||
1. Checks out `<target>` in the main working tree
|
||||
2. Merges the session branch (fast-forward if possible, merge commit otherwise)
|
||||
3. Pushes `<target>` to origin
|
||||
4. Stops and removes the VM
|
||||
5. Removes the worktree
|
||||
6. Deletes the local branch
|
||||
7. Deletes the remote branch
|
||||
|
||||
If there are uncommitted changes in the worktree, prompts to `sandlot save` first.
|
||||
|
||||
If the merge has conflicts:
|
||||
|
||||
1. Collects all conflicted files
|
||||
2. For each file, sends the full conflict diff (ours, theirs, and base) to the Claude API
|
||||
3. Claude resolves the conflict and returns the merged file
|
||||
4. Writes the resolved file and stages it
|
||||
5. Shows a summary of what Claude chose and why
|
||||
6. Prompts for confirmation before committing the merge
|
||||
|
||||
If you reject Claude's resolution, drops you into the main working tree to resolve manually. Run `sandlot push <target>` again to complete cleanup.
|
||||
|
||||
```
|
||||
$ sandlot push main
|
||||
Pushing fix-POST → main...
|
||||
Pushed main → origin/main
|
||||
Stopped VM fix-POST
|
||||
Removed worktree .sandlot/fix-POST/
|
||||
Deleted branch fix-POST (local + remote)
|
||||
```
|
||||
|
||||
With conflicts:
|
||||
|
||||
```
|
||||
$ sandlot push main
|
||||
Pushing fix-POST → main...
|
||||
2 conflicts found. Resolving with Claude...
|
||||
|
||||
src/handlers/post.ts
|
||||
✓ Kept the new validation logic from fix-POST,
|
||||
preserved the logging added in main
|
||||
|
||||
src/routes.ts
|
||||
✓ Combined route definitions from both branches
|
||||
|
||||
Accept Claude's resolutions? [Y/n] y
|
||||
Committed merge
|
||||
Pushed main → origin/main
|
||||
Stopped VM fix-POST
|
||||
Removed worktree .sandlot/fix-POST/
|
||||
Deleted branch fix-POST (local + remote)
|
||||
```
|
||||
|
||||
### `sandlot list`
|
||||
|
||||
Show all active sessions.
|
||||
|
|
@ -170,7 +79,7 @@ Stop a session's VM without destroying it. The worktree and branch remain.
|
|||
|
||||
### `sandlot rm <branch>`
|
||||
|
||||
Tear down a session without merging. Stops the VM, removes the worktree, deletes the local branch. Does not touch the remote branch.
|
||||
Tear down a session. Stops the VM, removes the worktree, deletes the local branch. Does not touch the remote branch.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -185,9 +94,6 @@ Optional `sandlot.json` at the repo root:
|
|||
"mounts": {
|
||||
"/path/to/shared/deps": "/deps"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -214,13 +120,12 @@ Sandlot tracks sessions in `.sandlot/state.json` at the repo root:
|
|||
|
||||
## Edge Cases
|
||||
|
||||
- **Nested sandlot calls**: `sandlot save` and `sandlot push` detect the current session from the working directory or a `SANDLOT_BRANCH` env var set when entering the VM.
|
||||
- **Stale VMs**: If a VM crashes, `sandlot open` should detect the dead VM and reboot it.
|
||||
- **Stale VMs**: If a VM crashes, `sandlot open` detects the dead VM and reboots it.
|
||||
- **Multiple repos**: State is per-repo. No global daemon.
|
||||
- **Branch name conflicts**: If `.sandlot/<branch>/` already exists as a directory but the session state is missing, prompt to clean up or recover.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Not a CI/CD tool. No pipelines, no test runners.
|
||||
- Not a replacement for git. All git state lives in the real repo. Sandlot is sugar on top.
|
||||
- Not a replacement for git. All git state lives in the real repo. Sandlot manages worktrees and VMs only.
|
||||
- No multi-user collaboration features. This is a single-developer workflow tool.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
"sandlot": "./src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"commander": "^13.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
74
src/ai.ts
74
src/ai.ts
|
|
@ -1,74 +0,0 @@
|
|||
import Anthropic from "@anthropic-ai/sdk"
|
||||
|
||||
let client: Anthropic | null = null
|
||||
|
||||
function getClient(): Anthropic {
|
||||
if (!client) {
|
||||
client = new Anthropic()
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
/** Generate a commit message from a diff. */
|
||||
export async function generateCommitMessage(diff: string, model: string): Promise<string> {
|
||||
const anthropic = getClient()
|
||||
|
||||
const response = await anthropic.messages.create({
|
||||
model,
|
||||
max_tokens: 256,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Generate a git commit message for this diff. The first line must be a single-line summary of 72 characters or less. If the diff is substantial, add a blank line followed by a body with more detail. Return ONLY the commit message, nothing else.\n\n${diff}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const block = response.content[0]
|
||||
if (block.type === "text") return block.text.trim()
|
||||
throw new Error("Unexpected response from Claude")
|
||||
}
|
||||
|
||||
/** Resolve a merge conflict in a file. Returns the resolved content and an explanation. */
|
||||
export async function resolveConflict(
|
||||
filePath: string,
|
||||
conflictContent: string,
|
||||
model: string
|
||||
): Promise<{ resolved: string; explanation: string }> {
|
||||
const anthropic = getClient()
|
||||
|
||||
const response = await anthropic.messages.create({
|
||||
model,
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `You are resolving a git merge conflict in the file "${filePath}". The file contains conflict markers (<<<<<<< HEAD, =======, >>>>>>> branch). Resolve the conflict by intelligently combining changes from both sides.
|
||||
|
||||
Return your response in this exact format:
|
||||
EXPLANATION: <one line explaining what you chose and why>
|
||||
RESOLVED:
|
||||
<the full resolved file content with no conflict markers>
|
||||
|
||||
File with conflicts:
|
||||
${conflictContent}`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const block = response.content[0]
|
||||
if (block.type !== "text") throw new Error("Unexpected response from Claude")
|
||||
|
||||
const text = block.text
|
||||
const explMatch = text.match(/EXPLANATION:\s*(.+)/)
|
||||
const resolvedMatch = text.match(/RESOLVED:\n([\s\S]+)/)
|
||||
|
||||
if (!explMatch || !resolvedMatch) {
|
||||
throw new Error("Could not parse Claude's conflict resolution response")
|
||||
}
|
||||
|
||||
return {
|
||||
explanation: explMatch[1].trim(),
|
||||
resolved: resolvedMatch[1].trimEnd() + "\n",
|
||||
}
|
||||
}
|
||||
182
src/cli.ts
182
src/cli.ts
|
|
@ -1,28 +1,12 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
import { homedir } from "os"
|
||||
import { Command } from "commander"
|
||||
import { join } from "path"
|
||||
|
||||
// Load ~/.env into process.env
|
||||
const envFile = Bun.file(join(homedir(), ".env"))
|
||||
if (await envFile.exists()) {
|
||||
const text = await envFile.text()
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith("#")) continue
|
||||
const eq = trimmed.indexOf("=")
|
||||
if (eq === -1) continue
|
||||
const key = trimmed.slice(0, eq)
|
||||
const val = trimmed.slice(eq + 1).replace(/^["']|["']$/g, "")
|
||||
process.env[key] ??= val
|
||||
}
|
||||
}
|
||||
import * as git from "./git.ts"
|
||||
import * as vm from "./vm.ts"
|
||||
import * as state from "./state.ts"
|
||||
import { loadConfig } from "./config.ts"
|
||||
import { generateCommitMessage, resolveConflict } from "./ai.ts"
|
||||
|
||||
const pkg = await Bun.file(new URL("../package.json", import.meta.url)).json()
|
||||
|
||||
|
|
@ -66,129 +50,6 @@ program
|
|||
await vm.shell(vmId)
|
||||
})
|
||||
|
||||
// ── sandlot save [message] ────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("save")
|
||||
.argument("[message]", "commit message (auto-generated if omitted)")
|
||||
.description("Stage and commit all changes, push to origin")
|
||||
.action(async (message?: string) => {
|
||||
const root = await git.repoRoot()
|
||||
const config = await loadConfig(root)
|
||||
const branch = await detectBranch(root)
|
||||
const session = await state.getSession(root, branch)
|
||||
const cwd = session ? join(root, session.worktree) : root
|
||||
|
||||
if (!(await git.hasChanges(cwd))) {
|
||||
console.log("No changes to commit.")
|
||||
return
|
||||
}
|
||||
|
||||
const staged = await git.stageAll(cwd)
|
||||
console.log(`Staged ${staged} files`)
|
||||
|
||||
let commitMsg: string
|
||||
if (message) {
|
||||
commitMsg = message
|
||||
} else {
|
||||
const diff = await git.stagedDiff(cwd)
|
||||
commitMsg = await generateCommitMessage(diff, config.ai?.model ?? "claude-sonnet-4-20250514")
|
||||
}
|
||||
|
||||
await git.commit(commitMsg, cwd)
|
||||
const firstLine = commitMsg.split("\n")[0]
|
||||
console.log(`Commit: ${firstLine}`)
|
||||
|
||||
await git.push(branch, cwd)
|
||||
console.log(`Pushed ${branch} → origin/${branch}`)
|
||||
})
|
||||
|
||||
// ── sandlot push <target> ─────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("push")
|
||||
.argument("<target>", "target branch to merge into")
|
||||
.description("Merge session branch into target, then tear down")
|
||||
.action(async (target: string) => {
|
||||
const root = await git.repoRoot()
|
||||
const config = await loadConfig(root)
|
||||
const branch = await detectBranch(root)
|
||||
const session = await state.getSession(root, branch)
|
||||
|
||||
if (!session) {
|
||||
console.error(`No session found for branch "${branch}".`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const worktreeCwd = join(root, session.worktree)
|
||||
|
||||
// Check for uncommitted changes
|
||||
if (await git.hasChanges(worktreeCwd)) {
|
||||
console.error(`Uncommitted changes in worktree. Run "sandlot save" first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`Pushing ${branch} → ${target}...`)
|
||||
|
||||
// Checkout target in main working tree
|
||||
await git.checkout(target, root)
|
||||
|
||||
// Merge
|
||||
const merged = await git.merge(branch, root)
|
||||
|
||||
if (!merged) {
|
||||
// Handle conflicts
|
||||
const conflicts = await git.conflictedFiles(root)
|
||||
console.log(`${conflicts.length} conflicts found. Resolving with Claude...\n`)
|
||||
|
||||
const model = config.ai?.model ?? "claude-sonnet-4-20250514"
|
||||
const resolutions: Array<{ file: string; explanation: string }> = []
|
||||
|
||||
for (const file of conflicts) {
|
||||
const content = await git.conflictContent(file, root)
|
||||
const { resolved, explanation } = await resolveConflict(file, content, model)
|
||||
await Bun.write(join(root, file), resolved)
|
||||
await git.stageFile(file, root)
|
||||
resolutions.push({ file, explanation })
|
||||
}
|
||||
|
||||
for (const { file, explanation } of resolutions) {
|
||||
console.log(` ${file}`)
|
||||
console.log(` ✓ ${explanation}\n`)
|
||||
}
|
||||
|
||||
// Prompt for confirmation
|
||||
process.stdout.write("Accept Claude's resolutions? [Y/n] ")
|
||||
const answer = await readLine()
|
||||
|
||||
if (answer.toLowerCase() === "n") {
|
||||
await git.abortMerge(root)
|
||||
console.log("Merge aborted. Resolve conflicts manually, then run sandlot push again.")
|
||||
return
|
||||
}
|
||||
|
||||
await git.commitMerge(root)
|
||||
console.log("Committed merge")
|
||||
}
|
||||
|
||||
// Push target
|
||||
await git.push(target, root)
|
||||
console.log(`Pushed ${target} → origin/${target}`)
|
||||
|
||||
// Tear down
|
||||
await vm.destroy(session.vm_id)
|
||||
console.log(`Stopped VM ${branch}`)
|
||||
|
||||
await git.removeWorktree(join(root, session.worktree), root)
|
||||
console.log(`Removed worktree ${session.worktree}/`)
|
||||
|
||||
await git.deleteLocalBranch(branch, root)
|
||||
await git.deleteRemoteBranch(branch, root)
|
||||
console.log(`Deleted branch ${branch} (local + remote)`)
|
||||
|
||||
await state.removeSession(root, branch)
|
||||
})
|
||||
|
||||
// ── sandlot list ──────────────────────────────────────────────────────
|
||||
|
||||
program
|
||||
|
|
@ -285,12 +146,12 @@ program
|
|||
console.log(`Stopped VM for ${branch}`)
|
||||
})
|
||||
|
||||
// ── sandlot rm <branch> ───────────────────────────────────────────────
|
||||
// ── sandlot close <branch> ────────────────────────────────────────────
|
||||
|
||||
program
|
||||
.command("rm")
|
||||
.command("close")
|
||||
.argument("<branch>", "branch name")
|
||||
.description("Tear down a session without merging")
|
||||
.description("Tear down a session and switch back to main")
|
||||
.action(async (branch: string) => {
|
||||
const root = await git.repoRoot()
|
||||
const session = await state.getSession(root, branch)
|
||||
|
|
@ -310,40 +171,9 @@ program
|
|||
console.log(`Deleted local branch ${branch}`)
|
||||
|
||||
await state.removeSession(root, branch)
|
||||
|
||||
await git.checkout("main", root)
|
||||
console.log(`Switched to main`)
|
||||
})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Detect the current session branch from env or working directory. */
|
||||
async function detectBranch(root: string): Promise<string> {
|
||||
// Check env var first (set when inside a VM)
|
||||
if (process.env.SANDLOT_BRANCH) {
|
||||
return process.env.SANDLOT_BRANCH
|
||||
}
|
||||
|
||||
// Check if cwd is inside a worktree
|
||||
const cwd = process.cwd()
|
||||
const sandlotDir = join(root, ".sandlot")
|
||||
if (cwd.startsWith(sandlotDir)) {
|
||||
const rel = cwd.slice(sandlotDir.length + 1)
|
||||
const branch = rel.split("/")[0]
|
||||
if (branch) return branch
|
||||
}
|
||||
|
||||
// Fall back to current git branch
|
||||
return await git.currentBranch()
|
||||
}
|
||||
|
||||
/** Read a line from stdin. */
|
||||
function readLine(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
process.stdin.setRawMode?.(false)
|
||||
process.stdin.resume()
|
||||
process.stdin.once("data", (chunk) => {
|
||||
process.stdin.pause()
|
||||
resolve(chunk.toString().trim())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
program.parse()
|
||||
|
|
|
|||
|
|
@ -7,18 +7,12 @@ export interface VmConfig {
|
|||
mounts?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface SandlotConfig {
|
||||
vm?: VmConfig
|
||||
ai?: AiConfig
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: SandlotConfig = {
|
||||
vm: { image: "ubuntu:24.04" },
|
||||
ai: { model: "claude-sonnet-4-20250514" },
|
||||
}
|
||||
|
||||
export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
|
||||
|
|
@ -28,7 +22,6 @@ export async function loadConfig(repoRoot: string): Promise<SandlotConfig> {
|
|||
const userConfig = await file.json()
|
||||
return {
|
||||
vm: { ...DEFAULT_CONFIG.vm, ...userConfig.vm },
|
||||
ai: { ...DEFAULT_CONFIG.ai, ...userConfig.ai },
|
||||
}
|
||||
}
|
||||
return DEFAULT_CONFIG
|
||||
|
|
|
|||
68
src/git.ts
68
src/git.ts
|
|
@ -49,73 +49,7 @@ export async function deleteLocalBranch(branch: string, cwd: string): Promise<vo
|
|||
await $`git branch -D ${branch}`.cwd(cwd).nothrow()
|
||||
}
|
||||
|
||||
/** Delete a remote branch. */
|
||||
export async function deleteRemoteBranch(branch: string, cwd: string): Promise<void> {
|
||||
await $`git push origin --delete ${branch}`.cwd(cwd).nothrow().quiet()
|
||||
}
|
||||
|
||||
/** Stage all changes and return the number of staged files. */
|
||||
export async function stageAll(cwd: string): Promise<number> {
|
||||
await $`git add .`.cwd(cwd)
|
||||
const status = await $`git diff --cached --name-only`.cwd(cwd).text()
|
||||
const files = status.trim().split("\n").filter(Boolean)
|
||||
return files.length
|
||||
}
|
||||
|
||||
/** Check if there are any uncommitted changes (staged or unstaged). */
|
||||
export async function hasChanges(cwd: string): Promise<boolean> {
|
||||
const result = await $`git status --porcelain`.cwd(cwd).text()
|
||||
return result.trim().length > 0
|
||||
}
|
||||
|
||||
/** Get the diff of staged changes. */
|
||||
export async function stagedDiff(cwd: string): Promise<string> {
|
||||
return await $`git diff --cached`.cwd(cwd).text()
|
||||
}
|
||||
|
||||
/** Commit with a message. */
|
||||
export async function commit(message: string, cwd: string): Promise<void> {
|
||||
await $`git commit -m ${message}`.cwd(cwd)
|
||||
}
|
||||
|
||||
/** Push a branch to origin. */
|
||||
export async function push(branch: string, cwd: string): Promise<void> {
|
||||
await $`git push -u origin ${branch}`.cwd(cwd)
|
||||
}
|
||||
|
||||
/** Checkout a branch in a working tree. */
|
||||
/** Checkout a branch. */
|
||||
export async function checkout(branch: string, cwd: string): Promise<void> {
|
||||
await $`git checkout ${branch}`.cwd(cwd)
|
||||
}
|
||||
|
||||
/** Merge a branch into the current branch. Returns true if successful, false if conflicts. */
|
||||
export async function merge(branch: string, cwd: string): Promise<boolean> {
|
||||
const result = await $`git merge ${branch}`.cwd(cwd).nothrow()
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
/** Get list of conflicted files. */
|
||||
export async function conflictedFiles(cwd: string): Promise<string[]> {
|
||||
const result = await $`git diff --name-only --diff-filter=U`.cwd(cwd).text()
|
||||
return result.trim().split("\n").filter(Boolean)
|
||||
}
|
||||
|
||||
/** Get the conflict content of a file (with markers). */
|
||||
export async function conflictContent(filePath: string, cwd: string): Promise<string> {
|
||||
return await Bun.file(`${cwd}/${filePath}`).text()
|
||||
}
|
||||
|
||||
/** Stage a resolved file. */
|
||||
export async function stageFile(filePath: string, cwd: string): Promise<void> {
|
||||
await $`git add ${filePath}`.cwd(cwd)
|
||||
}
|
||||
|
||||
/** Commit a merge (no message needed, uses default merge message). */
|
||||
export async function commitMerge(cwd: string): Promise<void> {
|
||||
await $`git commit --no-edit`.cwd(cwd)
|
||||
}
|
||||
|
||||
/** Abort a merge. */
|
||||
export async function abortMerge(cwd: string): Promise<void> {
|
||||
await $`git merge --abort`.cwd(cwd)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user