sandlot/src/git.ts

202 lines
8.4 KiB
TypeScript

import { existsSync } from "fs"
import { rm } from "fs/promises"
import { $ } from "bun"
/** 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()
if (result.exitCode !== 0) {
throw new Error("Not a git repository. Run this command from inside a git repo.")
}
return result.text().trim()
}
/** Get the current branch name. */
export async function currentBranch(cwd?: string): Promise<string> {
const result = await $`git rev-parse --abbrev-ref HEAD`.cwd(cwd ?? ".").nothrow().quiet()
if (result.exitCode !== 0) {
throw new Error("Could not determine current branch.")
}
return result.text().trim()
}
/** Check if a branch exists locally or remotely. Returns "local", "remote", or null. */
export async function branchExists(branch: string, cwd?: string, opts?: { fetch?: boolean }): Promise<"local" | "remote" | null> {
const dir = cwd ?? "."
const local = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(dir).nothrow().quiet()
if (local.exitCode === 0) return "local"
if (opts?.fetch) {
await $`git fetch origin`.cwd(dir).nothrow().quiet()
}
const remote = await $`git show-ref --verify --quiet refs/remotes/origin/${branch}`.cwd(dir).nothrow().quiet()
if (remote.exitCode === 0) return "remote"
return null
}
/** Create a worktree for the given branch. */
export async function createWorktree(branch: string, worktreePath: string, cwd: string): Promise<void> {
// Clean up stale worktree path if it exists
if (existsSync(worktreePath)) {
await $`git worktree remove ${worktreePath} --force`.cwd(cwd).nothrow().quiet()
if (existsSync(worktreePath)) {
await rm(worktreePath, { recursive: true })
}
}
await $`git worktree prune`.cwd(cwd).nothrow().quiet()
const exists = await branchExists(branch, cwd, { fetch: true })
let result
if (exists === "local") {
result = await $`git worktree add ${worktreePath} ${branch}`.cwd(cwd).nothrow().quiet()
} else if (exists === "remote") {
result = await $`git worktree add ${worktreePath} -b ${branch} origin/${branch}`.cwd(cwd).nothrow().quiet()
} else {
// New branch from current HEAD
result = await $`git worktree add -b ${branch} ${worktreePath}`.cwd(cwd).nothrow().quiet()
}
if (result.exitCode !== 0) {
throw new Error(`Failed to create worktree for "${branch}": ${result.stderr.toString().trim()}`)
}
}
/** Remove a worktree. Silently succeeds if the worktree is already gone. */
export async function removeWorktree(worktreePath: string, cwd: string): Promise<void> {
const result = await $`git worktree remove ${worktreePath} --force`.cwd(cwd).nothrow().quiet()
if (result.exitCode !== 0) {
// Worktree may already be gone or stale — prune and clean up the directory
await $`git worktree prune`.cwd(cwd).nothrow().quiet()
if (existsSync(worktreePath)) {
await rm(worktreePath, { recursive: true })
}
}
}
/** Delete a local branch. */
export async function deleteLocalBranch(branch: string, cwd: string): Promise<void> {
await $`git branch -D ${branch}`.cwd(cwd).nothrow().quiet()
}
/** Checkout a branch. */
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()}`)
}
}
/** Merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */
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
const unmerged = await $`git diff --name-only --diff-filter=U`.cwd(cwd).nothrow().quiet().text()
const files = unmerged.trim().split("\n").filter(Boolean)
if (files.length > 0) return files
// Not a conflict — some other merge failure
throw new Error(`Failed to merge branch "${branch}": ${result.stderr.toString().trim()}`)
}
/** Squash-merge a branch into the current branch. Returns conflicted file paths, or empty array if clean. */
export async function squashMerge(branch: string, cwd: string): Promise<string[]> {
const result = await $`git merge --squash ${branch}`.cwd(cwd).nothrow().quiet()
if (result.exitCode === 0) return []
// Check for unmerged (conflicted) files
const unmerged = await $`git diff --name-only --diff-filter=U`.cwd(cwd).nothrow().quiet().text()
const files = unmerged.trim().split("\n").filter(Boolean)
if (files.length > 0) return files
// Not a conflict — some other merge failure
throw new Error(`Failed to squash-merge branch "${branch}": ${result.stderr.toString().trim()}`)
}
/** Commit staged changes with a message. */
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()}`)
}
}
/** Stage a file. */
export async function stageFile(file: string, cwd: string): Promise<void> {
await $`git add ${file}`.cwd(cwd).nothrow().quiet()
}
/** 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()}`)
}
}
/** Abort an in-progress merge. */
export async function abortMerge(cwd: string): Promise<void> {
await $`git merge --abort`.cwd(cwd).nothrow().quiet()
}
/** 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
const inProgress = await $`git -C ${cwd} rev-parse --verify --quiet REBASE_HEAD`.nothrow().quiet()
if (inProgress.exitCode === 0) {
throw new Error(`A rebase is already in progress. Run "git -C ${cwd} rebase --abort" to cancel it first.`)
}
const result = await $`git rebase ${onto}`.cwd(cwd).nothrow().quiet()
if (result.exitCode === 0) return []
const unmerged = await $`git diff --name-only --diff-filter=U`.cwd(cwd).nothrow().quiet().text()
const files = unmerged.trim().split("\n").filter(Boolean)
if (files.length > 0) return files
// No conflicts but rebase still failed — include stderr for diagnostics
const stderr = result.stderr.toString().trim()
throw new Error(`Rebase onto "${onto}" failed: ${stderr || "(no output from git)"}`)
}
/** Continue a rebase after resolving conflicts. Returns conflicted files for the next commit, or empty if done. */
export async function rebaseContinue(cwd: string): Promise<string[]> {
const result = await $`git -c core.editor=true rebase --continue`.cwd(cwd).nothrow().quiet()
if (result.exitCode === 0) return []
const unmerged = await $`git diff --name-only --diff-filter=U`.cwd(cwd).nothrow().quiet().text()
const files = unmerged.trim().split("\n").filter(Boolean)
if (files.length > 0) return files
throw new Error(`Rebase --continue failed: ${result.stderr.toString().trim()}`)
}
/** Abort an in-progress rebase. */
export async function rebaseAbort(cwd: string): Promise<void> {
await $`git rebase --abort`.cwd(cwd).nothrow().quiet()
}
/** Check if a worktree has uncommitted changes. */
export async function isDirty(worktreePath: string): Promise<boolean> {
const result = await $`git -C ${worktreePath} status --porcelain`.nothrow().quiet()
if (result.exitCode !== 0) return false
return result.text().trim().length > 0
}
/** Check if a branch has commits beyond main. */
export async function hasNewCommits(worktreePath: string): Promise<boolean> {
const main = await mainBranch(worktreePath)
const result = await $`git -C ${worktreePath} rev-list ${main}..HEAD --count`.nothrow().quiet()
if (result.exitCode !== 0) return false
return parseInt(result.text().trim(), 10) > 0
}
/** Detect the main branch name (main or master). */
export async function mainBranch(cwd?: string): Promise<string> {
const dir = cwd ?? "."
const result = await $`git -C ${dir} rev-parse --verify --quiet refs/heads/main`.nothrow().quiet()
return result.exitCode === 0 ? "main" : "master"
}