252 lines
11 KiB
TypeScript
252 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
|
|
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
|
|
}
|
|
|
|
/** 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.")
|
|
}
|