REBASE_HEAD can linger after an interrupted rebase, causing false positives. Checking rebase-merge/ and rebase-apply/ directories matches how git itself determines rebase state. Also surface rebase failures in the CLI spinner instead of letting them propagate as unhandled exceptions.
253 lines
11 KiB
TypeScript
253 lines
11 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<{ branchCreated: boolean }> {
|
|
// 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
|
|
let switchedFromBranch = false
|
|
if (exists === "local") {
|
|
const main = await mainBranch(cwd)
|
|
if (branch === main) {
|
|
throw new Error(`Cannot create a worktree for the main branch "${main}".`)
|
|
}
|
|
// If the branch is checked out in the main worktree, switch it to main first
|
|
if (await currentBranch(cwd) === branch) {
|
|
if (await isDirty(cwd)) {
|
|
throw new Error(`Cannot move branch "${branch}" to a worktree: the main worktree has uncommitted changes. Commit or stash them first.`)
|
|
}
|
|
await checkout(main, cwd)
|
|
switchedFromBranch = true
|
|
}
|
|
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) {
|
|
if (switchedFromBranch) await checkout(branch, cwd).catch(() => {})
|
|
throw new Error(`Failed to create worktree for "${branch}": ${result.stderr.toString().trim()}`)
|
|
}
|
|
return { branchCreated: exists !== "local" }
|
|
}
|
|
|
|
/** 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, opts?: { squash?: boolean }): Promise<string[]> {
|
|
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<string> {
|
|
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<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 (check for rebase state directories, not REBASE_HEAD which can be stale)
|
|
const rebaseMerge = (await $`git -C ${cwd} rev-parse --git-path rebase-merge`.nothrow().quiet().text()).trim()
|
|
const rebaseApply = (await $`git -C ${cwd} rev-parse --git-path rebase-apply`.nothrow().quiet().text()).trim()
|
|
if (existsSync(rebaseMerge) || existsSync(rebaseApply)) {
|
|
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
|
|
}
|
|
|
|
/** Find the merge base (common ancestor) between two refs. */
|
|
export async function mergeBase(ref1: string, ref2: string, cwd: string): Promise<string> {
|
|
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<string> {
|
|
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<string> {
|
|
const result = await $`git diff --stat --stat-width=68 ${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<string> {
|
|
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<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
|
|
}
|
|
|
|
/** Get the full unified diff of a branch vs main as a string. */
|
|
export async function branchDiff(branch: string, main: string, cwd: string): Promise<string> {
|
|
const result = await $`git diff --no-ext-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<string> {
|
|
const dir = cwd ?? "."
|
|
const main = await $`git -C ${dir} rev-parse --verify --quiet refs/heads/main`.nothrow().quiet()
|
|
if (main.exitCode === 0) return "main"
|
|
const master = await $`git -C ${dir} rev-parse --verify --quiet refs/heads/master`.nothrow().quiet()
|
|
if (master.exitCode === 0) return "master"
|
|
throw new Error("Could not detect main branch: neither \"main\" nor \"master\" exists.")
|
|
}
|