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 { 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 { 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 { // 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 { 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 { await $`git branch -D ${branch}`.cwd(cwd).nothrow().quiet() } /** Checkout a branch. */ export async function checkout(branch: string, cwd: string): Promise { 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, opts?: { squash?: boolean }): Promise { const cmd = opts?.squash ? $`git merge --squash ${branch}`.cwd(cwd).nothrow().quiet() : $`git merge ${branch}`.cwd(cwd).nothrow().quiet() const result = await cmd 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 const label = opts?.squash ? "squash-merge" : "merge" throw new Error(`Failed to ${label} branch "${branch}": ${result.stderr.toString().trim()}`) } /** Return the staged diff as text. */ export async function diffStaged(cwd: string): Promise { return await $`git diff --staged`.cwd(cwd).nothrow().quiet().text() } /** Commit staged changes with a message. */ export async function commit(message: string, cwd: string): Promise { 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 { await $`git add ${file}`.cwd(cwd).nothrow().quiet() } /** Finalize a merge commit after resolving conflicts. */ export async function commitMerge(cwd: string): Promise { 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 { 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 { // 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 { 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 { await $`git rebase --abort`.cwd(cwd).nothrow().quiet() } /** Check if a worktree has uncommitted changes. */ export async function isDirty(worktreePath: string): Promise { const result = await $`git -C ${worktreePath} status --porcelain`.nothrow().quiet() if (result.exitCode !== 0) return false return result.text().trim().length > 0 } /** Find the merge base (common ancestor) between two refs. */ export async function mergeBase(ref1: string, ref2: string, cwd: string): Promise { const result = await $`git merge-base ${ref1} ${ref2}`.cwd(cwd).nothrow().quiet() if (result.exitCode !== 0) { throw new Error(`Could not find merge base between "${ref1}" and "${ref2}"`) } return result.text().trim() } /** Get a one-line-per-commit log for a revision range. Returns empty string on failure. */ export async function commitLog(range: string, cwd: string): Promise { const result = await $`git log --oneline ${range}`.cwd(cwd).nothrow().quiet() if (result.exitCode !== 0) return "" return result.text().trim() } /** Get a diff stat summary for a revision range. Returns empty string on failure. */ export async function diffStat(range: string, cwd: string): Promise { const result = await $`git diff --stat ${range}`.cwd(cwd).nothrow().quiet() if (result.exitCode !== 0) return "" return result.text().trim() } /** Get the diff for a specific file between two refs. Returns empty string on failure. */ export async function fileDiff(ref1: string, ref2: string, file: string, cwd: string): Promise { const result = await $`git diff ${ref1} ${ref2} -- ${file}`.cwd(cwd).nothrow().quiet() if (result.exitCode !== 0) return "" return result.text().trim() } /** Check if a branch has commits beyond main. */ export async function hasNewCommits(worktreePath: string): Promise { 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 } /** Get the full unified diff of a branch vs main as a string. */ export async function branchDiff(branch: string, cwd: string): Promise { const main = await mainBranch(cwd) const result = await $`git diff ${main}...${branch}`.cwd(cwd).nothrow().quiet() if (result.exitCode !== 0) return "" return result.text() } /** Detect the main branch name (main or master). */ export async function mainBranch(cwd?: string): Promise { const dir = cwd ?? "." const result = await $`git -C ${dir} rev-parse --verify --quiet refs/heads/main`.nothrow().quiet() return result.exitCode === 0 ? "main" : "master" }